diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 82c27100..a68cfa8f 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,115 @@ Short deterministic smoke runs can also override the configured duration and mes ```python3 loraMesh.py 2 --no-gui --simtime-seconds 5 --period-seconds 0.5``` +The same headless path can import positioned real-mesh nodes. `--from-map` +reads a Meshtastic map `/api/v1/nodes` JSON endpoint; the public default is +`https://meshtastic.liamcottle.net/api/v1/nodes`, but you can pass another +compatible endpoint URL. These map endpoints usually return a broad node list, +so pass a local area-of-interest bounding box. `--map-bbox` uses the common +`min_lat,min_lon,max_lat,max_lon` order that most GIS tools call +`south,west,north,east`; you can copy those four numbers from OpenStreetMap's +Export panel, geojson.io's bbox readout, QGIS, or any other tool that shows the +extent of the map view or selected polygon. Keep the box tight enough for the +local scenario you want to simulate: + +```python3 loraMesh.py --from-map https://meshtastic.liamcottle.net/api/v1/nodes --map-bbox 41.50,41.50,41.82,41.86 --map-limit 50 --no-gui``` + +You can also import positioned nodes from the NodeDB cached by a local +Meshtastic device. This uses the Python client `interface.nodesByNum` data that +backs `meshtastic --nodes`, not the pretty-printed table. Use TCP for a network +device, or omit `--nodedb-host` to use Meshtastic serial auto-detection. For a +quick local-device run, pass the device address and cap the imported node count: + +```python3 loraMesh.py --from-nodedb --nodedb-host 192.168.1.23 --map-limit 50 --no-gui``` + +NodeDB often contains old or far-away positions. Add `--map-bbox` when you want +to restrict the run to one local area: + +```python3 loraMesh.py --from-nodedb --nodedb-host 192.168.1.23 --map-bbox 41.50,41.50,41.82,41.86 --map-limit 50 --no-gui``` + +Imported nodes use the same `HM` antenna height and `hopLimit` defaults as +generated and file-backed scenarios. Change those config values when the +position source does not carry the simulation value you want. + +Terrain obstruction can be added to map, NodeDB, or origin-backed scenario inputs +without creating a custom terrain file. `--terrain-srtm` downloads missing SRTM +HGT tiles from Mapzen Terrain Tiles on AWS into a local cache and feeds the +terrain grid directly into terrain-aware node geometry: + +```python3 loraMesh.py --from-nodedb --nodedb-host 192.168.1.23 --map-limit 50 --terrain-srtm --no-gui``` + +With an explicit `--map-bbox`, SRTM samples that whole requested rectangle. When +the terrain bbox is derived from imported or file-backed nodes, Meshtasticator +keeps the download smaller: it loads tiles around the selected nodes and along +flat-link candidate paths, instead of downloading every tile in a large +edge-to-edge rectangle. When publishing screenshots, reports, or derived +datasets from this terrain source, attribute the terrain data to +[Mapzen Terrain Tiles on AWS](https://registry.opendata.aws/terrain-tiles/), +SRTM/NASA, and their underlying open elevation sources: + +```python3 loraMesh.py --from-map https://meshtastic.liamcottle.net/api/v1/nodes --map-bbox 41.50,41.50,41.82,41.86 --map-limit 50 --terrain-srtm --no-gui``` + +Map payload `altitude` values are absolute GPS/MSL altitude, not antenna height, +so map import keeps using `HM` as the fallback antenna height above local +ground. When `--terrain-srtm` is enabled, each map node is checked +against its own SRTM ground sample: plausible positive map altitudes are used as +absolute node altitude, while missing, below-ground, or implausibly high values +fall back to `SRTM ground + antenna height` for 3D distance calculations. + +Land-cover clutter is a separate optional CSV grid. Use it for broad urban, +open, water, or forest excess-loss inputs without pretending Meshtasticator is a +building-level ray tracer: + +```python3 loraMesh.py --from-file nodeConfig.yaml --terrain-srtm --clutter-grid clutter.csv --no-gui``` + +`tools/osm_to_clutter_csv.py` can build a coarse clutter grid from public +OpenStreetMap building, landuse, natural, and water polygons. The simulator +never fetches OpenStreetMap data implicitly. + +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. + +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 bundled terrain grid, an OpenStreetMap-derived land-cover clutter grid, +and an aggregate radio calibration over generated path features. Terrain, +clutter, and the fitted link-calibration model are enabled automatically for the +preset; use `--terrain-srtm` for a fresh SRTM terrain sample, `--clutter-grid` +for a different land-cover grid, or `--no-clutter` for old-style comparison +runs. The calibration report is in `docs/batumi_radio_calibration.md`. + +```python3 loraMesh.py --preset batumi --no-gui --simtime-seconds 5 --period-seconds 2 --phy-loss-model --capture-collision-model``` + +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. + +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``` @@ -41,16 +150,27 @@ 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. + +DCR and DTP can be combined. DCR changes airtime and forward-error-correction +strength; DTP changes how many receivers can CAD-detect, demodulate, or collide +with the packet. ### Period Mean period (in ms) with which the nodes generate a new message following an exponential distribution. E.g. if you set it to 300s, each node will generate a message on average once every five minutes. diff --git a/README.md b/README.md index 55d14297..8a82b97f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,56 @@ # 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 +``` + +Run the radio-physics comparison workflow in one command: + +```bash +python3 tools/radio_policy_compare.py --policies static,dcr,dtp --simtime-seconds 60 --period-seconds 5 +``` + +For CI-style runs, write JSON/Markdown artifacts: + +```bash +python3 tools/radio_policy_compare.py \ + --simtime-seconds 120 \ + --period-seconds 5 \ + --json-output out/radio_policy_compare.json \ + --markdown-output out/radio_policy_compare.md +``` + +Threshold flags such as `--max-reach-drop-pp` are accepted only when a later +policy such as `dcr` or `dtp` is compared against the `static` baseline. + +For manual radio-physics experiments, keep the same scenario and traffic load +while enabling packet loss and capture-aware collisions: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ + --phy-loss-model --capture-collision-model +``` + +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/batchSim.py b/batchSim.py index d61e3ece..be06c00f 100644 --- a/batchSim.py +++ b/batchSim.py @@ -232,7 +232,7 @@ def __init__(self, conf, x, y): x, y = coords[nodeId] # We create a NodeConfig object so that MeshNode will use that - nodeConfig = NodeConfig(nodeId, Point(x, y, routerTypeConf.HM), routerTypeConf.PERIOD, antenna_gain=routerTypeConf.GL, hop_limit=routerTypeConf.hopLimit) + nodeConfig = NodeConfig(nodeId, Point(x, y, routerTypeConf.HM), routerTypeConf.PERIOD, routerTypeConf.PTX, routerTypeConf.FREQ, antenna_gain=routerTypeConf.GL, hop_limit=routerTypeConf.hopLimit) node_configs.append(nodeConfig) if SHOW_GRAPH: diff --git a/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/docs/radio_physics_quickstart.md b/docs/radio_physics_quickstart.md new file mode 100644 index 00000000..8acfb28b --- /dev/null +++ b/docs/radio_physics_quickstart.md @@ -0,0 +1,127 @@ +# 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 --policies static,dcr,dtp --simtime-seconds 60 --period-seconds 5 +``` + +It runs the same preset and traffic load for: + +- `static`: static coding rate with packet-loss and capture-collision physics. +- `dcr`: Dynamic Coding Rate on top of the same physics flags. +- `dtp`: Dynamic TX Power on top of the same physics flags. + +The output is one table with reach, useful traffic, airtime, collisions, PHY +loss, and placeholders for future policy counters. + +For CI, write durable artifacts: + +```bash +python3 tools/radio_policy_compare.py \ + --simtime-seconds 120 \ + --period-seconds 5 \ + --json-output out/radio_policy_compare.json \ + --markdown-output out/radio_policy_compare.md +``` + +Threshold flags such as `--max-reach-drop-pp` compare every non-baseline policy +against the first policy in `--policies`; they require at least two policies. +This lets CI fail when `dcr` or `dtp` loses too much reach/useful traffic or +spends too much extra TX airtime. The JSON file is intended for machines; the +Markdown file is intended for CI summaries, uploaded artifacts, or PR comments. + +Extra `loraMesh.py` flags can be applied to every run after `--`: + +```bash +python3 tools/radio_policy_compare.py --policies static,dcr,dtp -- --no-clutter +``` + +Enable packet-level loss and capture-aware collisions when testing radio +physics. Those two flags make weak links and overlapping transmissions matter: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ + --phy-loss-model --capture-collision-model +``` + +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. + +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 + +- `--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 packet-policy comparisons once more policy flags are + available. +- Short runs are noisy. Use longer runs or repeated runs before claiming that a + policy is better. +- Treat CI thresholds as guardrails, not proof of RF truth. A failed threshold + means "inspect this change"; a passed threshold means "no regression in this + fixed simulator scenario". 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/common.py b/lib/common.py index b8a639bf..36970e96 100644 --- a/lib/common.py +++ b/lib/common.py @@ -1,11 +1,16 @@ import random -import os import numpy as np from lib import phy +from lib.link_model import calculate_link_budget 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 @@ -52,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))) @@ -60,6 +64,7 @@ def setup_asymmetric_links(conf, nodes): """updates conf to populate LINK_OFFSET member to simulate asymmetric links """ asymLinkRng = random.Random(conf.SEED) + conf.LINK_OFFSET = {} totalPairs = 0 symmetricLinks = 0 asymmetricLinks = 0 @@ -75,20 +80,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, nodeA.position.z, nodeB.position.z) - - 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 + budgetAB = calculate_link_budget(conf, nodeA, nodeB, conf.LINK_OFFSET[(a, b)]) + budgetBA = calculate_link_budget(conf, nodeB, nodeA, conf.LINK_OFFSET[(b, a)]) - 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 8765daa8..cde426ac 100644 --- a/lib/config.py +++ b/lib/config.py @@ -38,7 +38,46 @@ 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 + + ################################################# + ####### 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 + + ################################################# + ####### 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": { @@ -398,9 +437,82 @@ 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 ### + ################################################# + ####### TERRAIN OBSTRUCTION MODEL ############### + ################################################# + # Disabled by default. When enabled, TERRAIN_GRID holds an in-memory + # grid sampled from SRTM HGT tiles. + self.TERRAIN_ENABLED = False + self.TERRAIN_GRID = None + # "ground": Point.z is antenna height above local ground. + # "sea_level": Point.z is absolute antenna altitude after adding + # terrain ground elevation. + self.NODE_Z_REFERENCE = "ground" + self.GEO_ORIGIN_LAT = None + self.GEO_ORIGIN_LON = None + self.TERRAIN_PROFILE_SAMPLES = 24 + self.TERRAIN_FRESNEL_CLEARANCE = 0.6 + # Match the common radio-planning 4/3 Earth-radius approximation. The + # terrain model uses it as an earth-bulge term so long coastal and ridge + # links do not look unrealistically flat. + self.TERRAIN_EFFECTIVE_EARTH_RADIUS_MULTIPLIER = 4.0 / 3.0 + self.TERRAIN_MIN_ANTENNA_HEIGHT_M = 1.5 + self.TERRAIN_MAX_LOSS_DB = 35.0 + + ################################################# + ####### LAND-COVER CLUTTER MODEL ################ + ################################################# + # Optional excess loss from buildings/land use. This is intentionally + # separate from terrain: hills can be visible while low urban fabric + # still blocks balcony-to-balcony links. + self.CLUTTER_ENABLED = False + self.CLUTTER_GRID_FILE = None + self.CLUTTER_PROFILE_SAMPLES = 16 + self.CLUTTER_URBAN_LOSS_DB_PER_KM = 4.0 + self.CLUTTER_SUBURBAN_LOSS_DB_PER_KM = 2.0 + self.CLUTTER_FOREST_LOSS_DB_PER_KM = 2.5 + self.CLUTTER_OPEN_LOSS_DB_PER_KM = 0.2 + self.CLUTTER_WATER_LOSS_DB_PER_KM = 0.0 + self.CLUTTER_URBAN_ENDPOINT_LOSS_DB = 3.0 + self.CLUTTER_HIGH_VANTAGE_ELEVATION_M = 120.0 + self.CLUTTER_HIGH_VANTAGE_LOSS_FACTOR = 0.35 + self.CLUTTER_COASTAL_PATH_LOSS_FACTOR = 0.25 + self.CLUTTER_COASTAL_SAMPLE_FRACTION = 0.55 + self.CLUTTER_MAX_LOSS_DB = 25.0 + + ################################################# + ####### 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/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/dcr.py b/lib/dcr.py new file mode 100644 index 00000000..a779f963 --- /dev/null +++ b/lib/dcr.py @@ -0,0 +1,211 @@ +"""Dynamic Coding Rate policy for Meshtasticator experiments. + +The firmware idea is to choose LoRa coding rate very late, after queueing and +listen-before-talk. The simulator mirrors that shape: this module changes only +the packet's physical CR and resulting airtime immediately before low-level +transmit. + +With the default PHY model this is mostly an airtime/collision-pressure study. +When the empirical PHY-loss model is enabled, the selected CR also changes the +payload decode probability near weak-link edges. +""" + +from dataclasses import dataclass + +from lib.packet import NODENUM_BROADCAST + + +CR_SLIM = 5 +CR_NORMAL = 6 +CR_ROBUST = 7 +CR_RESCUE = 8 + + +@dataclass(frozen=True) +class DcrDecision: + cr: int + reason: str + + +def _clamp_cr(cr: int, min_cr: int, max_cr: int) -> int: + return max(min_cr, min(max_cr, cr)) + + +def _score_to_cr(score: int) -> int: + if score <= 0: + return CR_SLIM + if score == 1: + return CR_NORMAL + if score == 2: + return CR_ROBUST + return CR_RESCUE + + +def _selected_region_duty_limit(conf) -> float | None: + """Return a legal duty-cycle limit only when the region actually has one. + + Regions with 100% duty cycle are effectively unrestricted for this policy. + Avoid inventing a local fallback threshold there; channel congestion and + regulatory duty-cycle pressure are separate signals. + """ + duty_cycle = conf.REGION.get("duty_cycle", 100) + if 0 < duty_cycle < 100: + return float(duty_cycle) + return None + + +def _node_queue_depth(node) -> int: + """Best-effort count of packets waiting behind the current transmitter slot.""" + return len(getattr(node.transmitter, "queue", [])) + + +def _current_channel_utilization_percent(node) -> float: + """Return rolling channel utilization including the active 10-second bucket.""" + completed_util = node.channel_utilization_percent() + current_bucket_airtime = max(0.0, node.txAirUtilization - node.prevTxAirUtilization) + current_bucket_util = ( + current_bucket_airtime + / (node.conf.CHANNEL_UTILIZATION_PERIODS * node.conf.TEN_SECONDS_INTERVAL) + * 100.0 + ) + return completed_util + current_bucket_util + + +def classify_channel_pressure(node) -> tuple[str, float, int]: + """Classify mesh pressure from existing simulator signals. + + The thresholds describe local simulated congestion, not legal limits. + Regulatory pressure is handled separately by `_selected_region_duty_limit`. + """ + util = _current_channel_utilization_percent(node) + queue_depth = _node_queue_depth(node) + + if util >= node.conf.DCR_CONGESTED_UTIL_PERCENT or queue_depth >= node.conf.DCR_CONGESTED_QUEUE_DEPTH: + return "congested", util, queue_depth + + if util >= node.conf.DCR_BUSY_UTIL_PERCENT or queue_depth >= node.conf.DCR_BUSY_QUEUE_DEPTH: + return "busy", util, queue_depth + + if util <= node.conf.DCR_IDLE_UTIL_PERCENT and queue_depth <= 1: + return "idle", util, queue_depth + + return "normal", util, queue_depth + + +def _base_packet_score(packet) -> tuple[int, list[str]]: + """Approximate packet classes with fields Meshtasticator currently has. + + Generated traffic does not carry Meshtastic portnums, app priority, or + telemetry/user-message classes. ACKs are the only control class visible + without adding synthetic app metadata. + """ + if packet.isAck: + return 1, ["control_ack"] + + # Keep first attempts compact and let retry/link/context signals justify + # spending extra FEC. This avoids making idle background floods fatter by + # default in dense public-mesh style runs. + return 0, ["user"] + + +def _retry_score(node, packet, pressure: str, util: float) -> tuple[int, list[str]]: + attempt = max(0, node.conf.maxRetransmission - packet.retransmissions) + if attempt == 0: + return 0, [] + + if pressure in ("busy", "congested"): + return 0, [f"retry_{attempt}_no_fec_bump_channel_{pressure}"] + + duty_limit = _selected_region_duty_limit(node.conf) + if duty_limit is not None and util >= duty_limit: + return 0, [f"retry_{attempt}_no_fec_bump_duty_limit"] + + # Later attempts after quiet loss are the intentional robustness spend: + # a normal retry moves generic user traffic to CR6, while a final quiet + # retry can still reach CR8 when the budget allows it. + final_retry = packet.retransmissions <= 1 + return (3 if final_retry else 1), [f"retry_{attempt}_quiet_loss"] + + +def _relay_score(packet) -> tuple[int, list[str]]: + if packet.txNodeId == packet.origTxNodeId: + return 0, [] + + score = -1 + reasons = ["generic_relay"] + + if packet.hopLimit <= 1: + # Last-hop relay may be the final useful chance for this packet, but it + # still should not jump to CR8 without retry/link evidence. + score += 2 + reasons.append("last_hop") + + return score, reasons + + +def _cr8_budget_allows(node, packet, candidate_cr: int) -> bool: + if candidate_cr != CR_RESCUE: + return True + + total_airtime = node.txAirUtilization + if total_airtime <= 0: + return True + + candidate_airtime = packet.airtime_for_cr(candidate_cr) + cr8_airtime = node.dcrAirtimeByCr.get(CR_RESCUE, 0.0) + candidate_airtime + return (cr8_airtime / total_airtime * 100.0) <= node.conf.DCR_CR8_AIRTIME_LIMIT_PERCENT + + +def choose_dynamic_coding_rate(node, packet) -> DcrDecision: + """Choose a per-packet CR using only information the simulator already has.""" + if not node.conf.DCR_ENABLED: + return DcrDecision(packet.cr, "dcr_off") + + score, reasons = _base_packet_score(packet) + pressure, util, queue_depth = classify_channel_pressure(node) + + if pressure == "idle": + # Idle air is reserve, not automatic permission to fatten every first + # attempt. Retry/control scoring below is where quiet-air robustness is + # intentionally spent. + reasons.append("idle_no_first_attempt_bump") + elif pressure == "busy": + score -= 1 + elif pressure == "congested": + score -= 2 + reasons.append(f"channel_{pressure}") + + retry_delta, retry_reasons = _retry_score(node, packet, pressure, util) + score += retry_delta + reasons.extend(retry_reasons) + + relay_delta, relay_reasons = _relay_score(packet) + score += relay_delta + reasons.extend(relay_reasons) + + min_cr = max(node.conf.DCR_MIN_CR, node.conf.DCR_USER_MIN_CR) + if ( + not packet.isAck + and getattr(packet, "destId", NODENUM_BROADCAST) != NODENUM_BROADCAST + and packet.txNodeId != packet.origTxNodeId + and pressure not in ("busy", "congested") + ): + # Direct destination plus a relay hop is real header-level context. It + # is valuable enough to avoid the thinnest CR when local air is not + # busy, while origin-hop and busy direct floods can remain compact. + min_cr = max(min_cr, CR_NORMAL) + reasons.append("direct_relay_min_cr") + + if packet.isAck: + # ACKs are tiny and important, but should still not become CR8 storms. + min_cr = max(min_cr, CR_NORMAL) + + cr = _clamp_cr(_score_to_cr(score), min_cr, node.conf.DCR_MAX_CR) + + if not _cr8_budget_allows(node, packet, cr): + cr = _clamp_cr(CR_ROBUST, min_cr, node.conf.DCR_MAX_CR) + reasons.append("cr8_budget_clamp") + + reasons.append(f"util={util:.1f}") + reasons.append(f"queue={queue_depth}") + return DcrDecision(cr, ",".join(reasons)) diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 2eedc3a7..408a9b4e 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -6,6 +6,7 @@ from simpy import Environment as SimpyEnvironment import numpy as np +from lib.common import setup_asymmetric_links from lib.config import Config from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking from lib.node import MeshNode, NodeConfig @@ -68,9 +69,40 @@ 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 + and p.sensedByN[n.nodeid] is True + and p.collidedAtN[n.nodeid] is False + ]) + 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: @@ -95,6 +127,28 @@ 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) + } + dtp_tx_count = sum(getattr(n, "dtpTxCount", 0) for n in nodes) + self.results["dtpTxByPower"] = {} + self.results["dtpTxByCrPower"] = {} + for n in nodes: + for power, count in getattr(n, "dtpTxByPower", {}).items(): + self.results["dtpTxByPower"][power] = self.results["dtpTxByPower"].get(power, 0) + count + for cr_power, count in getattr(n, "dtpTxByCrPower", {}).items(): + self.results["dtpTxByCrPower"][cr_power] = self.results["dtpTxByCrPower"].get(cr_power, 0) + count + self.results["dtpMeanDetectedByTx"] = ( + sum(getattr(n, "dtpDetectedByTx", 0) for n in nodes) / dtp_tx_count if dtp_tx_count else 0.0 + ) + self.results["dtpMeanSensedByTx"] = ( + sum(getattr(n, "dtpSensedByTx", 0) for n in nodes) / dtp_tx_count if dtp_tx_count else 0.0 + ) if self.results["totalPairs"] != 0: noLinkRate = self.results["noLinks"] / self.results["totalPairs"] @@ -141,6 +195,7 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non # link counts, and thus do an O(n^2) precomputation anyways, just # always do this and reserve checking/not checking the map later based # on config settings. + setup_asymmetric_links(self.conf, self.node_configs) self.initialize_connectivity_map() # node configs provided, create nodes with them diff --git a/lib/dtp.py b/lib/dtp.py new file mode 100644 index 00000000..16fb9360 --- /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 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 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: + # DTP should shrink interference, not sabotage the final retry. CR can + # help payload reliability, but it cannot recover packets pushed below + # preamble/header sensitivity by excessive power reduction. + reasons.append("max_power_final_retry") + 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/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/link_model.py b/lib/link_model.py new file mode 100644 index 00000000..2c1c6663 --- /dev/null +++ b/lib/link_model.py @@ -0,0 +1,119 @@ +"""Shared link-budget calculation for generated radio pairs. + +Packet construction, asymmetric-link reporting, and the interactive helper all +need the same answer: what RSSI/SNR does this TX/RX pair get after distance, +terrain, clutter, antenna gains, optional asymmetry, and optional field +calibration? Keeping that in one helper prevents calibration code from becoming +a hidden side path in only one simulator mode. +""" + +import math +from dataclasses import dataclass + +from lib.clutter import clutter_obstruction_loss, clutter_path_features +from lib.phy import estimate_path_loss +from lib.radio_loss import apply_link_calibration, estimate_snr +from lib.terrain import terrain_ground_elevation, terrain_obstruction_loss + + +@dataclass(frozen=True) +class LinkBudget: + distance_m: float + base_path_loss_db: float + terrain_loss_db: float + clutter_loss_db: float + offset_db: float + raw_rssi_dbm: float + rssi_dbm: float + snr_db: float + features: dict + + @property + def path_loss_db(self): + return self.base_path_loss_db + self.terrain_loss_db + self.clutter_loss_db + self.offset_db + + @property + def calibrated_path_loss_db(self): + return self.path_loss_db + self.raw_rssi_dbm - self.rssi_dbm + + +def _antenna_gain(node): + """Accept both MeshNode.antennaGain and NodeConfig.antenna_gain.""" + return getattr(node, "antennaGain", getattr(node, "antenna_gain", 0.0)) + + +def _antenna_height(node): + """Accept runtime and config antenna height above local ground.""" + return getattr(node, "antennaHeight", getattr(node, "antenna_height", node.position.z)) + + +def _link_calibration_features(conf, tx_point, rx_point, raw_snr, terrain_loss, clutter_loss): + """Build the reusable feature vector consumed by fitted calibration. + + These are path-shape features, not node-pair identities. Coefficients fitted + from one real mesh can therefore be applied to newly generated node pairs or + to nearby meshes that have terrain/clutter inputs but no observed links. + """ + horizontal_distance_m = max(1.0, math.hypot(rx_point.x - tx_point.x, rx_point.y - tx_point.y)) + log_distance_km = math.log10(horizontal_distance_m / 1000.0) + + tx_ground = terrain_ground_elevation(conf, tx_point) + rx_ground = terrain_ground_elevation(conf, rx_point) + grounds = [ground for ground in (tx_ground, rx_ground) if ground is not None] + max_ground_m = max(grounds) if grounds else 0.0 + min_ground_m = min(grounds) if grounds else 0.0 + ground_delta_m = abs((tx_ground or 0.0) - (rx_ground or 0.0)) if len(grounds) == 2 else 0.0 + high_vantage = 1.0 if max_ground_m >= conf.CLUTTER_HIGH_VANTAGE_ELEVATION_M else 0.0 + + clutter_features = clutter_path_features(conf, tx_point, rx_point) + urban_fraction = clutter_features["urban_fraction"] + + features = { + "raw_snr_clip": max(-120.0, min(10.0, raw_snr)), + "log_distance_km": log_distance_km, + "log_distance_km_sq": log_distance_km * log_distance_km, + "terrain_loss_db": terrain_loss, + "clutter_loss_db": clutter_loss, + "terrain_high_vantage_loss_db": terrain_loss * high_vantage, + "clutter_urban_loss_db": clutter_loss * urban_fraction, + "max_ground_elevation_100m": max_ground_m / 100.0, + "min_ground_elevation_100m": min_ground_m / 100.0, + "ground_delta_100m": ground_delta_m / 100.0, + "high_vantage": high_vantage, + **clutter_features, + } + return features + + +def calculate_link_budget(conf, tx_node, rx_node, offset_db=0.0, tx_power_dbm=None): + """Calculate raw and calibrated radio budget for one directed pair.""" + tx_point = tx_node.position + rx_point = rx_node.position + distance_m = tx_point.euclidean_distance(rx_point) + base_loss = estimate_path_loss(conf, distance_m, conf.FREQ, _antenna_height(tx_node), _antenna_height(rx_node)) + terrain_loss = terrain_obstruction_loss(conf, tx_point, rx_point, conf.FREQ) + clutter_loss = clutter_obstruction_loss(conf, tx_point, rx_point) + + raw_path_loss = base_loss + terrain_loss + clutter_loss + offset_db + + # Keep packet delivery and link-summary statistics on the same budget. The + # TX endpoint contributes radiated antenna gain, while the RX endpoint + # contributes receive antenna gain; terrain/clutter/calibration are path + # properties layered around those endpoint gains. + tx_power = conf.PTX if tx_power_dbm is None else tx_power_dbm + raw_rssi = tx_power + _antenna_gain(tx_node) + _antenna_gain(rx_node) - raw_path_loss + raw_snr = raw_rssi - conf.NOISE_LEVEL + features = _link_calibration_features(conf, tx_point, rx_point, raw_snr, terrain_loss, clutter_loss) + rssi = apply_link_calibration(conf, raw_rssi, features) + + return LinkBudget( + distance_m=distance_m, + base_path_loss_db=base_loss, + terrain_loss_db=terrain_loss, + clutter_loss_db=clutter_loss, + offset_db=offset_db, + raw_rssi_dbm=raw_rssi, + rssi_dbm=rssi, + snr_db=estimate_snr(conf, rssi), + features=features, + ) diff --git a/lib/mac.py b/lib/mac.py index 46befce2..a8d42c22 100644 --- a/lib/mac.py +++ b/lib/mac.py @@ -2,6 +2,7 @@ import random from lib.phy import airtime, get_current_slot_time +from lib.radio_loss import estimate_snr logger = logging.getLogger(__name__) @@ -19,7 +20,9 @@ def set_transmit_delay(node, packet): # from RadioLibInterface::setTransmitDela def get_tx_delay_msec_weighted(node, rssi): # from RadioInterface::getTxDelayMsecWeighted - snr = rssi - node.conf.NOISE_LEVEL + # Use the same reported-SNR estimate as the packet-loss model so calibrated + # presets do not drive relay delay from an impossible near-field SNR tail. + snr = estimate_snr(node.conf, rssi) SNR_MIN = -20 SNR_MAX = 10 slot_time_msec = get_current_slot_time() @@ -53,8 +56,9 @@ def get_tx_delay_msec(node): # from RadioInterface::getTxDelayMsec def get_retransmission_msec(node, packet): # from RadioInterface::getRetransmissionMsec - preset = node.conf.current_preset - packetAirtime = int(airtime(node.conf, preset["sf"], preset["cr"], packet.packetLen, preset["bw"])) + # Retransmission timeout has to follow the physical airtime of the packet + # that was actually sent. With DCR disabled this is still the preset CR. + packetAirtime = int(airtime(node.conf, packet.sf, packet.cr, packet.packetLen, packet.bw)) channelUtil = node.airUtilization / node.env.now * 100 CWsize = int(channelUtil * (CWmax - CWmin) / 100 + CWmin) return 2 * packetAirtime + (2 ** CWsize + 2 * CWmax + 2 ** (int((CWmax + CWmin) / 2))) * get_current_slot_time() + PROCESSING_TIME_MSEC diff --git a/lib/map_input.py b/lib/map_input.py new file mode 100644 index 00000000..114eb16d --- /dev/null +++ b/lib/map_input.py @@ -0,0 +1,220 @@ +"""Input adapter for public Meshtastic map and positioned node locations. + +The public map is useful as a location source, but it is not a simulator data +model. This adapter only converts map nodes with valid positions into the same +NodeConfig shape the GUI YAML path already uses. Link quality, terrain, and PER +remain simulator concerns configured elsewhere. +""" + +import json +import math +import statistics +import urllib.error +import urllib.request + +from lib.geo import valid_lat_lon +from lib.node import NodeConfig +from lib.terrain import latlon_to_xy + + +DEFAULT_MAP_NODES_URL = "https://meshtastic.liamcottle.net/api/v1/nodes" + + +def decode_map_coordinate(value): + """Decode Meshtastic map integer coordinates into decimal degrees.""" + if value is None: + return None + coordinate = float(value) + if abs(coordinate) > 180: + coordinate /= 1e7 + return coordinate + + +def decode_map_altitude(value): + """Return a finite positive map altitude in meters, or None for placeholders.""" + if value is None: + return None + altitude = float(value) + if not math.isfinite(altitude) or altitude <= 0: + return None + return altitude + + +def parse_bbox(value): + """Parse `min_lat,min_lon,max_lat,max_lon` into a numeric tuple.""" + parts = [part.strip() for part in value.split(",")] + if len(parts) != 4: + raise ValueError("map bbox must be min_lat,min_lon,max_lat,max_lon") + + min_lat, min_lon, max_lat, max_lon = [float(part) for part in parts] + if not all( + math.isfinite(value) + for value in (min_lat, min_lon, max_lat, max_lon) + ): + raise ValueError("map bbox values must be finite") + if not valid_lat_lon(min_lat, min_lon) or not valid_lat_lon(max_lat, max_lon): + raise ValueError("map bbox values must be valid latitude/longitude degrees") + if min_lat > max_lat or min_lon > max_lon: + raise ValueError("map bbox minimums must be less than maximums") + return min_lat, min_lon, max_lat, max_lon + + +def fetch_map_payload(url=DEFAULT_MAP_NODES_URL): + request = urllib.request.Request(url, headers={ + "User-Agent": "Meshtasticator map input", + "Accept": "application/json", + }) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as err: + raise ValueError(f"could not fetch map payload from {url}: {err}") from err + + +def role_name_for_node(node): + role_name = node.get("role_name") + if role_name: + return str(role_name).upper() + + # Fallback for map rows where the numeric role is known but the name is not + # populated. Public map rows may carry this as either an integer or a string. + # Unrecognized roles stay CLIENT-like unless explicitly mapped. + try: + role_value = int(node.get("role")) + except (TypeError, ValueError): + role_value = node.get("role") + + return { + 1: "CLIENT_MUTE", + 2: "ROUTER", + 3: "ROUTER_CLIENT", + 4: "REPEATER", + 11: "ROUTER_LATE", + 12: "CLIENT_BASE", + }.get(role_value, "CLIENT") + + +def payload_nodes(payload): + """Return node rows from accepted public-map payload shapes. + + Current map data is normally wrapped as {"nodes": [...]}, but accepting a + top-level list keeps tests and cached exports from needing a fake envelope. + """ + if isinstance(payload, dict): + nodes = payload.get("nodes", []) + elif isinstance(payload, list): + nodes = payload + else: + raise ValueError("map payload must be a JSON object with nodes or a node list") + + if not isinstance(nodes, list): + raise ValueError("map payload nodes must be a list") + return nodes + + +def filter_positioned_map_nodes(nodes, bbox=None): + positioned = [] + for node in nodes: + if not isinstance(node, dict): + continue + + try: + lat = decode_map_coordinate(node.get("latitude")) + lon = decode_map_coordinate(node.get("longitude")) + except (TypeError, ValueError): + continue + if lat is None or lon is None: + continue + if not valid_lat_lon(lat, lon): + continue + + if bbox is not None: + min_lat, min_lon, max_lat, max_lon = bbox + if not (min_lat <= lat <= max_lat and min_lon <= lon <= max_lon): + continue + + positioned.append((node, lat, lon)) + return positioned + + +def node_configs_from_positioned_rows( + positioned, + period, + antenna_height=1.5, + hop_limit=3, + tx_power=30, + freq=902e6, + origin=None, + return_origin=False, +): + """Build NodeConfig objects from `(node, lat, lon)` positioned rows.""" + if origin is None: + origin_lat = statistics.median([lat for _, lat, _ in positioned]) + origin_lon = statistics.median([lon for _, _, lon in positioned]) + else: + try: + origin_lat, origin_lon = (float(origin[0]), float(origin[1])) + except (TypeError, ValueError, IndexError) as err: + raise ValueError("map origin must be valid finite latitude/longitude degrees") from err + + if not valid_lat_lon(origin_lat, origin_lon): + raise ValueError("map origin must be valid finite latitude/longitude degrees") + + configs = [] + origin_tuple = (origin_lat, origin_lon) + for sim_node_id, (node, lat, lon) in enumerate(positioned): + x, y = latlon_to_xy(lat, lon, origin_lat, origin_lon) + role_name = role_name_for_node(node) + node_dict = { + "x": round(x, 2), + "y": round(y, 2), + # Meshtastic map altitude is absolute altitude, not antenna height. + # Keep z as antenna height unless SRTM is present to sanity-check + # and apply the optional absolute altitude per node. + "z": antenna_height, + "absoluteAltitude": decode_map_altitude(node.get("altitude")), + "isRouter": role_name in {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE"}, + "isRepeater": role_name == "REPEATER", + "isClientMute": role_name == "CLIENT_MUTE", + "hopLimit": hop_limit, + "antennaGain": 0, + "neighborInfo": False, + } + configs.append(NodeConfig.from_gen_scenario_output(sim_node_id, node_dict, period, tx_power, freq)) + + if return_origin: + return configs, origin_tuple + return configs + + +def node_configs_from_map_payload( + payload, + period, + bbox=None, + limit=None, + antenna_height=1.5, + hop_limit=3, + tx_power=30, + freq=902e6, + origin=None, + return_origin=False, +): + """Build NodeConfig objects from a Meshtastic map `/api/v1/nodes` payload.""" + positioned = filter_positioned_map_nodes(payload_nodes(payload), bbox) + if limit is not None: + if limit < 1: + raise ValueError("map limit must be at least 1") + positioned = positioned[:limit] + if not positioned: + raise ValueError("map payload produced no positioned nodes") + + return node_configs_from_positioned_rows( + positioned, + period, + antenna_height=antenna_height, + hop_limit=hop_limit, + tx_power=tx_power, + freq=freq, + origin=origin, + return_origin=return_origin, + ) diff --git a/lib/node.py b/lib/node.py index 459eab30..d041ca61 100644 --- a/lib/node.py +++ b/lib/node.py @@ -8,12 +8,17 @@ 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.dtp import choose_dynamic_tx_power +from lib.geo import valid_lat_lon +from lib.link_model import calculate_link_budget from lib.mac import set_transmit_delay, get_retransmission_msec from lib.phy import check_collision, is_channel_active, airtime from lib.packet import NODENUM_BROADCAST, MeshPacket, MeshMessage -from lib.phy import estimate_path_loss from lib.point import Point +from lib.radio_loss import estimate_snr +from lib.terrain import NODE_Z_REFERENCE_SEA_LEVEL, apply_terrain_altitude logger = logging.getLogger(__name__) @@ -59,7 +64,7 @@ def get_stats_dictionary(self) -> dict: class NodeConfig: """Specific configuration for a node """ - def __init__(self, node_id: int, position: Point, period: int, tx_power: int, freq: float, role: MESHTASTIC_ROLE = MESHTASTIC_ROLE.CLIENT, antenna_gain: float = 0, hop_limit: int = 3, neighbor_info: bool = False): + def __init__(self, node_id: int, position: Point, period: int, tx_power: int = 30, freq: float = 902e6, role: MESHTASTIC_ROLE = MESHTASTIC_ROLE.CLIENT, antenna_gain: float = 0, hop_limit: int = 3, neighbor_info: bool = False, antenna_height=None, absolute_altitude=None): """Initial configuration of a node Arguments: @@ -72,6 +77,8 @@ def __init__(self, node_id: int, position: Point, period: int, tx_power: int, fr antenna_gain -- antenna gain in dBi. Default 0 hop_limit -- hop limit. Default 3 neighbor_info -- if neighbor info is enabled. Default False + antenna_height -- antenna height above local ground. Default: position.z + absolute_altitude -- optional map-reported absolute altitude in meters """ self.node_id = node_id self.position = position.copy() # make sure we keep our own point @@ -82,6 +89,8 @@ def __init__(self, node_id: int, position: Point, period: int, tx_power: int, fr self.antenna_gain = antenna_gain self.hop_limit = hop_limit self.neighbor_info = neighbor_info + self.antenna_height = position.z if antenna_height is None else antenna_height + self.absolute_altitude = absolute_altitude @classmethod def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int, tx_power: int, freq: float): @@ -118,7 +127,9 @@ def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int, tx_p else: role = MESHTASTIC_ROLE.CLIENT - return NodeConfig(node_id, position, period, tx_power, freq, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo']) + antenna_height = nd.get("antennaHeight", nd["z"]) + absolute_altitude = nd.get("absoluteAltitude") + return NodeConfig(node_id, position, period, tx_power, freq, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo'], antenna_height, absolute_altitude) def compute_rssi_and_pathloss_to(self, rx_nodeconf, conf: Config) -> (float, float): """Compute RSSI and pathloss from this node config as the transmitting node @@ -134,12 +145,68 @@ def compute_rssi_and_pathloss_to(self, rx_nodeconf, conf: Config) -> (float, flo if self.node_id == rx_nodeconf.node_id: raise ValueError(f"Calculating rssi/pathloss between identical nodes is invalid. Node ID {self.node_id}") - # compute path loss - dist = self.position.euclidean_distance(rx_nodeconf.position) - pl = estimate_path_loss(conf, dist, self.freq, self.position.z, rx_nodeconf.position.z) - rssi = self.tx_power + self.antenna_gain + rx_nodeconf.antenna_gain - pl + offset = getattr(conf, "LINK_OFFSET", {}).get((self.node_id, rx_nodeconf.node_id), 0) + budget = calculate_link_budget(conf, self, rx_nodeconf, offset, tx_power_dbm=self.tx_power) + return budget.rssi_dbm, budget.calibrated_path_loss_db + + +def node_configs_from_yaml(raw_config, period: int, tx_power: int = 30, freq: float = 902e6) -> list[NodeConfig]: + """Convert saved node YAML into NodeConfig objects. + + The GUI writes a plain `{node_id: node_fields}` map. Real-mesh scenario + files may wrap the same map under `nodes` so they can also store geographic + origin metadata. Accept both shapes here so saved scenarios can be fed back + into the normal simulator CLI. + """ + if isinstance(raw_config, dict) and "nodes" in raw_config: + node_map = raw_config["nodes"] + else: + node_map = raw_config + + if not isinstance(node_map, dict): + raise ValueError("node YAML must be a node map or an object with a 'nodes' map") + + configs = [] + for sim_node_id, node_dict in enumerate(node_map.values()): + configs.append(NodeConfig.from_gen_scenario_output(sim_node_id, node_dict, period, tx_power, freq)) + return configs + + +def origin_from_yaml(raw_config): + """Return `(lat, lon)` origin metadata from wrapped scenario YAML if present.""" + if not isinstance(raw_config, dict): + return None + + origin = raw_config.get("origin") + if not isinstance(origin, dict) or "lat" not in origin or "lon" not in origin: + return None + + try: + lat = float(origin["lat"]) + lon = float(origin["lon"]) + except (TypeError, ValueError) as err: + raise ValueError("origin.lat and origin.lon must be finite numbers") from err + + if not math.isfinite(lat) or not math.isfinite(lon): + raise ValueError("origin.lat and origin.lon must be finite numbers") + if not valid_lat_lon(lat, lon): + raise ValueError("origin.lat and origin.lon must be valid latitude/longitude degrees") + + return lat, lon + +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] - return (rssi, pl) class MeshNode: """Class containing all the particular state of a MeshNode, references to necessary @@ -167,13 +234,15 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa # set up internal RNGs self.moveRng = random.Random(self.nodeid) self.nodeRng = random.Random(self.nodeid) - self.rebroadcastRng = random.Random() + self.rebroadcastRng = random.Random(f"{self.conf.SEED}:{self.nodeid}:rebroadcast") # require the user to specify a node configuration now, including position self.position = self.node_conf.position # explicitly use position in node_conf self.role = self.node_conf.role self.hopLimit = self.node_conf.hop_limit self.antennaGain = self.node_conf.antenna_gain + self.antennaHeight = self.node_conf.antenna_height + self.absolute_altitude = self.node_conf.absolute_altitude self.period = self.node_conf.period # using this more like a struct than a proper object. @@ -198,6 +267,13 @@ 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.dtpTxByPower = {} + self.dtpTxByCrPower = {} + self.dtpDetectedByTx = 0 + self.dtpSensedByTx = 0 + self.dtpTxCount = 0 self.droppedByDelay = 0 self.rebroadcastPackets = 0 self.isMoving = False @@ -294,6 +370,12 @@ def move_node(self): # Update node’s position self.position.update_xy(new_x, new_y) + if ( + self.conf.TERRAIN_ENABLED + and self.conf.TERRAIN_GRID is not None + and self.conf.NODE_Z_REFERENCE == NODE_Z_REFERENCE_SEA_LEVEL + ): + apply_terrain_altitude(self.conf.TERRAIN_GRID, self) # update connectivity map: # - update for this node: we may have gained and lost reachable nodes @@ -390,6 +472,21 @@ def perhaps_cancel_dupe(self, packet): return self.timesReceived[packet.seq] > 2 if self.is_router or self.is_repeater else self.timesReceived[packet.seq] > 1 return False + def latest_retry_timer_packet(self, packet): + """Return the newest queued/generated retry attempt for this message.""" + candidates = [ + packetSent + for packetSent in self.packets + if packetSent.origTxNodeId == self.nodeid and packetSent.seq == packet.seq + ] + if not candidates: + return packet + return min(candidates, key=lambda packetSent: packetSent.retransmissions) + + def wait_for_retry_timer_airtime(self, packet): + """Wait until DCR has selected the airtime used by the retry timer.""" + while self.conf.DCR_ENABLED and packet in self.packets and not packet.retryTimerAirtimeReady: + yield self.env.timeout(1) def generate_message(self): while True: @@ -400,14 +497,18 @@ 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 p = self.send_packet(destId) while p.wantAck: # ReliableRouter: retransmit message if no ACK received after timeout - retransmissionMsec = get_retransmission_msec(self, p) + retry_timer_packet = self.latest_retry_timer_packet(p) + yield from self.wait_for_retry_timer_airtime(retry_timer_packet) + if retry_timer_packet not in self.packets: + break + retransmissionMsec = get_retransmission_msec(self, retry_timer_packet) yield self.env.timeout(retransmissionMsec) ackReceived = False # check whether you received an ACK on the transmitted message @@ -453,17 +554,46 @@ def transmit(self, packet): # check if you received an ACK for this message in the meantime self.was_seen_recently(packet, ownTransmit=True) if not self.perhaps_cancel_dupe(packet): # if you did not receive an ACK for this message in the meantime + # Firmware DCR runs very late too: after queue/LBT waiting, but + # before airtime accounting and packet start/end timestamps. + decision = choose_dynamic_coding_rate(self, packet) + if decision.cr != packet.cr: + packet.set_coding_rate(decision.cr) + packet.retryTimerAirtimeReady = True + logger.debug( + f"{self.env.now:.3f} Node {self.nodeid} DCR selected CR 4/{packet.cr} for packet {packet.seq}: {decision.reason}" + ) + + power_decision = choose_dynamic_tx_power(self, packet) + if power_decision.tx_power_dbm != packet.txpow: + packet.set_tx_power(power_decision.tx_power_dbm) + logger.debug( + f"{self.env.now:.3f} Node {self.nodeid} DTP selected {packet.txpow} dBm for packet {packet.seq}: {power_decision.reason}" + ) + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started low level send {packet.unique_packet_seq} for msg {packet.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") self.nrPacketsSent += 1 - for rx_node in self.nodes: - if packet.sensedByN[rx_node.nodeid]: - if check_collision(self.conf, self.env, packet, rx_node.nodeid, self.packetsAtN) == 0: - self.packetsAtN[rx_node.nodeid].append(packet) - # packet's collidedAtN field is now computed/valid packet.startTime = self.env.now packet.endTime = self.env.now + packet.timeOnAir + for rx_node in self.nodes: + if packet_is_rx_candidate(packet, rx_node.nodeid, self.conf.CAPTURE_COLLISION_MODEL_ENABLED): + collision = check_collision(self.conf, self.env, packet, rx_node.nodeid, self.packetsAtN) + if self.conf.CAPTURE_COLLISION_MODEL_ENABLED: + # Even a packet that cannot be decoded is still RF + # energy on the channel and may jam later packets. + self.packetsAtN[rx_node.nodeid].append(packet) + elif collision == 0: + self.packetsAtN[rx_node.nodeid].append(packet) self.txAirUtilization += packet.timeOnAir self.airUtilization += packet.timeOnAir + self.dcrTxByCr[packet.cr] = self.dcrTxByCr.get(packet.cr, 0) + 1 + self.dcrAirtimeByCr[packet.cr] = self.dcrAirtimeByCr.get(packet.cr, 0.0) + packet.timeOnAir + self.dtpTxByPower[packet.txpow] = self.dtpTxByPower.get(packet.txpow, 0) + 1 + cr_power_key = f"{packet.cr}@{packet.txpow}" + self.dtpTxByCrPower[cr_power_key] = self.dtpTxByCrPower.get(cr_power_key, 0) + 1 + self.dtpDetectedByTx += sum(1 for detected in packet.detectedByN if detected) + self.dtpSensedByTx += sum(1 for sensed in packet.sensedByN if sensed) + self.dtpTxCount += 1 self.bc_pipe.put(packet) # queue for nodes to receive packet self.isTransmitting = True yield self.env.timeout(packet.timeOnAir) @@ -475,20 +605,52 @@ def transmit(self, packet): def receive(self, in_pipe): while True: p = yield in_pipe.get() + packet_log_id = getattr(p, "unique_packet_seq", p.seq) + + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} fetches packet {packet_log_id} for msg {p.seq} from {p.txNodeId} from bc_pipe: sensed: {p.sensedByN[self.nodeid]} collided: {p.collidedAtN[self.nodeid]} on air: {p.onAirToN[self.nodeid]}") + + if self.conf.CAPTURE_COLLISION_MODEL_ENABLED: + if p.sensedByN[self.nodeid] and p.onAirToN[self.nodeid]: + p.onAirToN[self.nodeid] = False + if not self.isTransmitting and not p.collidedAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {packet_log_id} for msg {p.seq} from {p.txNodeId}") + self.isReceiving.append(True) + elif self.isTransmitting: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not lock packet {p.seq}.") + p.sensedByN[self.nodeid] = False + else: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not lock packet {packet_log_id} for msg {p.seq}.") + continue + + if p.sensedByN[self.nodeid]: + try: + self.isReceiving[self.isReceiving.index(True)] = False + except Exception: + pass + self.airUtilization += p.timeOnAir + if p.collidedAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {packet_log_id}.") + continue + if p.phyLostAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {packet_log_id} for msg {p.seq} to weak-link PHY errors.") + continue + p.receivedAtN[self.nodeid] = True + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {packet_log_id} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") + self.handle_received_packet(p) + continue - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} fetches packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId} from bc_pipe: sensed: {p.sensedByN[self.nodeid]} collided: {p.collidedAtN[self.nodeid]} on air: {p.onAirToN[self.nodeid]}") if p.sensedByN[self.nodeid] and p.onAirToN[self.nodeid]: # start of reception if p.collidedAtN[self.nodeid]: - # this packet collided, so we can sense it but not decode it. - # Mark it as no-longer on air and leave further processing to - # the 'end of transmission' branch + # This packet collided, so we can sense it but not decode + # it. Mark it as no-longer on air and leave further + # processing to the end-of-transmission branch. p.onAirToN[self.nodeid] = False elif not self.isTransmitting: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {packet_log_id} for msg {p.seq} from {p.txNodeId}") p.onAirToN[self.nodeid] = False self.isReceiving.append(True) else: # if you were currently transmitting, you could not have sensed it - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} was transmitting, so could not receive packet {p.unique_packet_seq} for msg {p.seq}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} was transmitting, so could not receive packet {packet_log_id} for msg {p.seq}") p.sensedByN[self.nodeid] = False p.onAirToN[self.nodeid] = False elif p.sensedByN[self.nodeid]: # end of reception @@ -499,60 +661,71 @@ def receive(self, in_pipe): self.airUtilization += p.timeOnAir # begin receiving packet fine, but a collision begins before we finish receiving. if p.collidedAtN[self.nodeid]: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {p.unique_packet_seq}.") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {packet_log_id}.") + continue + if p.phyLostAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {packet_log_id} for msg {p.seq} to weak-link PHY errors.") continue p.receivedAtN[self.nodeid] = True - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {p.unique_packet_seq} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") # TODO: better way to calculate delay for log - self.delays.append(self.env.now - p.genTime) + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {packet_log_id} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") # TODO: better way to calculate delay for log + self.handle_received_packet(p) - # Update history of received packets - self.was_seen_recently(p) + def handle_received_packet(self, p): + """Handle decoded MeshPacket after RX PHY/collision checks pass.""" + self.delays.append(self.env.now - p.genTime) - # check if implicit ACK for own generated message - if p.origTxNodeId == self.nodeid: - if p.isAck: - logger.debug(f"Node {self.nodeid} received real ACK on generated message.") - else: - logger.debug(f"Node {self.nodeid} received implicit ACK on message sent.") - p.ackReceived = True - continue + # Update history of received packets + self.was_seen_recently(p) - ackReceived = False - realAckReceived = False - for sentPacket in self.packets: - # check if ACK for message you currently have in queue - if sentPacket.txNodeId == self.nodeid and sentPacket.seq == p.seq: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received implicit ACK for message in queue.") - ackReceived = True - sentPacket.ackReceived = True - # check if real ACK for message sent - if sentPacket.origTxNodeId == self.nodeid and p.isAck and sentPacket.seq == p.requestId: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received real ACK.") - realAckReceived = True - sentPacket.ackReceived = True - - # send real ACK if you are the destination and you did not yet send the ACK - if p.wantAck and p.destId == self.nodeid and not any(pA.requestId == p.seq for pA in self.packets): - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} sends a flooding ACK.") - messageSeq = self.messageSeq.get() - self.messages.append(MeshMessage(self.nodeid, p.origTxNodeId, self.env.now, messageSeq)) - pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) - self.packets.append(pAck) - self.env.process(self.transmit(pAck)) - # Rebroadcasting Logic for received message. This is a broadcast or a DM not meant for us. - elif not p.destId == self.nodeid and not ackReceived and not realAckReceived and p.hopLimit > 0: - self.my_stats.packetsHeard += 1 # packets which could potentially be rebroadcast - # FloodingRouter: rebroadcast received packet - if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: - if not self.is_client_mute: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} schedules rebroadcast for received packet {p.unique_packet_seq} for msg {p.seq}") - self.my_stats.packetsRebroadcast += 1 - pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) - pNew.hopLimit = p.hopLimit - 1 - self.packets.append(pNew) - self.env.process(self.transmit(pNew)) - else: - self.droppedByDelay += 1 + # check if implicit ACK for own generated message + if p.origTxNodeId == self.nodeid: + if p.isAck: + logger.debug(f"Node {self.nodeid} received real ACK on generated message.") + else: + logger.debug(f"Node {self.nodeid} received implicit ACK on message sent.") + p.ackReceived = True + return + + ackReceived = False + realAckReceived = False + for sentPacket in self.packets: + # check if ACK for message you currently have in queue + if sentPacket.txNodeId == self.nodeid and sentPacket.seq == p.seq: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received implicit ACK for message in queue.") + ackReceived = True + sentPacket.ackReceived = True + # check if real ACK for message sent + if sentPacket.origTxNodeId == self.nodeid and p.isAck and sentPacket.seq == p.requestId: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received real ACK.") + realAckReceived = True + sentPacket.ackReceived = True + + # send real ACK if you are the destination and you did not yet send the ACK + if p.wantAck and p.destId == self.nodeid and not any(pA.requestId == p.seq for pA in self.packets): + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} sends a flooding ACK.") + messageSeq = self.messageSeq.get() + self.messages.append(MeshMessage(self.nodeid, p.origTxNodeId, self.env.now, messageSeq)) + pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) + pAck.priorHopRssi = p.rssiAtN[self.nodeid] + pAck.priorHopSnr = estimate_snr(self.conf, pAck.priorHopRssi) + self.packets.append(pAck) + self.env.process(self.transmit(pAck)) + # Rebroadcasting Logic for received message. This is a broadcast or a DM not meant for us. + elif not p.destId == self.nodeid and not ackReceived and not realAckReceived and p.hopLimit > 0: + self.my_stats.packetsHeard += 1 # packets which could potentially be rebroadcast + # FloodingRouter: rebroadcast received packet + if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: + if not self.is_client_mute: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} schedules rebroadcast for received packet {p.unique_packet_seq} for msg {p.seq}") + self.my_stats.packetsRebroadcast += 1 + pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) + pNew.hopLimit = p.hopLimit - 1 + pNew.priorHopRssi = p.rssiAtN[self.nodeid] + pNew.priorHopSnr = estimate_snr(self.conf, pNew.priorHopRssi) + self.packets.append(pNew) + self.env.process(self.transmit(pNew)) + else: + self.droppedByDelay += 1 def get_stats(self) -> MeshNodeStats: """Get internally-tracked statistics/data. Only valid after the sim ends. @@ -579,12 +752,6 @@ def default_generate_node_list(conf: Config) -> [NodeConfig]: # role isRouter = conf.router - isRepeater = False - isClientMute = False - - # other default values - hopLimit = conf.hopLimit - antennaGain = conf.GL # map misc. booleans into single role if isRouter: diff --git a/lib/nodedb_input.py b/lib/nodedb_input.py new file mode 100644 index 00000000..5dd02ef1 --- /dev/null +++ b/lib/nodedb_input.py @@ -0,0 +1,140 @@ +"""Input adapter for positions stored in a local Meshtastic device NodeDB.""" + +from lib.geo import valid_lat_lon +from lib.map_input import ( + decode_map_altitude, + decode_map_coordinate, + node_configs_from_positioned_rows, + role_name_for_node, +) + + +def fetch_nodedb_payload(host=None, port=None, serial_port=None): + """Read positioned nodes from a local Meshtastic device. + + This uses the same Python client state that powers `meshtastic --nodes`. + The CLI pretty-prints `interface.nodesByNum`; the simulator wants that raw + structure so it can project node positions without parsing a terminal table. + """ + if host is not None and serial_port is not None: + raise ValueError("--nodedb-host and --nodedb-serial-port are mutually exclusive") + + try: + if host is not None: + from meshtastic import tcp_interface + + iface = tcp_interface.TCPInterface(hostname=host, portNumber=port or 4403) + else: + from meshtastic import serial_interface + + iface = serial_interface.SerialInterface(devPath=serial_port) + except Exception as err: + raise ValueError(f"could not connect to Meshtastic device: {err}") from err + + try: + return list((iface.nodesByNum or {}).values()) + finally: + close = getattr(iface, "close", None) + if close is not None: + close() + + +def nodedb_payload_nodes(payload): + """Return node rows from accepted NodeDB payload shapes.""" + nodes = payload + if hasattr(payload, "nodesByNum"): + nodes = payload.nodesByNum + elif isinstance(payload, dict) and "nodesByNum" in payload: + nodes = payload["nodesByNum"] + + if isinstance(nodes, dict): + nodes = nodes.values() + if not isinstance(nodes, (list, tuple)) and not hasattr(nodes, "__iter__"): + raise ValueError("NodeDB payload must be nodesByNum, a node list, or an iterable of node rows") + return list(nodes) + + +def role_name_for_nodedb_node(node): + user = node.get("user") if isinstance(node, dict) else None + if isinstance(user, dict) and user.get("role") is not None: + return normalize_nodedb_role(user["role"]) + if isinstance(node, dict) and node.get("role") is not None: + return normalize_nodedb_role(node["role"]) + return "CLIENT" + + +def normalize_nodedb_role(raw_role): + try: + return role_name_for_node({"role": int(raw_role)}) + except (TypeError, ValueError): + return str(raw_role).upper() + + +def positioned_nodedb_nodes(nodes, bbox=None): + """Return `(node, lat, lon)` rows for NodeDB entries with valid positions.""" + positioned = [] + for node in nodes: + if not isinstance(node, dict): + continue + position = node.get("position") + if not isinstance(position, dict): + continue + + try: + lat = position.get("latitude") + if lat is None: + lat = decode_map_coordinate(position.get("latitudeI")) + lon = position.get("longitude") + if lon is None: + lon = decode_map_coordinate(position.get("longitudeI")) + except (TypeError, ValueError): + continue + if lat is None or lon is None: + continue + if not valid_lat_lon(lat, lon): + continue + + if bbox is not None: + min_lat, min_lon, max_lat, max_lon = bbox + if not (min_lat <= lat <= max_lat and min_lon <= lon <= max_lon): + continue + + node = { + "role_name": role_name_for_nodedb_node(node), + "altitude": decode_map_altitude(position.get("altitude")), + } + positioned.append((node, lat, lon)) + return positioned + + +def node_configs_from_nodedb_payload( + payload, + period, + bbox=None, + limit=None, + antenna_height=1.5, + hop_limit=3, + tx_power=30, + freq=902e6, + origin=None, + return_origin=False, +): + """Build NodeConfig objects from a local Meshtastic NodeDB payload.""" + positioned = positioned_nodedb_nodes(nodedb_payload_nodes(payload), bbox) + if limit is not None: + if limit < 1: + raise ValueError("map limit must be at least 1") + positioned = positioned[:limit] + if not positioned: + raise ValueError("NodeDB payload produced no positioned nodes") + + return node_configs_from_positioned_rows( + positioned, + period, + antenna_height=antenna_height, + hop_limit=hop_limit, + tx_power=tx_power, + freq=freq, + origin=origin, + return_origin=return_origin, + ) diff --git a/lib/osm_clutter.py b/lib/osm_clutter.py new file mode 100644 index 00000000..99d4945c --- /dev/null +++ b/lib/osm_clutter.py @@ -0,0 +1,307 @@ +"""Export public OpenStreetMap land-use/building data to clutter CSV. + +This is a standalone data-prep helper. The simulator runtime reads the exported +CSV and never fetches OSM/Overpass data implicitly. +""" + +import argparse +import csv +import json +import math +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from lib.geo import valid_lat_lon +from lib.map_input import parse_bbox +from lib.terrain import latlon_to_xy, xy_to_latlon + + +DEFAULT_OVERPASS_URL = "https://overpass-api.de/api/interpreter" + +URBAN_LANDUSE = { + "commercial", + "construction", + "garages", + "industrial", + "military", + "railway", + "residential", + "retail", +} +FOREST_VALUES = {"forest", "wood"} +WATER_VALUES = {"basin", "reservoir", "salt_pond", "water"} +OPEN_NATURAL = {"beach", "grassland", "heath", "sand", "scrub"} + + +def overpass_query(bbox): + """Build a bounded Overpass query for clutter-relevant OSM polygons.""" + min_lat, min_lon, max_lat, max_lon = bbox + box = f"{min_lat},{min_lon},{max_lat},{max_lon}" + return f""" +[out:json][timeout:90]; +( + way["building"]({box}); + way["landuse"~"^(commercial|construction|garages|industrial|military|railway|residential|retail|forest)$"]({box}); + way["natural"~"^(beach|grassland|heath|sand|scrub|water|wood)$"]({box}); + way["water"]({box}); +); +out tags geom; +""" + + +def fetch_overpass_payload(bbox, url=DEFAULT_OVERPASS_URL): + data = urllib.parse.urlencode({"data": overpass_query(bbox)}).encode() + request = urllib.request.Request( + url, + data=data, + headers={ + "User-Agent": "Meshtasticator OSM clutter exporter", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return json.load(response) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as err: + raise ValueError(f"could not fetch OSM clutter payload from {url}: {err}") from err + + +def parse_origin(value): + """Parse `lat,lon` for local raster projection origin.""" + parts = [part.strip() for part in value.split(",")] + if len(parts) != 2: + raise ValueError("origin must be lat,lon") + + lat, lon = [float(part) for part in parts] + if not math.isfinite(lat) or not math.isfinite(lon): + raise ValueError("origin values must be finite") + if not valid_lat_lon(lat, lon): + raise ValueError("origin values must be valid latitude/longitude degrees") + return lat, lon + + +def classify_osm_element(tags): + """Map OSM tags to broad clutter classes used by the radio model.""" + if tags.get("building"): + return "urban" + + landuse = tags.get("landuse") + natural = tags.get("natural") + water = tags.get("water") + + if landuse in URBAN_LANDUSE: + return "urban" + if landuse in FOREST_VALUES or natural in FOREST_VALUES: + return "forest" + if landuse in WATER_VALUES or natural in WATER_VALUES or water: + return "water" + if natural in OPEN_NATURAL: + return "open" + return None + + +def payload_elements(payload): + """Return Overpass elements from the expected JSON object shape.""" + if not isinstance(payload, dict): + raise ValueError("OSM payload must be a JSON object") + + elements = payload.get("elements", []) + if not isinstance(elements, list): + raise ValueError("OSM payload elements must be a list") + return elements + + +def point_in_polygon(x, y, polygon): + """Return True when a point is inside a simple polygon.""" + inside = False + j = len(polygon) - 1 + for i, (xi, yi) in enumerate(polygon): + xj, yj = polygon[j] + if (yi > y) != (yj > y): + x_intersect = (xj - xi) * (y - yi) / (yj - yi) + xi + if x < x_intersect: + inside = not inside + j = i + return inside + + +def polygon_bounds(polygon): + xs = [point[0] for point in polygon] + ys = [point[1] for point in polygon] + return min(xs), min(ys), max(xs), max(ys) + + +def polygon_centroid(polygon): + if not polygon: + return 0.0, 0.0 + return ( + sum(point[0] for point in polygon) / len(polygon), + sum(point[1] for point in polygon) / len(polygon), + ) + + +def osm_polygons(payload, origin): + """Yield `(clutter_class, polygon_xy, bounds, centroid)` from Overpass JSON.""" + origin_lat, origin_lon = origin + for element in payload_elements(payload): + if not isinstance(element, dict): + continue + + geometry = element.get("geometry") or [] + tags = element.get("tags") or {} + if not isinstance(geometry, list) or not isinstance(tags, dict): + continue + + clutter_class = classify_osm_element(tags) + if not clutter_class or len(geometry) < 3: + continue + + polygon = [] + for point in geometry: + if not isinstance(point, dict): + polygon = [] + break + try: + lat = float(point["lat"]) + lon = float(point["lon"]) + except (KeyError, TypeError, ValueError): + polygon = [] + break + if not valid_lat_lon(lat, lon): + polygon = [] + break + polygon.append(latlon_to_xy(lat, lon, origin_lat, origin_lon)) + if len(polygon) < 3: + continue + + if polygon[0] != polygon[-1]: + polygon.append(polygon[0]) + bounds = polygon_bounds(polygon) + centroid = polygon_centroid(polygon) + yield clutter_class, polygon, bounds, centroid + + +def _frange(start, stop, step): + value = start + epsilon = step / 1000.0 + while value <= stop + epsilon: + yield value + value += step + + +def classify_cell(x, y, polygons, step_m): + """Classify one clutter grid cell from intersecting OSM polygons.""" + hits = {"urban": 0, "forest": 0, "water": 0, "open": 0} + half = step_m / 2.0 + for clutter_class, polygon, bounds, centroid in polygons: + min_x, min_y, max_x, max_y = bounds + if x < min_x - half or x > max_x + half or y < min_y - half or y > max_y + half: + continue + + # Land-use polygons often contain the cell center. Building footprints + # are much smaller than the exported raster cell, so also count nearby + # building centroids/bounds as urban evidence. + if point_in_polygon(x, y, polygon): + hits[clutter_class] = hits.get(clutter_class, 0) + 2 + elif clutter_class == "urban" and min_x - half <= x <= max_x + half and min_y - half <= y <= max_y + half: + cx, cy = centroid + if abs(cx - x) <= half and abs(cy - y) <= half: + hits["urban"] += 1 + + if hits["water"] > 0: + return "water" + if hits["urban"] > 0: + return "urban" + if hits["forest"] > 0: + return "forest" + return "open" + + +def rasterize_clutter(payload, bbox, origin=None, step_m=500.0): + """Rasterize OSM polygons to rows suitable for `ClutterGrid.from_csv()`.""" + if not math.isfinite(step_m) or step_m <= 0: + raise ValueError("step_m must be a positive finite number") + + if origin is None: + min_lat, min_lon, max_lat, max_lon = bbox + origin = ((min_lat + max_lat) / 2.0, (min_lon + max_lon) / 2.0) + + origin_lat, origin_lon = origin + min_lat, min_lon, max_lat, max_lon = bbox + min_x, min_y = latlon_to_xy(min_lat, min_lon, origin_lat, origin_lon) + max_x, max_y = latlon_to_xy(max_lat, max_lon, origin_lat, origin_lon) + min_x, max_x = sorted((min_x, max_x)) + min_y, max_y = sorted((min_y, max_y)) + + polygons = list(osm_polygons(payload, origin)) + rows = [] + for x in _frange(math.floor(min_x / step_m) * step_m, math.ceil(max_x / step_m) * step_m, step_m): + for y in _frange(math.floor(min_y / step_m) * step_m, math.ceil(max_y / step_m) * step_m, step_m): + lat, lon = xy_to_latlon(x, y, origin_lat, origin_lon) + if not (min_lat <= lat <= max_lat and min_lon <= lon <= max_lon): + continue + rows.append({ + "x_m": round(x, 2), + "y_m": round(y, 2), + "lat": round(lat, 7), + "lon": round(lon, 7), + "clutter_class": classify_cell(x, y, polygons, step_m), + }) + return rows + + +def write_clutter_csv(rows, output_path): + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["x_m", "y_m", "lat", "lon", "clutter_class"], + lineterminator="\n", + ) + writer.writeheader() + writer.writerows(rows) + + +def main(argv=None): + parser = argparse.ArgumentParser(description="export OSM land-use/building clutter to Meshtasticator CSV") + parser.add_argument("--bbox", required=True, help="min_lat,min_lon,max_lat,max_lon") + parser.add_argument("--origin", help="origin lat,lon for local x/y output; defaults to bbox center") + parser.add_argument("--step-meters", type=float, default=500.0, help="output raster spacing in meters") + parser.add_argument("--output", required=True, help="output clutter CSV path") + parser.add_argument("--overpass-url", default=DEFAULT_OVERPASS_URL, help="Overpass interpreter endpoint") + parser.add_argument("--input-json", help="read an existing Overpass JSON response instead of fetching") + args = parser.parse_args(argv) + + try: + bbox = parse_bbox(args.bbox) + except ValueError as err: + parser.error(str(err)) + + origin = None + if args.origin: + try: + origin = parse_origin(args.origin) + except ValueError as err: + parser.error(str(err)) + + if args.input_json: + try: + with open(args.input_json, encoding="utf-8") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as err: + parser.error(f"could not read OSM clutter JSON: {err}") + else: + payload = fetch_overpass_payload(bbox, args.overpass_url) + + try: + rows = rasterize_clutter(payload, bbox, origin=origin, step_m=args.step_meters) + except ValueError as err: + parser.error(str(err)) + write_clutter_csv(rows, args.output) + + +if __name__ == "__main__": + main() diff --git a/lib/packet.py b/lib/packet.py index cdbf1c1e..459481d3 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -2,7 +2,9 @@ import random from lib.discrete_event_sim_components import Counter -from lib.phy import airtime, estimate_path_loss +from lib.link_model import calculate_link_budget +from lib.phy import airtime +from lib.radio_loss import payload_is_lost NODENUM_BROADCAST = 0xFFFFFFFF @@ -50,14 +52,23 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.requestId = requestId self.genTime = genTime self.now = now - self.txpow = self.conf.PTX + self.nodes = nodes + self.baseTxPower = int(self.conf.PTX) + self.txpow = self.baseTxPower + self.priorHopRssi = None + self.priorHopSnr = None self.LplAtN = [0 for _ in range(self.conf.NR_NODES)] + self.terrainLossAtN = [0 for _ in range(self.conf.NR_NODES)] + self.clutterLossAtN = [0 for _ in range(self.conf.NR_NODES)] self.rssiAtN = [0 for _ in range(self.conf.NR_NODES)] self.sensedByN = [False for _ in range(self.conf.NR_NODES)] # nodes which may possibly sense this packet self.detectedByN = [False for _ in range(self.conf.NR_NODES)] self.collidedAtN = [False for _ in range(self.conf.NR_NODES)] + self.collisionReasonAtN = [None for _ in range(self.conf.NR_NODES)] self.receivedAtN = [False for _ in range(self.conf.NR_NODES)] + self.phyLostAtN = [False for _ in range(self.conf.NR_NODES)] self.onAirToN = [True for _ in range(self.conf.NR_NODES)] + self.phyLossDrawAtN = [0.0 for _ in range(self.conf.NR_NODES)] # configuration values self.sf = self.conf.current_preset["sf"] @@ -65,63 +76,108 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.bw = self.conf.current_preset["bw"] self.freq = self.conf.FREQ self.tx_node = next(n for n in nodes if n.nodeid == self.txNodeId) + self.connectivity_map = connectivity_map + self.baseline_pathloss_matrix = baseline_pathloss_matrix logger.debug(f"{self.now:.3f} Packet {self.unique_packet_seq} for msg {self.seq} generated by node {self.txNodeId}") - - # calculate reception at all other nodes - for rx_node in nodes: - if rx_node.nodeid == self.txNodeId: - continue - - # reduce calculations just to plausibly reachable nodes. This is initialized - # before sim start, and updated whenever a moving node's position is updated, - # so is always an accurate map of what nodes could (with extra margin) even - # sense each other. - if self.conf.ENABLE_CONNECTIVITY_MAP and not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): - logger.debug(f"{self.now:.3f} skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") - if conf.MODEL_ASYMMETRIC_LINKS: - # Each tx -> rx computation we skip gets the asrm_rng one call out - # of sync between simulations with and without the connectivity - # map optimization. Thus particular tx -> rx calculations - # change between the optimization, which can lead to changes - # in sim behavior between the optimization being on/off, leading - # to inconsistencies beteen the optimization being on/off. - # - # Keep things balanced by 'unnecessarily' calling the rng here. - MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) - continue - - if self.conf.ENABLE_CONNECTIVITY_MAP: - # look up baseline path loss from matrix, since we've already computed it. - baseline_pathloss = baseline_pathloss_matrix[self.txNodeId][rx_node.nodeid] - else: - dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) - baseline_pathloss = estimate_path_loss(self.conf, dist_3d, self.freq, self.tx_node.position.z, rx_node.position.z) - - if conf.MODEL_ASYMMETRIC_LINKS: - offset = MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) - logger.debug(f"{self.now:.3f} packet {self.unique_packet_seq} for msg {self.seq} has asym offset {offset} dB") - if abs(offset) > conf.CONNECTIVITY_MAP_RSSI_MARGIN: - logger.warning(f"{self.now:.3f} packet {self.unique_packet_counter} has asymmetric RSSI offset {offset} which is outside margin. This will lead to inconsistent results with the connectivity map optimization.") - else: - offset = 0 - self.LplAtN[rx_node.nodeid] = baseline_pathloss + offset - self.rssiAtN[rx_node.nodeid] = self.txpow + self.tx_node.antennaGain + rx_node.antennaGain - self.LplAtN[rx_node.nodeid] - if self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["sensitivity"]: - self.sensedByN[rx_node.nodeid] = True - if self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["cad_threshold"]: - self.detectedByN[rx_node.nodeid] = True + if self.conf.PHY_LOSS_MODEL_ENABLED: + for rx_node in nodes: + if rx_node.nodeid != self.txNodeId: + self.phyLossDrawAtN[rx_node.nodeid] = random.random() self.packetLen = plen self.timeOnAir = airtime(self.conf, self.sf, self.cr, self.packetLen, self.bw) + self.retryTimerAirtimeReady = False self.startTime = 0 self.endTime = 0 + self.refresh_link_budgets() # Routing self.retransmissions = self.conf.maxRetransmission self.ackReceived = False self.hopLimit = self.tx_node.hopLimit + def refresh_link_budgets(self): + """Recompute receiver-side RF state for the current TX power. + + Per-packet power changes only alter transmitter output level. Terrain, + clutter, and pair calibration remain the same path; RSSI, CAD + detection, sensitivity, and empirical PHY loss must be recalculated + before collision handling. + """ + for rx_node in self.nodes: + if rx_node.nodeid == self.txNodeId: + continue + if ( + self.conf.ENABLE_CONNECTIVITY_MAP + and not self.conf.MOVEMENT_ENABLED + and rx_node.nodeid not in self.connectivity_map[self.txNodeId] + ): + logger.debug( + f"{self.now:.3f} skipping {self.txNodeId} -> {rx_node.nodeid} computation. " + f"connectivity map: {self.connectivity_map[self.txNodeId]}" + ) + continue + budget = calculate_link_budget( + self.conf, + self.tx_node, + rx_node, + getattr(self.conf, "LINK_OFFSET", {}).get((self.txNodeId, rx_node.nodeid), 0), + tx_power_dbm=self.txpow, + ) + self.terrainLossAtN[rx_node.nodeid] = budget.terrain_loss_db + self.clutterLossAtN[rx_node.nodeid] = budget.clutter_loss_db + self.LplAtN[rx_node.nodeid] = budget.calibrated_path_loss_db + self.rssiAtN[rx_node.nodeid] = budget.rssi_dbm + self.detectedByN[rx_node.nodeid] = self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["cad_threshold"] + self.refresh_phy_reception() + + def airtime_for_cr(self, cr): + """Predict airtime if this packet is transmitted with a different CR.""" + return airtime(self.conf, self.sf, cr, self.packetLen, self.bw) + + def set_coding_rate(self, cr): + """Change only physical CR and recompute airtime. + + LoRa explicit-header packets carry CR in the PHY header, so changing the + selected CR does not alter Meshtastic payload bytes in this experiment. + """ + self.cr = cr + self.timeOnAir = self.airtime_for_cr(cr) + self.refresh_phy_reception() + + def set_tx_power(self, tx_power_dbm): + """Change temporary TX power and recompute RF visibility. + + The configured region power remains the packet's baseTxPower. This + method only models a per-transmission reduction. + """ + self.txpow = int(tx_power_dbm) + self.refresh_link_budgets() + + def refresh_phy_reception(self): + """Recompute payload-loss state after the selected CR changes. + + Reception remains gated by the configured modem sensitivity. Stronger + coding rates improve payload decode probability near that edge, but they + do not resurrect packets whose preamble/header would not be heard. + """ + for rx_node_id, rssi in enumerate(self.rssiAtN): + if rx_node_id == self.txNodeId: + continue + + self.sensedByN[rx_node_id] = rssi >= self.conf.current_preset["sensitivity"] + self.phyLostAtN[rx_node_id] = False + + if self.sensedByN[rx_node_id]: + self.phyLostAtN[rx_node_id] = payload_is_lost( + self.conf, + rssi, + self.cr, + self.packetLen, + self.phyLossDrawAtN[rx_node_id], + ) + class MeshMessage: def __init__(self, origTxNodeId, destId, genTime, seq): diff --git a/lib/phy.py b/lib/phy.py index cc2a8212..a2e6b63c 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -30,6 +30,9 @@ def get_current_slot_time(): # from RadioInterface::computeSlotTimeMsec def check_collision(conf, env, packet, rx_nodeId, packetsAtN): + if conf.CAPTURE_COLLISION_MODEL_ENABLED: + return check_capture_collision(conf, packet, rx_nodeId, packetsAtN) + # Check for collisions at rx_node col = 0 if conf.COLLISION_DUE_TO_INTERFERENCE: @@ -53,20 +56,82 @@ def check_collision(conf, env, packet, rx_nodeId, packetsAtN): return 0 +def check_capture_collision(conf, packet, rx_nodeId, packetsAtN): + """Check overlap with a capture-aware same-SF collision model. + + The legacy model is intentionally preserved unless explicitly enabled. This + path models the part that matters for real crowded meshes: a receiver can + keep a sufficiently stronger packet through a weaker overlap, but equal or + stronger interference during the preamble/header lock window destroys it. + Later payload-only overlap is tolerated when it is only a short tail. + """ + col = 0 + if conf.COLLISION_DUE_TO_INTERFERENCE and random.random() < conf.INTERFERENCE_LEVEL: + mark_collision(packet, rx_nodeId, "external_interference") + col = 1 + + for other in packetsAtN[rx_nodeId]: + if not intervals_overlap(packet.startTime, packet.endTime, other.startTime, other.endTime): + continue + if not frequency_collision(packet, other) or not sf_collision(packet, other): + continue + + casualties = capture_collision_casualties(conf, packet, other, rx_nodeId) + if casualties: + logger.debug( + f'Packet nr. {packet.seq} from {packet.txNodeId} and packet nr. ' + f'{other.seq} from {other.txNodeId} overlap at node {rx_nodeId}; ' + f'capture casualties {[p.seq for p, _ in casualties]}' + ) + for casualty, reason in casualties: + mark_collision(casualty, rx_nodeId, reason) + if casualty == packet: + col = 1 + return col + + def frequency_collision(p1, p2): - if abs(p1.freq - p2.freq) <= 120 and (p1.bw == 500 or p2.freq == 500): + delta_khz = _frequency_delta_khz(p1, p2) + p1_bw_khz = _bandwidth_khz(p1) + p2_bw_khz = _bandwidth_khz(p2) + + if delta_khz <= 120 and (p1_bw_khz == 500 or p2_bw_khz == 500): return True - elif abs(p1.freq - p2.freq) <= 60 and (p1.bw == 250 or p2.freq == 250): + elif delta_khz <= 60 and (p1_bw_khz == 250 or p2_bw_khz == 250): return True - elif abs(p1.freq - p2.freq) <= 30: + elif delta_khz <= 30: return True return False +def _frequency_delta_khz(p1, p2): + """Return center-frequency separation in kHz. + + Meshtasticator stores modem frequencies in Hz. Some small tests and older + LoRaSim-derived snippets use MHz-scale values, so normalize both shapes here + instead of making the collision predicate depend on caller units. + """ + delta = abs(p1.freq - p2.freq) + if max(abs(p1.freq), abs(p2.freq)) > 1e6: + return delta / 1000.0 + return delta * 1000.0 + + +def _bandwidth_khz(packet): + """Return LoRa bandwidth in kHz for both Hz and kHz-style packet fields.""" + return packet.bw / 1000.0 if packet.bw > 1000 else packet.bw + + def sf_collision(p1, p2): return p1.sf == p2.sf +def mark_collision(packet, rx_nodeId, reason): + packet.collidedAtN[rx_nodeId] = True + if hasattr(packet, "collisionReasonAtN"): + packet.collisionReasonAtN[rx_nodeId] = reason + + def power_collision(p1, p2, rx_nodeId): powerThreshold = 6 # dB if abs(p1.rssiAtN[rx_nodeId] - p2.rssiAtN[rx_nodeId]) < powerThreshold: @@ -91,6 +156,74 @@ def timing_collision(conf, env, p1, p2): return False +def intervals_overlap(start1, end1, start2, end2): + return max(start1, start2) < min(end1, end2) + + +def overlap_ms(p1, p2): + return max(0.0, min(p1.endTime, p2.endTime) - max(p1.startTime, p2.startTime)) + + +def preamble_lock_window_ms(conf, packet): + """Approximate the fragile LoRa preamble/header acquisition interval.""" + symbols = max(1, conf.NPREAM - 5) + return symbols * (2 ** packet.sf) / packet.bw * 1000 + + +def overlaps_preamble_lock(conf, victim, interferer): + return intervals_overlap( + victim.startTime, + min(victim.endTime, victim.startTime + preamble_lock_window_ms(conf, victim)), + interferer.startTime, + interferer.endTime, + ) + + +def packet_survives_overlap(conf, victim, interferer, rx_nodeId): + """Return whether `victim` survives this one overlapping interferer. + + This is still a compact simulator model, not a chip-level LoRa demodulator. + It encodes the two big effects the binary model misses: capture by a packet + that is at least COLLISION_CAPTURE_THRESHOLD_DB stronger at this receiver, + and small late-tail overlap that does not destroy an already-locked packet. + """ + desired_margin_db = victim.rssiAtN[rx_nodeId] - interferer.rssiAtN[rx_nodeId] + if desired_margin_db >= conf.COLLISION_CAPTURE_THRESHOLD_DB: + return True + + if overlaps_preamble_lock(conf, victim, interferer): + return False + + fraction = overlap_ms(victim, interferer) / victim.timeOnAir if victim.timeOnAir > 0 else 1.0 + if fraction >= conf.COLLISION_PAYLOAD_OVERLAP_LOSS_FRACTION: + return False + + return True + + +def capture_collision_casualties(conf, p1, p2, rx_nodeId): + casualties = [] + if _packet_was_decodable_at_rx(p1, rx_nodeId) and not packet_survives_overlap(conf, p1, p2, rx_nodeId): + casualties.append((p1, "capture_overlap")) + if _packet_was_decodable_at_rx(p2, rx_nodeId) and not packet_survives_overlap(conf, p2, p1, rx_nodeId): + casualties.append((p2, "capture_overlap")) + return casualties + + +def _packet_was_decodable_at_rx(packet, rx_nodeId): + """Return whether collision loss is meaningful for this packet. + + Capture mode tracks CAD-detectable-but-undecodable packets as interference + energy. Those packets can jam another packet, but they should not inflate + collision counters as failed decodes because they were below the receiver's + demodulation threshold before overlap was considered. + """ + sensed_by_node = getattr(packet, "sensedByN", None) + if sensed_by_node is None: + return True + return sensed_by_node[rx_nodeId] + + def is_channel_active(node, env): if random.randrange(10) <= node.conf.INTERFERENCE_LEVEL * 10: return True @@ -142,7 +275,12 @@ def estimate_path_loss(conf, dist, freq, txZ=None, rxZ=None, model=None): model = conf.MODEL # With randomized movements we may end up on top of another node which is problematic for log(dist) - dist = max(dist, .001) + # + # Some real-mesh presets can also set a larger floor as an empirical + # near-field/clutter calibration. The 3GPP/Hata formulas are not meaningful + # at apartment-scale separations, and map node positions are coarse enough + # that "two pins are close" does not mean "two antennas have clear 20 m RF". + dist = max(dist, conf.PATH_LOSS_DISTANCE_FLOOR_M) # Log-Distance model if model == 0: diff --git a/lib/presets.py b/lib/presets.py new file mode 100644 index 00000000..e1652344 --- /dev/null +++ b/lib/presets.py @@ -0,0 +1,154 @@ +"""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", + }, +} + +RADIO_CALIBRATION_FIELDS = ( + "NOISE_LEVEL", + "PATH_LOSS_DISTANCE_FLOOR_M", + "REPORTED_SNR_MIN_DB", + "REPORTED_SNR_MAX_DB", + "LINK_CALIBRATION_MODEL_ENABLED", + "LINK_CALIBRATION_COEFFICIENTS", + "LINK_CALIBRATION_SNR_MIN_DB", + "LINK_CALIBRATION_SNR_MAX_DB", +) + + +def available_presets(): + return sorted(PRESETS.keys()) + + +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 snapshot_radio_calibration(conf): + """Capture caller-default radio calibration so reusable CLI parses reset cleanly.""" + snapshot = {} + for field in RADIO_CALIBRATION_FIELDS: + value = getattr(conf, field) + snapshot[field] = value.copy() if isinstance(value, dict) else value + return snapshot + + +def restore_radio_calibration(conf, snapshot): + for field, value in snapshot.items(): + setattr(conf, field, value.copy() if isinstance(value, dict) else value) + + +def apply_preset_radio_calibration(conf, name): + """Apply optional aggregate radio calibration stored with a packaged preset. + + 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/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/lib/srtm.py b/lib/srtm.py new file mode 100644 index 00000000..31f1e2ab --- /dev/null +++ b/lib/srtm.py @@ -0,0 +1,372 @@ +"""Mapzen Terrain Tiles / SRTM HGT helpers for Meshtasticator terrain inputs. + +The simulator can build an in-memory terrain grid directly from cached or +downloaded HGT tiles. +""" + +import gzip +import math +import shutil +import sys +import urllib.error +import zipfile +from array import array +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlopen + +from lib.terrain import TerrainGrid, latlon_to_xy + + +DEFAULT_SRTM_URL_TEMPLATE = ( + "https://s3.amazonaws.com/elevation-tiles-prod/skadi/{lat_band}/{tile}.hgt.gz" +) +SRTM_DATA_ATTRIBUTION = ( + "Mapzen Terrain Tiles on AWS, using SRTM/NASA and other open elevation sources" +) +SRTM_DATA_ATTRIBUTION_URL = "https://registry.opendata.aws/terrain-tiles/" +HGT_VOID = -32768 +SRTM_MIN_LAT = -56.0 +SRTM_MAX_LAT = 60.0 +SRTM_MIN_LON = -180.0 +SRTM_MAX_LON = 180.0 + + +def clamp_bbox_to_srtm_coverage(bbox): + """Clamp a derived geographic bbox to available SRTM coverage.""" + min_lat, min_lon, max_lat, max_lon = bbox + clamped = ( + max(min_lat, SRTM_MIN_LAT), + max(min_lon, SRTM_MIN_LON), + min(max_lat, SRTM_MAX_LAT), + min(max_lon, SRTM_MAX_LON), + ) + if clamped[0] >= clamped[2] or clamped[1] >= clamped[3]: + raise ValueError("bbox does not overlap SRTM coverage") + return clamped + + +def srtm_tile_name(lat, lon): + """Return the HGT tile name containing `lat, lon`, for example N41E041.""" + lat_floor = math.floor(lat) + lon_floor = math.floor(lon) + lat_prefix = "N" if lat_floor >= 0 else "S" + lon_prefix = "E" if lon_floor >= 0 else "W" + return f"{lat_prefix}{abs(lat_floor):02d}{lon_prefix}{abs(lon_floor):03d}" + + +def _edge_clamped_sample_coordinate(value, upper_bound): + return math.nextafter(value, -math.inf) if value == upper_bound else value + + +def _sample_tile_name(lat, lon): + lat_for_tile = _edge_clamped_sample_coordinate(lat, SRTM_MAX_LAT) + lon_for_tile = _edge_clamped_sample_coordinate(lon, SRTM_MAX_LON) + return srtm_tile_name(lat_for_tile, lon_for_tile) + + +def _parse_tile_name(tile_name): + if len(tile_name) != 7 or tile_name[0] not in "NS" or tile_name[3] not in "EW": + raise ValueError(f"invalid SRTM tile name: {tile_name}") + + lat = int(tile_name[1:3]) + lon = int(tile_name[4:7]) + return (-lat if tile_name[0] == "S" else lat, -lon if tile_name[3] == "W" else lon) + + +def tile_bbox(tile_name): + """Return the geographic bbox covered by one SRTM tile.""" + south, west = _parse_tile_name(tile_name) + return south, west, south + 1.0, west + 1.0 + + +def tiles_for_bbox(bbox): + """Return sorted SRTM tile names covering a geographic bounding box.""" + min_lat, min_lon, max_lat, max_lon = bbox + if min_lat < SRTM_MIN_LAT or max_lat > SRTM_MAX_LAT: + raise ValueError("SRTM coverage is limited to latitudes between 56°S and 60°N") + if min_lon < SRTM_MIN_LON or max_lon > SRTM_MAX_LON: + raise ValueError( + "SRTM coverage is limited to longitudes between 180°W and 180°E" + ) + + min_lat_for_tile = ( + math.nextafter(min_lat, -math.inf) if min_lat == SRTM_MAX_LAT else min_lat + ) + min_lon_for_tile = ( + math.nextafter(min_lon, -math.inf) if min_lon == SRTM_MAX_LON else min_lon + ) + max_lat_for_tile = ( + math.nextafter(max_lat, -math.inf) + if max_lat > min_lat or max_lat == SRTM_MAX_LAT + else max_lat + ) + max_lon_for_tile = ( + math.nextafter(max_lon, -math.inf) + if max_lon > min_lon or max_lon == SRTM_MAX_LON + else max_lon + ) + min_lat_tile = math.floor(min_lat_for_tile) + min_lon_tile = math.floor(min_lon_for_tile) + max_lat_tile = math.floor(max_lat_for_tile) + max_lon_tile = math.floor(max_lon_for_tile) + names = [] + for lat_floor in range(min_lat_tile, max_lat_tile + 1): + for lon_floor in range(min_lon_tile, max_lon_tile + 1): + names.append(srtm_tile_name(lat_floor, lon_floor)) + return sorted(set(names)) + + +class SrtmTile: + """A single SRTM HGT tile loaded into memory. + + HGT files are square grids of signed big-endian 16-bit elevations. Real + SRTM tiles are usually 1201x1201 or 3601x3601, but tests use tiny square + files and the reader deliberately accepts those too. + """ + + def __init__(self, tile_name, side, elevations): + self.tile_name = tile_name + self.south, self.west = _parse_tile_name(tile_name) + self.north = self.south + 1 + self.east = self.west + 1 + self.side = side + self.elevations = elevations + + @classmethod + def from_hgt(cls, path, tile_name=None): + path = Path(path) + tile_name = tile_name or path.name.split(".")[0] + data = path.read_bytes() + if len(data) % 2: + raise ValueError(f"HGT file has odd byte length: {path}") + + cell_count = len(data) // 2 + side = math.isqrt(cell_count) + if side * side != cell_count: + raise ValueError(f"HGT file is not a square grid: {path}") + + elevations = array("h") + elevations.frombytes(data) + if sys.byteorder == "little": + elevations.byteswap() + return cls(tile_name, side, elevations) + + def elevation_at(self, lat, lon): + """Return nearest-sample elevation in meters, or None for SRTM voids.""" + row = round((self.north - lat) * (self.side - 1)) + col = round((lon - self.west) * (self.side - 1)) + row = min(max(row, 0), self.side - 1) + col = min(max(col, 0), self.side - 1) + return self._nearest_valid_elevation(row, col) + + def _nearest_valid_elevation(self, row, col): + value = self._value_at(row, col) + if value is not None: + return value + + # SRTM voids are rare around populated areas. A tiny local search keeps + # one bad pixel from punching a hole in an otherwise usable terrain grid. + for radius in range(1, 4): + candidates = [] + for rr in range(max(0, row - radius), min(self.side, row + radius + 1)): + for cc in range(max(0, col - radius), min(self.side, col + radius + 1)): + if abs(rr - row) != radius and abs(cc - col) != radius: + continue + value = self._value_at(rr, cc) + if value is not None: + candidates.append((abs(rr - row) + abs(cc - col), value)) + if candidates: + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + return None + + def _value_at(self, row, col): + value = self.elevations[row * self.side + col] + if value == HGT_VOID: + return None + return float(value) + + +def ensure_hgt_tile( + tile_name, cache_dir, url_template=DEFAULT_SRTM_URL_TEMPLATE, download_missing=True +): + """Return a cached `.hgt` path, downloading and unpacking it when allowed.""" + cache_dir = Path(cache_dir).expanduser() + cache_dir.mkdir(parents=True, exist_ok=True) + hgt_path = cache_dir / f"{tile_name}.hgt" + if hgt_path.exists(): + return hgt_path + + if not download_missing: + raise FileNotFoundError(f"missing cached SRTM tile: {hgt_path}") + + lat_band = tile_name[:3] + try: + url = url_template.format(tile=tile_name, lat_band=lat_band) + except KeyError as err: + raise ValueError( + "url_template may only use {tile} and {lat_band} placeholders" + ) from err + parsed_path = Path(urlparse(url).path) + download_path = ( + cache_dir / f"{tile_name}{''.join(parsed_path.suffixes) or '.download'}" + ) + partial_hgt_path = cache_dir / f"{tile_name}.hgt.tmp" + + try: + with urlopen(url, timeout=60) as response, download_path.open("wb") as out: + shutil.copyfileobj(response, out) + except (OSError, urllib.error.URLError) as err: + raise ValueError( + f"could not download SRTM tile {tile_name} from {url}: {err}" + ) from err + + try: + partial_hgt_path.unlink(missing_ok=True) + if download_path.suffix == ".gz": + with ( + gzip.open(download_path, "rb") as src, + partial_hgt_path.open("wb") as out, + ): + shutil.copyfileobj(src, out) + elif download_path.suffix == ".zip": + with zipfile.ZipFile(download_path) as archive: + hgt_members = [ + name for name in archive.namelist() if name.lower().endswith(".hgt") + ] + if not hgt_members: + raise ValueError(f"zip archive has no .hgt member: {download_path}") + expected_name = f"{tile_name}.hgt".lower() + matching_members = [ + name + for name in hgt_members + if Path(name).name.lower() == expected_name + ] + if not matching_members: + raise ValueError( + f"zip archive has no {tile_name}.hgt member: {download_path}" + ) + with ( + archive.open(matching_members[0]) as src, + partial_hgt_path.open("wb") as out, + ): + shutil.copyfileobj(src, out) + else: + download_path.replace(partial_hgt_path) + partial_hgt_path.replace(hgt_path) + except (OSError, gzip.BadGzipFile, zipfile.BadZipFile, ValueError) as err: + partial_hgt_path.unlink(missing_ok=True) + raise ValueError(f"could not unpack SRTM tile {tile_name}: {err}") from err + + return hgt_path + + +def _coordinate_values(start, stop, step): + values = [] + value = start + while value <= stop + 1e-12: + values.append(value) + value += step + + if 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, + tile_names=None, +): + """Yield lat/lon/elevation rows sampled from SRTM tiles over `bbox`.""" + if not math.isfinite(step_meters) or step_meters <= 0: + raise ValueError("step_meters must be a positive finite number") + + min_lat, min_lon, max_lat, max_lon = bbox + mid_lat = (min_lat + max_lat) / 2.0 + lat_step = step_meters / 111320.0 + lon_step = step_meters / (111320.0 * max(math.cos(math.radians(mid_lat)), 0.01)) + + requested_tile_names = sorted(set(tile_names or tiles_for_bbox(bbox))) + tiles = {} + for tile_name in requested_tile_names: + path = ensure_hgt_tile(tile_name, cache_dir, url_template, download_missing) + tiles[tile_name] = SrtmTile.from_hgt(path, tile_name) + + emitted = set() + for tile_name, tile in tiles.items(): + tile_min_lat, tile_min_lon, tile_max_lat, tile_max_lon = tile_bbox(tile_name) + sample_min_lat = max(min_lat, tile_min_lat) + sample_min_lon = max(min_lon, tile_min_lon) + sample_max_lat = min(max_lat, tile_max_lat) + sample_max_lon = min(max_lon, tile_max_lon) + if sample_min_lat > sample_max_lat or sample_min_lon > sample_max_lon: + continue + + for lat in _coordinate_values(sample_min_lat, sample_max_lat, lat_step): + for lon in _coordinate_values(sample_min_lon, sample_max_lon, lon_step): + sample_key = (round(lat, 7), round(lon, 7)) + if sample_key in emitted: + continue + emitted.add(sample_key) + + sample_tile = tiles.get(_sample_tile_name(lat, lon)) + if sample_tile is None: + continue + elevation = sample_tile.elevation_at(lat, lon) + if elevation is None: + continue + yield { + "lat": f"{lat:.7f}", + "lon": f"{lon:.7f}", + "elevation_m": f"{elevation:.1f}", + } + + for lat, lon in ( + (sample_min_lat, sample_min_lon), + (sample_min_lat, sample_max_lon), + (sample_max_lat, sample_min_lon), + (sample_max_lat, sample_max_lon), + ): + sample_key = (round(lat, 7), round(lon, 7)) + if sample_key in emitted: + continue + emitted.add(sample_key) + tile = tiles.get(_sample_tile_name(lat, lon)) + if tile is None: + continue + elevation = tile.elevation_at(lat, lon) + if elevation is None: + continue + yield { + "lat": f"{lat:.7f}", + "lon": f"{lon:.7f}", + "elevation_m": f"{elevation:.1f}", + } + + +def terrain_grid_from_srtm( + bbox, + step_meters, + cache_dir, + origin_lat, + origin_lon, + url_template=DEFAULT_SRTM_URL_TEMPLATE, + download_missing=True, + tile_names=None, +): + """Build an in-memory TerrainGrid from SRTM tiles without writing CSV.""" + samples = [] + for row in terrain_rows_from_srtm( + bbox, step_meters, cache_dir, url_template, download_missing, tile_names + ): + lat = float(row["lat"]) + lon = float(row["lon"]) + elevation = float(row["elevation_m"]) + x, y = latlon_to_xy(lat, lon, origin_lat, origin_lon) + samples.append((x, y, elevation)) + return TerrainGrid.from_rows(samples) diff --git a/lib/terrain.py b/lib/terrain.py new file mode 100644 index 00000000..c9b634c6 --- /dev/null +++ b/lib/terrain.py @@ -0,0 +1,238 @@ +"""Optional terrain obstruction model for radio links. + +The simulator's default coordinates are local meters with `Point.z` acting like +antenna height. Terrain-aware input loaders can lift points to absolute antenna +altitude (`ground elevation + antenna height`) so the existing 3D distance path +already reflects terrain before any extra RF obstruction loss is applied. + +The loss model is intentionally conservative and dependency-free: sample the +path, find the worst Fresnel/line-of-sight obstruction, then apply the standard +single knife-edge diffraction approximation for that obstruction. It is not a +full ray tracer, but it captures the important Batumi-mesh case where hills and +ridges matter more than flat-earth distance alone. +""" + +import math + + +EARTH_RADIUS_M = 6371000.0 +NODE_Z_REFERENCE_GROUND = "ground" +NODE_Z_REFERENCE_SEA_LEVEL = "sea_level" +MAX_REASONABLE_STRUCTURE_HEIGHT_M = 850.0 + + +def latlon_to_xy(lat, lon, origin_lat, origin_lon): + """Project WGS84 lat/lon to local x/y meters with an equirectangular map.""" + origin_lat_rad = math.radians(origin_lat) + x = math.radians(lon - origin_lon) * EARTH_RADIUS_M * math.cos(origin_lat_rad) + y = math.radians(lat - origin_lat) * EARTH_RADIUS_M + return x, y + + +def xy_to_latlon(x, y, origin_lat, origin_lon): + """Inverse of latlon_to_xy for small local simulation areas.""" + origin_lat_rad = math.radians(origin_lat) + origin_cos = math.cos(origin_lat_rad) + if abs(origin_cos) < 1e-9: + raise ValueError("origin latitude is too close to a pole for local x/y projection") + + lat = origin_lat + math.degrees(y / EARTH_RADIUS_M) + lon = origin_lon + math.degrees(x / (EARTH_RADIUS_M * origin_cos)) + return lat, lon + + +class TerrainGrid: + """Small scattered terrain sample grid with inverse-distance interpolation.""" + + def __init__(self, samples): + self.samples = samples + + @classmethod + def from_rows(cls, rows): + """Build a terrain grid from `(x_m, y_m, elevation_m)` samples.""" + samples = [] + for row_number, row in enumerate(rows, start=1): + try: + x, y, elevation = row + except (TypeError, ValueError) as err: + raise ValueError(f"terrain sample {row_number} must have x, y, and elevation") from err + x = float(x) + y = float(y) + elevation = float(elevation) + if not math.isfinite(x) or not math.isfinite(y) or not math.isfinite(elevation): + raise ValueError(f"terrain sample {row_number} values must be finite") + samples.append((x, y, elevation)) + + if not samples: + raise ValueError("terrain grid has no samples") + return cls(samples) + + def elevation_at(self, x, y): + weighted_sum = 0.0 + weight_total = 0.0 + + nearest = sorted( + ((math.hypot(x - sx, y - sy), elevation) for sx, sy, elevation in self.samples), + key=lambda item: item[0], + )[:8] + + for distance, elevation in nearest: + if distance < 0.01: + return elevation + weight = 1.0 / (distance * distance) + weighted_sum += elevation * weight + weight_total += weight + + return weighted_sum / weight_total + + +def _terrain_grid(conf): + """Return the configured in-memory terrain grid when terrain is enabled.""" + if not conf.TERRAIN_ENABLED: + return None + return getattr(conf, "TERRAIN_GRID", None) + + +def terrain_ground_elevation(conf, point): + """Return terrain elevation at a point, or None when terrain is unavailable.""" + grid = _terrain_grid(conf) + if grid is None: + return None + return grid.elevation_at(point.x, point.y) + + +def map_altitude_if_plausible(node, ground): + """Return per-node map altitude when SRTM says it is physically plausible.""" + altitude = getattr(node, "absolute_altitude", None) + if altitude is None: + return None + altitude = float(altitude) + if not math.isfinite(altitude): + return None + if altitude <= ground: + return None + if altitude > ground + MAX_REASONABLE_STRUCTURE_HEIGHT_M: + return None + return altitude + + +def node_antenna_height(node): + """Return node antenna height above ground for config and live node types.""" + return getattr( + node, + "antenna_height", + getattr(node, "antennaHeight", node.position.z), + ) + + +def apply_terrain_altitude(terrain_grid, node): + """Lift one node z coordinate to absolute antenna altitude from terrain. + + `node.antenna_height` remains antenna height above local ground. That keeps + path-loss models with explicit antenna-height terms from receiving absolute + MSL altitude by mistake. + """ + ground = terrain_grid.elevation_at(node.position.x, node.position.y) + map_altitude = map_altitude_if_plausible(node, ground) + if map_altitude is None: + node.position.z = ground + node_antenna_height(node) + else: + node.position.z = map_altitude + + +def apply_terrain_altitudes(terrain_grid, node_config): + """Lift node z coordinates to absolute antenna altitude from terrain.""" + for node in node_config: + apply_terrain_altitude(terrain_grid, node) + + +def terrain_antenna_altitude(conf, grid, point): + """Return absolute antenna altitude for a terrain-backed point.""" + ground = grid.elevation_at(point.x, point.y) + min_altitude = ground + conf.TERRAIN_MIN_ANTENNA_HEIGHT_M + if getattr(conf, "NODE_Z_REFERENCE", NODE_Z_REFERENCE_GROUND) == NODE_Z_REFERENCE_SEA_LEVEL: + return max(point.z, min_altitude) + return ground + max(point.z, conf.TERRAIN_MIN_ANTENNA_HEIGHT_M) + + +def knife_edge_loss_db(v): + """ITU-R single knife-edge diffraction loss approximation.""" + if v <= -0.78: + return 0.0 + return 6.9 + 20.0 * math.log10(math.sqrt((v - 0.1) ** 2 + 1.0) + v - 0.1) + + +def terrain_obstruction_loss(conf, tx_point, rx_point, freq): + """Estimate extra terrain obstruction loss in dB for a TX/RX path.""" + grid = _terrain_grid(conf) + if grid is None: + return 0.0 + + # Packet objects are created often and ask for every receiver. In a static + # topology the terrain term is a pure function of the two endpoint + # coordinates, so cache it on Config and keep the packet hot path cheap. + cache = getattr(conf, "_terrain_loss_cache", None) + if cache is None: + cache = {} + conf._terrain_loss_cache = cache + + cache_key = ( + round(tx_point.x, 2), + round(tx_point.y, 2), + round(tx_point.z, 2), + round(rx_point.x, 2), + round(rx_point.y, 2), + round(rx_point.z, 2), + round(freq, 0), + id(getattr(conf, "TERRAIN_GRID", None)), + conf.GEO_ORIGIN_LAT, + conf.GEO_ORIGIN_LON, + conf.TERRAIN_PROFILE_SAMPLES, + conf.TERRAIN_FRESNEL_CLEARANCE, + conf.TERRAIN_EFFECTIVE_EARTH_RADIUS_MULTIPLIER, + conf.TERRAIN_MIN_ANTENNA_HEIGHT_M, + conf.TERRAIN_MAX_LOSS_DB, + ) + if cache_key in cache: + return cache[cache_key] + + horizontal_distance = math.hypot(rx_point.x - tx_point.x, rx_point.y - tx_point.y) + if horizontal_distance <= 0: + return 0.0 + + wavelength = 299792458.0 / freq + tx_height = terrain_antenna_altitude(conf, grid, tx_point) + rx_height = terrain_antenna_altitude(conf, grid, rx_point) + effective_earth_radius = EARTH_RADIUS_M * conf.TERRAIN_EFFECTIVE_EARTH_RADIUS_MULTIPLIER + curvature_scale = (horizontal_distance * horizontal_distance) / (2.0 * effective_earth_radius) + + worst_loss = 0.0 + for i in range(1, conf.TERRAIN_PROFILE_SAMPLES): + fraction = i / conf.TERRAIN_PROFILE_SAMPLES + x = tx_point.x + (rx_point.x - tx_point.x) * fraction + y = tx_point.y + (rx_point.y - tx_point.y) * fraction + ground = grid.elevation_at(x, y) + los_height = tx_height + (rx_height - tx_height) * fraction + d1 = horizontal_distance * fraction + d2 = horizontal_distance - d1 + + fresnel_radius = math.sqrt(wavelength * d1 * d2 / horizontal_distance) + # A straight local projection makes long links look too flat. Apply the + # usual 4/3 effective Earth radius bulge at each path sample before + # measuring Fresnel clearance against the line between antennas. + earth_bulge = curvature_scale * fraction * (1.0 - fraction) + obstruction_height = ( + ground + + earth_bulge + + conf.TERRAIN_FRESNEL_CLEARANCE * fresnel_radius + - los_height + ) + if obstruction_height <= 0: + continue + + v = obstruction_height * math.sqrt(2.0 * horizontal_distance / (wavelength * d1 * d2)) + worst_loss = max(worst_loss, knife_edge_loss_db(v)) + + loss = min(worst_loss, conf.TERRAIN_MAX_LOSS_DB) + cache[cache_key] = loss + return loss diff --git a/loraMesh.py b/loraMesh.py index ac54e536..552fac98 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -4,11 +4,51 @@ import math import os import random +from pathlib import Path import yaml from lib.config import CONFIG -from lib.node import NodeConfig, default_generate_node_list +from lib.map_input import ( + DEFAULT_MAP_NODES_URL, + fetch_map_payload, + node_configs_from_map_payload, + parse_bbox, +) +from lib.nodedb_input import fetch_nodedb_payload, node_configs_from_nodedb_payload +from lib.node import ( + NodeConfig, + default_generate_node_list, + node_configs_from_yaml, + origin_from_yaml, +) +from lib.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, + restore_radio_calibration, + snapshot_radio_calibration, +) +from lib.srtm import ( + DEFAULT_SRTM_URL_TEMPLATE, + SRTM_DATA_ATTRIBUTION, + SRTM_DATA_ATTRIBUTION_URL, + clamp_bbox_to_srtm_coverage, + terrain_grid_from_srtm, + tiles_for_bbox, +) +from lib.terrain import ( + NODE_Z_REFERENCE_SEA_LEVEL, + apply_terrain_altitudes, + node_antenna_height, + xy_to_latlon, +) +from lib.phy import estimate_path_loss conf = CONFIG logger = logging.getLogger(__name__) @@ -18,7 +58,7 @@ def configure_logging(): """Apply CLI logging defaults without changing logging during module import.""" - logging.basicConfig(level=logging.INFO) # default log level + logging.basicConfig(level=logging.INFO) # default log level def get_cli_defaults(conf): @@ -32,11 +72,156 @@ def get_cli_defaults(conf): "PERIOD": conf.PERIOD, "GUI_ENABLED": conf.GUI_ENABLED, "PLOT": conf.PLOT, + "TERRAIN_PROFILE_SAMPLES": conf.TERRAIN_PROFILE_SAMPLES, + "CLUTTER_PROFILE_SAMPLES": conf.CLUTTER_PROFILE_SAMPLES, + "NODE_Z_REFERENCE": conf.NODE_Z_REFERENCE, + "RADIO_CALIBRATION": snapshot_radio_calibration(conf), + "DTP_MAX_POWER_DROP_DB": conf.DTP_MAX_POWER_DROP_DB, + "DTP_POWER_STEP_DB": conf.DTP_POWER_STEP_DB, + "DTP_MIN_TX_POWER_DBM": conf.DTP_MIN_TX_POWER_DBM, + "DTP_STRONG_LINK_MARGIN_DB": conf.DTP_STRONG_LINK_MARGIN_DB, + "DTP_VERY_STRONG_LINK_MARGIN_DB": conf.DTP_VERY_STRONG_LINK_MARGIN_DB, }, ) return getattr(conf, CLI_DEFAULT_ATTR) +def set_geo_origin(conf, origin): + """Use scenario geographic origin for lat/lon terrain grids when available.""" + if origin is None: + conf.GEO_ORIGIN_LAT = None + conf.GEO_ORIGIN_LON = None + return + conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON = origin + + +def bbox_from_points(points, origin, margin_m=1000.0): + """Build a geographic bbox around local x/y points when an origin exists.""" + if origin is None: + return None + origin_lat, origin_lon = origin + min_x = min(point.x for point in points) - margin_m + max_x = max(point.x for point in points) + margin_m + min_y = min(point.y for point in points) - margin_m + max_y = max(point.y for point in points) + margin_m + lat_a, lon_a = xy_to_latlon(min_x, min_y, origin_lat, origin_lon) + lat_b, lon_b = xy_to_latlon(max_x, max_y, origin_lat, origin_lon) + return clamp_bbox_to_srtm_coverage( + ( + min(lat_a, lat_b), + min(lon_a, lon_b), + max(lat_a, lat_b), + max(lon_a, lon_b), + ) + ) + + +def bbox_from_node_config(node_config, origin, margin_m=1000.0): + """Build a geographic bbox around local x/y nodes when an origin exists.""" + return bbox_from_points([node.position for node in node_config], origin, margin_m) + + +def fit_simulation_bounds_to_node_config(conf, node_config, margin_m=1000.0): + """Expand movement/GUI bounds so loaded coordinates are not clamped.""" + min_x = min(node.position.x for node in node_config) - margin_m + max_x = max(node.position.x for node in node_config) + margin_m + min_y = min(node.position.y for node in node_config) - margin_m + max_y = max(node.position.y for node in node_config) + margin_m + + left = conf.OX - conf.XSIZE / 2 + right = conf.OX + conf.XSIZE / 2 + bottom = conf.OY - conf.YSIZE / 2 + top = conf.OY + conf.YSIZE / 2 + if left <= min_x and max_x <= right and bottom <= min_y and max_y <= top: + return + + conf.OX = (min_x + max_x) / 2 + conf.OY = (min_y + max_y) / 2 + conf.XSIZE = max_x - min_x + conf.YSIZE = max_y - min_y + + +def nodes_have_flat_link_budget(conf, node_a, node_b): + """Return whether two nodes can hear each other before terrain loss.""" + distance = node_a.position.euclidean_distance(node_b.position) + path_loss = estimate_path_loss( + conf, + distance, + conf.FREQ, + node_antenna_height(node_a), + node_antenna_height(node_b), + ) + sensitivity = conf.current_preset["sensitivity"] + antenna_gain_a = getattr(node_a, "antennaGain", getattr(node_a, "antenna_gain", 0)) + antenna_gain_b = getattr(node_b, "antennaGain", getattr(node_b, "antenna_gain", 0)) + tx_power_a = getattr(node_a, "tx_power", conf.PTX) + tx_power_b = getattr(node_b, "tx_power", conf.PTX) + rssi_ab = tx_power_a + antenna_gain_a + antenna_gain_b - path_loss + rssi_ba = tx_power_b + antenna_gain_b + antenna_gain_a - path_loss + return rssi_ab >= sensitivity or rssi_ba >= sensitivity + + +def srtm_tiles_for_node_config_links(conf, node_config, origin, margin_m=1000.0): + """Return SRTM tiles around nodes and flat-link candidate paths.""" + if origin is None: + return None + + tile_names = set() + for node in node_config: + bbox = bbox_from_points([node.position], origin, margin_m) + tile_names.update(tiles_for_bbox(bbox)) + + for index, node_a in enumerate(node_config): + for node_b in node_config[index + 1 :]: + if not nodes_have_flat_link_budget(conf, node_a, node_b): + continue + bbox = bbox_from_points( + [node_a.position, node_b.position], origin, margin_m + ) + tile_names.update(tiles_for_bbox(bbox)) + + return sorted(tile_names) + + +def 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. @@ -46,26 +231,211 @@ 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 group = parser.add_mutually_exclusive_group() - group.add_argument('nr_nodes', nargs='?', type=int, help='Number of nodes to generate. If unspecified, do interactive simulation') - group.add_argument('--from-file', nargs='?', const='nodeConfig.yaml', type=str, metavar='filename', help='Name of yaml file storing node config under "out/" directory. If unspecified, defaults to "nodeConfig.yaml".') + group.add_argument( + "nr_nodes", + nargs="?", + type=int, + help="Number of nodes to generate. If unspecified, do interactive simulation", + ) + group.add_argument( + "--from-file", + nargs="?", + const="nodeConfig.yaml", + type=str, + metavar="filename", + help='Name of yaml file storing node config under "out/" directory. If unspecified, defaults to "nodeConfig.yaml".', + ) + group.add_argument( + "--from-map", + nargs="?", + const=DEFAULT_MAP_NODES_URL, + type=str, + metavar="url", + help="Fetch node locations from a Meshtastic map /api/v1/nodes endpoint.", + ) + group.add_argument( + "--from-nodedb", + action="store_true", + help="Fetch positioned nodes from a local Meshtastic device NodeDB.", + ) + 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. # Just implement as an optional argument, and manually treat it as incompatible with `--from-file` - parser.add_argument('--router-type', type=conf.ROUTER_TYPE, choices=conf.ROUTER_TYPE, help='Router type to use, taken from ROUTER_TYPE enum. Omit the leading "ROUTER_TYPE". Incompatible with --from-file') - parser.add_argument('--simtime-seconds', type=float, help='Override simulation duration in seconds') - parser.add_argument('--period-seconds', type=float, help='Override mean message-generation period in seconds') - parser.add_argument('--no-gui', action='store_true', help='Run without Tk/Matplotlib graphing or schedule plotting') - parser.add_argument('--disable-connectivity-map', action='store_true', help='disable the connectivity map optimization. May be faster for some scenarios with many moving nodes and/or a densely connected network.') - parser.add_argument('-v', '--verbose', action='store_true', help='enable verbose/debug output') + parser.add_argument( + "--router-type", + type=conf.ROUTER_TYPE, + choices=conf.ROUTER_TYPE, + help='Router type to use, taken from ROUTER_TYPE enum. Omit the leading "ROUTER_TYPE". Incompatible with --from-file', + ) + parser.add_argument( + "--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( + "--terrain-srtm-cache-dir", + default=str(Path.home() / ".cache" / "meshtasticator" / "srtm"), + help="where downloaded SRTM .hgt tiles are cached", + ) + parser.add_argument( + "--terrain-srtm-url-template", + default=DEFAULT_SRTM_URL_TEMPLATE, + help="SRTM download URL template with {lat_band} and {tile}", + ) + parser.add_argument( + "--terrain-srtm-offline", action="store_true", help="use cached SRTM tiles only" + ) + parser.add_argument( + "--terrain-profile-samples", + type=int, + help="number of terrain samples along each TX/RX path", + ) + parser.add_argument( + "--clutter-grid", + type=str, + help="CSV land-cover clutter grid for optional building/urban excess loss", + ) + parser.add_argument( + "--clutter-profile-samples", + type=int, + help="number of clutter samples along each TX/RX path", + ) + parser.add_argument( + "--no-clutter", + action="store_true", + help="disable land-cover clutter even when a grid is available", + ) + parser.add_argument( + "--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="Position import bounding box as min_lat,min_lon,max_lat,max_lon", + ) + parser.add_argument( + "--map-limit", + type=int, + help="Maximum number of positioned imported nodes after bbox filtering", + ) + parser.add_argument( + "--nodedb-host", + type=str, + help="Hostname or IP of a Meshtastic TCP device for --from-nodedb", + ) + parser.add_argument( + "--nodedb-port", + type=int, + help="TCP port of a Meshtastic TCP device for --from-nodedb", + ) + parser.add_argument( + "--nodedb-serial-port", + type=str, + help="Serial device path for --from-nodedb; defaults to Meshtastic auto-detection", + ) + parser.add_argument( + "--simtime-seconds", type=float, help="Override simulation duration in seconds" + ) + parser.add_argument( + "--period-seconds", + type=float, + help="Override mean message-generation period in seconds", + ) + parser.add_argument( + "--no-gui", + action="store_true", + help="Run without Tk/Matplotlib graphing or schedule plotting", + ) + parser.add_argument( + "--disable-connectivity-map", + action="store_true", + help="disable the connectivity map optimization. May be faster for some scenarios with many moving nodes and/or a densely connected network.", + ) + parser.add_argument( + "--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"] @@ -73,15 +443,71 @@ def parse_params(conf, args=None) -> [NodeConfig]: plot_enabled = cli_defaults["PLOT"] if parsed_arguments.simtime_seconds is not None: - if not math.isfinite(parsed_arguments.simtime_seconds) or parsed_arguments.simtime_seconds < MIN_TIME_OVERRIDE_SECONDS: - parser.error(f"--simtime-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds") + if ( + not math.isfinite(parsed_arguments.simtime_seconds) + or parsed_arguments.simtime_seconds < MIN_TIME_OVERRIDE_SECONDS + ): + parser.error( + f"--simtime-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds" + ) simtime = int(parsed_arguments.simtime_seconds * conf.ONE_SECOND_INTERVAL) if parsed_arguments.period_seconds is not None: - if not math.isfinite(parsed_arguments.period_seconds) or parsed_arguments.period_seconds < MIN_TIME_OVERRIDE_SECONDS: - parser.error(f"--period-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds") + if ( + not math.isfinite(parsed_arguments.period_seconds) + or parsed_arguments.period_seconds < MIN_TIME_OVERRIDE_SECONDS + ): + parser.error( + f"--period-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds" + ) period = int(parsed_arguments.period_seconds * conf.ONE_SECOND_INTERVAL) + if parsed_arguments.map_limit is not None and parsed_arguments.map_limit < 1: + parser.error("--map-limit must be at least 1") + if not math.isfinite(conf.HM) or conf.HM <= 0: + parser.error("config HM must be a positive finite antenna height") + if conf.hopLimit < 0: + parser.error("config hopLimit must be at least 0") + if ( + parsed_arguments.terrain_profile_samples is not None + and parsed_arguments.terrain_profile_samples < 2 + ): + parser.error("--terrain-profile-samples must be at least 2") + if ( + not math.isfinite(parsed_arguments.terrain_srtm_step_meters) + or parsed_arguments.terrain_srtm_step_meters <= 0 + ): + parser.error("--terrain-srtm-step-meters must be a positive finite number") + if parsed_arguments.clutter_profile_samples is not None and parsed_arguments.clutter_profile_samples < 1: + parser.error("--clutter-profile-samples must be at least 1") + if parsed_arguments.dtp_max_drop_db is not None and parsed_arguments.dtp_max_drop_db < 0: + parser.error("--dtp-max-drop-db must be at least 0") + if parsed_arguments.dtp_power_step_db is not None and parsed_arguments.dtp_power_step_db < 1: + parser.error("--dtp-power-step-db must be at least 1") + if ( + parsed_arguments.dtp_strong_margin_db is not None + and (not math.isfinite(parsed_arguments.dtp_strong_margin_db) or parsed_arguments.dtp_strong_margin_db < 0) + ): + parser.error("--dtp-strong-margin-db must be a non-negative finite number") + if ( + parsed_arguments.dtp_very_strong_margin_db is not None + and (not math.isfinite(parsed_arguments.dtp_very_strong_margin_db) or parsed_arguments.dtp_very_strong_margin_db < 0) + ): + parser.error("--dtp-very-strong-margin-db must be a non-negative finite number") + + dtp_strong_margin = ( + parsed_arguments.dtp_strong_margin_db + if parsed_arguments.dtp_strong_margin_db is not None + else cli_defaults["DTP_STRONG_LINK_MARGIN_DB"] + ) + dtp_very_strong_margin = ( + parsed_arguments.dtp_very_strong_margin_db + if parsed_arguments.dtp_very_strong_margin_db is not None + else cli_defaults["DTP_VERY_STRONG_LINK_MARGIN_DB"] + ) + if dtp_very_strong_margin < dtp_strong_margin: + parser.error("--dtp-very-strong-margin-db must be >= --dtp-strong-margin-db") + if parsed_arguments.no_gui: # Headless CI and smoke runs should not pay Tk startup, per-node # plt.pause(), or the final interactive schedule plot. Keep this as an @@ -89,28 +515,129 @@ def parse_params(conf, args=None) -> [NodeConfig]: gui_enabled = False plot_enabled = False - # enforce defaulting to True - if parsed_arguments.disable_connectivity_map: - conf.ENABLE_CONNECTIVITY_MAP = False - else: - conf.ENABLE_CONNECTIVITY_MAP = True + connectivity_map_enabled = not parsed_arguments.disable_connectivity_map - if parsed_arguments.from_file is not None and parsed_arguments.router_type is not None: - parser.error("Incompatible argument selection. --from-file and --router-type can not be used together") + if ( + parsed_arguments.from_file is not None + or parsed_arguments.from_map is not None + or parsed_arguments.from_nodedb + or parsed_arguments.preset is not None + ) and parsed_arguments.router_type is not None: + parser.error( + "Incompatible argument selection. --from-file/--from-map/--from-nodedb/--preset and --router-type can not be used together" + ) + if not parsed_arguments.from_nodedb and ( + parsed_arguments.nodedb_host is not None + or parsed_arguments.nodedb_port is not None + or parsed_arguments.nodedb_serial_port is not None + ): + parser.error("--nodedb-* options require --from-nodedb") + if ( + parsed_arguments.from_nodedb + and parsed_arguments.nodedb_port is not None + and parsed_arguments.nodedb_host is None + ): + parser.error("--nodedb-port requires --nodedb-host") + if parsed_arguments.no_clutter and parsed_arguments.clutter_grid: + parser.error("--no-clutter can not be combined with --clutter-grid") seeded_for_scenario = False + bounds_follow_node_config = False + terrain_bbox = None + terrain_tile_names = None + scenario_origin = None + terrain_grid = None + terrain_enabled = parsed_arguments.terrain_srtm + terrain_profile_samples = cli_defaults["TERRAIN_PROFILE_SAMPLES"] + node_z_reference = cli_defaults["NODE_Z_REFERENCE"] + if parsed_arguments.terrain_profile_samples is not None: + terrain_profile_samples = parsed_arguments.terrain_profile_samples + bundled_terrain_grid = None + bundled_clutter_grid = None + selected_preset = None if parsed_arguments.from_file is not None: - with open(os.path.join("out", parsed_arguments.from_file), 'r', encoding="utf-8") as file: - raw_config = yaml.load(file, Loader=yaml.FullLoader) - config = [ - # transmit power and frequency not previously saved. Use defaults from Config. - NodeConfig.from_gen_scenario_output(node_id, node_config, period, conf.PTX, conf.FREQ) - for node_id, node_config in raw_config.items() - ] + try: + if parsed_arguments.map_bbox is not None: + terrain_bbox = parse_bbox(parsed_arguments.map_bbox) + with open( + os.path.join("out", parsed_arguments.from_file), "r", encoding="utf-8" + ) as file: + raw_config = yaml.safe_load(file) + config = node_configs_from_yaml(raw_config, period, conf.PTX, conf.FREQ) + scenario_origin = origin_from_yaml(raw_config) + except (OSError, ValueError, yaml.YAMLError) as err: + parser.error(f"could not load --from-file YAML: {err}") + nr_nodes = len(config) + bounds_follow_node_config = True + elif parsed_arguments.preset is not None: + selected_preset = parsed_arguments.preset + config = load_preset_node_configs(parsed_arguments.preset, period) + scenario_origin = preset_origin(parsed_arguments.preset) + # Packaged scenarios can carry terrain/clutter grids matched to the + # node geometry. Use them by default, while still letting explicit CLI + # files override them for A/B comparison runs. + bundled_terrain_grid = preset_terrain_grid(parsed_arguments.preset) + bundled_clutter_grid = preset_clutter_grid(parsed_arguments.preset) nr_nodes = len(config) + bounds_follow_node_config = True + elif parsed_arguments.from_map is not None: + if parsed_arguments.map_bbox is None: + parser.error( + "--from-map requires --map-bbox min_lat,min_lon,max_lat,max_lon" + ) + try: + terrain_bbox = parse_bbox(parsed_arguments.map_bbox) + raw_map_payload = fetch_map_payload(parsed_arguments.from_map) + config, map_origin = node_configs_from_map_payload( + raw_map_payload, + period, + bbox=terrain_bbox, + limit=parsed_arguments.map_limit, + antenna_height=conf.HM, + hop_limit=conf.hopLimit, + tx_power=conf.PTX, + freq=conf.FREQ, + return_origin=True, + ) + scenario_origin = map_origin + except ValueError as err: + parser.error(str(err)) + nr_nodes = len(config) + bounds_follow_node_config = True + elif parsed_arguments.from_nodedb: + try: + if parsed_arguments.map_bbox is not None: + terrain_bbox = parse_bbox(parsed_arguments.map_bbox) + raw_nodedb_payload = fetch_nodedb_payload( + host=parsed_arguments.nodedb_host, + port=parsed_arguments.nodedb_port, + serial_port=parsed_arguments.nodedb_serial_port, + ) + config, nodedb_origin = node_configs_from_nodedb_payload( + raw_nodedb_payload, + period, + bbox=terrain_bbox, + limit=parsed_arguments.map_limit, + antenna_height=conf.HM, + hop_limit=conf.hopLimit, + tx_power=conf.PTX, + freq=conf.FREQ, + return_origin=True, + ) + scenario_origin = nodedb_origin + except ValueError as err: + parser.error(str(err)) + nr_nodes = len(config) + bounds_follow_node_config = True elif parsed_arguments.nr_nodes is not None: + if parsed_arguments.terrain_srtm: + parser.error( + "--terrain-srtm requires --from-map --map-bbox or a scenario file with origin metadata" + ) if parsed_arguments.nr_nodes < 2: - parser.error(f"Need at least two nodes. You specified {parsed_arguments.nr_nodes}") + parser.error( + f"Need at least two nodes. You specified {parsed_arguments.nr_nodes}" + ) nr_nodes = parsed_arguments.nr_nodes if parsed_arguments.router_type is not None: routerType = parsed_arguments.router_type @@ -124,43 +651,157 @@ def parse_params(conf, args=None) -> [NodeConfig]: seeded_for_scenario = True config = default_generate_node_list(conf) else: + if parsed_arguments.terrain_srtm: + parser.error( + "--terrain-srtm requires --from-map --map-bbox or a scenario file with origin metadata" + ) if not gui_enabled: - parser.error("--no-gui requires nr_nodes or --from-file") + parser.error("--no-gui requires nr_nodes, --from-file, --from-map, or --preset") from lib.gui import gen_scenario config_dict = gen_scenario(conf) - config = [NodeConfig.from_gen_scenario_output(node_id, cfg, period) for node_id, cfg in config_dict.items()] + config = [NodeConfig.from_gen_scenario_output(node_id, cfg, period, conf.PTX, conf.FREQ) for node_id, cfg in config_dict.items()] nr_nodes = len(config) if nr_nodes < 2: parser.error(f"Need at least two nodes. You specified {nr_nodes}") + if parsed_arguments.terrain_srtm and terrain_bbox is None: + try: + terrain_bbox = bbox_from_node_config(config, scenario_origin) + terrain_tile_names = srtm_tiles_for_node_config_links( + conf, config, scenario_origin + ) + except ValueError as err: + parser.error(f"could not derive SRTM terrain bbox: {err}") + if terrain_bbox is None: + parser.error( + "--terrain-srtm requires --from-map --map-bbox or a scenario file with origin metadata" + ) + if parsed_arguments.terrain_srtm and scenario_origin is None: + parser.error( + "--terrain-srtm requires --from-map/--from-nodedb or a scenario file with origin metadata" + ) + + if parsed_arguments.terrain_srtm: + try: + origin_lat, origin_lon = scenario_origin + terrain_grid = terrain_grid_from_srtm( + terrain_bbox, + parsed_arguments.terrain_srtm_step_meters, + parsed_arguments.terrain_srtm_cache_dir, + origin_lat, + origin_lon, + parsed_arguments.terrain_srtm_url_template, + download_missing=not parsed_arguments.terrain_srtm_offline, + tile_names=terrain_tile_names, + ) + apply_terrain_altitudes(terrain_grid, config) + node_z_reference = NODE_Z_REFERENCE_SEA_LEVEL + except (OSError, ValueError) as err: + parser.error(f"could not load SRTM terrain: {err}") + elif bundled_terrain_grid is not None: + try: + terrain_grid = load_preset_terrain_grid(parsed_arguments.preset) + apply_terrain_altitudes(terrain_grid, config) + terrain_enabled = True + node_z_reference = NODE_Z_REFERENCE_SEA_LEVEL + except (OSError, ValueError) as err: + parser.error(f"could not load preset terrain: {err}") + if not seeded_for_scenario: - # Loaded and interactive scenarios do not need random state for node - # placement, but the later MAC/PHY simulation does. Seed only after - # 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) + if bounds_follow_node_config: + fit_simulation_bounds_to_node_config(conf, config) + conf.SIMTIME = simtime conf.PERIOD = period conf.GUI_ENABLED = gui_enabled conf.PLOT = plot_enabled conf.NR_NODES = nr_nodes + conf.ENABLE_CONNECTIVITY_MAP = connectivity_map_enabled + conf.DCR_ENABLED = parsed_arguments.dcr + conf.DTP_ENABLED = parsed_arguments.dtp + conf.DTP_MAX_POWER_DROP_DB = ( + parsed_arguments.dtp_max_drop_db + if parsed_arguments.dtp_max_drop_db is not None + else cli_defaults["DTP_MAX_POWER_DROP_DB"] + ) + conf.DTP_POWER_STEP_DB = ( + parsed_arguments.dtp_power_step_db + if parsed_arguments.dtp_power_step_db is not None + else cli_defaults["DTP_POWER_STEP_DB"] + ) + conf.DTP_MIN_TX_POWER_DBM = ( + parsed_arguments.dtp_min_power_dbm + if parsed_arguments.dtp_min_power_dbm is not None + else cli_defaults["DTP_MIN_TX_POWER_DBM"] + ) + conf.DTP_STRONG_LINK_MARGIN_DB = dtp_strong_margin + conf.DTP_VERY_STRONG_LINK_MARGIN_DB = dtp_very_strong_margin + set_geo_origin(conf, scenario_origin) + conf.TERRAIN_ENABLED = terrain_enabled + conf.TERRAIN_GRID = terrain_grid + conf.TERRAIN_PROFILE_SAMPLES = terrain_profile_samples + conf.NODE_Z_REFERENCE = node_z_reference + 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 + else: + conf.CLUTTER_PROFILE_SAMPLES = cli_defaults["CLUTTER_PROFILE_SAMPLES"] + conf.PHY_LOSS_MODEL_ENABLED = parsed_arguments.phy_loss_model + conf.CAPTURE_COLLISION_MODEL_ENABLED = parsed_arguments.capture_collision_model + restore_radio_calibration(conf, cli_defaults["RADIO_CALIBRATION"]) + if parsed_arguments.preset is not None: + apply_preset_radio_calibration(conf, parsed_arguments.preset) if parsed_arguments.verbose: # Set this logger and lib.* to DEBUG only after the command line has # resolved into a usable scenario. Failed parser inputs should not leave # imported callers with noisier logging. logger.setLevel(logging.DEBUG) - lib_logger = logging.getLogger('lib') + lib_logger = logging.getLogger("lib") lib_logger.setLevel(logging.DEBUG) print("verbose output enabled") print("Number of nodes:", conf.NR_NODES) print("Modem:", conf.MODEM_PRESET) - print("Simulation time (s):", conf.SIMTIME/1000) - print("Period (s):", conf.PERIOD/1000) + print("Simulation time (s):", conf.SIMTIME / 1000) + print("Period (s):", conf.PERIOD / 1000) print("Interference level:", conf.INTERFERENCE_LEVEL) + if conf.TERRAIN_ENABLED: + print( + "Terrain data attribution:", + f"{SRTM_DATA_ATTRIBUTION} ({SRTM_DATA_ATTRIBUTION_URL})", + ) + print("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") + 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 @@ -196,39 +837,61 @@ def run_simulation(conf, node_config): messages = results["messages"] # collect second-order results from finalized results - sent = results['sent'] - potentialReceivers = results['potentialReceivers'] - nrCollisions = results['nrCollisions'] - nrSensed = results['nrSensed'] - nrReceived = results['nrReceived'] - meanDelay = results['meanDelay'] - txAirUtilizationRate = results['txAirUtilizationRate'] - collisionRate = results['collisionRate'] - nodeReach = results['nodeReach'] - usefulness = results['usefulness'] - delayDropped = results['delayDropped'] + sent = results["sent"] + potentialReceivers = results["potentialReceivers"] + nrCollisions = results["nrCollisions"] + nrSensed = results["nrSensed"] + nrReceived = results["nrReceived"] + meanDelay = results["meanDelay"] + txAirUtilizationRate = results["txAirUtilizationRate"] + collisionRate = results["collisionRate"] + nodeReach = results["nodeReach"] + usefulness = results["usefulness"] + delayDropped = results["delayDropped"] print("*******************************") print(f"\nRouter Type: {conf.SELECTED_ROUTER_TYPE}") - print('Number of messages created:', messageSeq) - print('Number of packets sent:', sent, 'to', potentialReceivers, 'potential receivers') + print("Number of messages created:", messageSeq) + print( + "Number of packets sent:", sent, "to", potentialReceivers, "potential receivers" + ) print("Number of collisions:", nrCollisions) print("Number of packets sensed:", nrSensed) print("Number of packets received:", nrReceived) - print('Delay average (ms):', round(meanDelay, 2)) - print('Average Tx air utilization:', round(txAirUtilizationRate * 100, 2), '%') - print("Percentage of packets that collided:", round(collisionRate*100, 2)) - print("Average percentage of nodes reached:", round(nodeReach*100, 2)) - print("Percentage of received packets containing new message:", round(usefulness*100, 2)) + print("Delay average (ms):", round(meanDelay, 2)) + print("Average Tx air utilization:", round(txAirUtilizationRate * 100, 2), "%") + print("Percentage of packets that collided:", round(collisionRate * 100, 2)) + print("Average percentage of nodes reached:", round(nodeReach * 100, 2)) + print( + "Percentage of received packets containing new message:", + round(usefulness * 100, 2), + ) print("Number of packets dropped by delay/hop limit:", delayDropped) + if conf.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.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)) + if conf.CLUTTER_ENABLED: + print("Mean clutter loss (dB):", round(results["meanClutterLossDb"], 2)) + print("Max clutter loss (dB):", round(results["maxClutterLossDb"], 2)) + if conf.MODEL_ASYMMETRIC_LINKS: - noLinkRate = results['noLinkRate'] - print("No links:", round(noLinkRate * 100, 2), '%') + noLinkRate = results["noLinkRate"] + print("No links:", round(noLinkRate * 100, 2), "%") if conf.MOVEMENT_ENABLED: - movingNodes = results['movingNodes'] - gpsEnabled = results['gpsEnabled'] + movingNodes = results["movingNodes"] + gpsEnabled = results["gpsEnabled"] print("Number of moving nodes:", movingNodes) print("Number of moving nodes w/ GPS:", gpsEnabled) diff --git a/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_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_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_dcr.py b/tests/test_dcr.py new file mode 100644 index 00000000..dc49da5c --- /dev/null +++ b/tests/test_dcr.py @@ -0,0 +1,176 @@ +import unittest + +from lib.config import Config +from lib.dcr import CR_NORMAL, CR_RESCUE, CR_SLIM, choose_dynamic_coding_rate +from lib.packet import NODENUM_BROADCAST + + +class FakePacket: + def __init__( + self, + cr=5, + is_ack=False, + retransmissions=3, + tx_node_id=0, + orig_tx_node_id=0, + hop_limit=3, + dest_id=NODENUM_BROADCAST, + ): + self.cr = cr + self.isAck = is_ack + self.retransmissions = retransmissions + self.txNodeId = tx_node_id + self.origTxNodeId = orig_tx_node_id + self.hopLimit = hop_limit + self.destId = dest_id + + def airtime_for_cr(self, cr): + return {5: 100.0, 6: 120.0, 7: 140.0, 8: 160.0}[cr] + + +class FakeTransmitter: + def __init__(self, queue_depth=0): + self.queue = [object()] * queue_depth + + +class FakeNode: + def __init__(self, util=0.0, queue_depth=0): + self.conf = Config() + self.conf.DCR_ENABLED = True + self._util = util + self.transmitter = FakeTransmitter(queue_depth) + self.dcrAirtimeByCr = {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0} + self.txAirUtilization = 0.0 + self.prevTxAirUtilization = 0.0 + + def channel_utilization_percent(self): + return self._util + + +class TestDynamicCodingRate(unittest.TestCase): + def test_dcr_disabled_keeps_packet_cr(self): + node = FakeNode() + node.conf.DCR_ENABLED = False + packet = FakePacket(cr=7) + + decision = choose_dynamic_coding_rate(node, packet) + + self.assertEqual(decision.cr, 7) + self.assertEqual(decision.reason, "dcr_off") + + def test_idle_user_first_attempt_stays_compact_cr(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_coding_rate(node, FakePacket()) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertIn("idle_no_first_attempt_bump", decision.reason) + + def test_idle_user_retry_gets_normal_cr(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=2)) + + self.assertEqual(decision.cr, CR_NORMAL) + + def test_busy_user_packet_can_use_compact_cr(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_coding_rate(node, FakePacket()) + + self.assertEqual(decision.cr, CR_SLIM) + + def test_current_bucket_airtime_contributes_to_busy_pressure(self): + node = FakeNode(util=0.0) + node.txAirUtilization = 6000.0 + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=2)) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertIn("channel_busy", decision.reason) + + def test_direct_origin_packet_can_stay_compact_cr(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(dest_id=7)) + + self.assertEqual(decision.cr, CR_SLIM) + + def test_nonbusy_direct_relay_minimum_is_normal_cr(self): + node = FakeNode(util=5.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7)) + + self.assertEqual(decision.cr, CR_NORMAL) + self.assertIn("direct_relay_min_cr", decision.reason) + + def test_busy_direct_relay_can_stay_compact_cr(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7)) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertNotIn("direct_relay_min_cr", decision.reason) + + def test_retry_does_not_escalate_on_unrestricted_region_magic_limit(self): + node = FakeNode(util=12.0) + node.conf.REGION = node.conf.regions["US"] + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=2)) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertIn("channel_busy", decision.reason) + + def test_quiet_final_retry_can_use_rescue_cr(self): + node = FakeNode(util=0.0) + node.conf.DCR_CR8_AIRTIME_LIMIT_PERCENT = 100.0 + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=1)) + + self.assertEqual(decision.cr, CR_RESCUE) + + def test_quiet_final_retry_can_use_rescue_cr_on_cold_start(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=1)) + + self.assertEqual(decision.cr, CR_RESCUE) + self.assertNotIn("cr8_budget_clamp", decision.reason) + + def test_rescue_cr_is_clamped_after_budget_is_spent(self): + node = FakeNode(util=0.0) + node.txAirUtilization = 1000.0 + node.dcrAirtimeByCr[CR_RESCUE] = 100.0 + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=1)) + + self.assertEqual(decision.cr, 7) + self.assertIn("cr8_budget_clamp", decision.reason) + + def test_ack_minimum_is_normal_even_when_busy(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(is_ack=True)) + + self.assertEqual(decision.cr, CR_NORMAL) + + def test_ack_respects_user_minimum_cr(self): + node = FakeNode(util=12.0) + node.conf.DCR_USER_MIN_CR = 7 + + decision = choose_dynamic_coding_rate(node, FakePacket(is_ack=True)) + + self.assertEqual(decision.cr, 7) + + def test_last_hop_relay_uses_normal_cr_without_retry_evidence(self): + node = FakeNode(util=5.0) + packet = FakePacket(tx_node_id=2, orig_tx_node_id=1, hop_limit=1) + + decision = choose_dynamic_coding_rate(node, packet) + + self.assertEqual(decision.cr, CR_NORMAL) + self.assertIn("last_hop", decision.reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 9ca7787e..21c06637 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -74,6 +74,13 @@ 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} + self.dtpTxByPower = {} + self.dtpTxByCrPower = {} + self.dtpDetectedByTx = 0 + self.dtpSensedByTx = 0 + self.dtpTxCount = 0 class MockPacket: def __init__(self, num_nodes: int): @@ -127,6 +134,12 @@ 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') + 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') @@ -202,10 +215,48 @@ def test_connectivity_map_optimization_is_consistent(self): for f in facets: self.assertEqual(all_results[0][f], all_results[1][f], f'connectivity map optimization is inconsistent for facet {f}') + def test_phy_loss_counts_only_sensed_non_collided_copies(self): + from lib.config import Config + + class MockNode: + def __init__(self, nodeid: int): + self.nodeid = nodeid + self.usefulPackets = 0 + self.txAirUtilization = 0.0 + self.droppedByDelay = 0 + self.isMoving = False + self.gpsEnabled = False + + class MockPacket: + def __init__(self): + self.collidedAtN = [False, True, False] + self.sensedByN = [True, True, False] + self.receivedAtN = [False, False, False] + self.phyLostAtN = [True, True, True] + self.collisionReasonAtN = [None, "capture", None] + self.terrainLossAtN = [0.0, 0.0, 0.0] + self.clutterLossAtN = [0.0, 0.0, 0.0] + + conf = Config() + conf.NR_NODES = 3 + sim_results = lib.discrete_event_sim.SimulationResults({ + "nodes": [MockNode(0), MockNode(1), MockNode(2)], + "packets": [MockPacket()], + "delays": [], + "messageSeq": 1, + "totalPairs": 0, + "asymmetricLinks": 0, + "symmetricLinks": 0, + "noLinks": 0, + }) + + sim_results.finalize(conf) + + self.assertEqual(sim_results["nrPhyLoss"], 1) + self.assertEqual(sim_results["nrCollisions"], 1) + # TODO: add default-skip GUI test? def test_discrete_sim_ten_nodes(self): - import numpy as np - from lib.node import default_generate_node_list from lib.config import CONFIG @@ -229,15 +280,7 @@ def test_discrete_sim_ten_nodes(self): # collect & unpack results for easy copy/paste of asserts results = sim.get_results() - # put "first order" results in local scope for easy access - packets = results["packets"] - packetsAtN = results["packetsAtN"] messageSeq = results["messageSeq"] - messages = results["messages"] - delays = results["delays"] - totalPairs = results["totalPairs"] - noLinks = results["noLinks"] - nodes = results["nodes"] # Begin actual tests, comparing against a hardcoded 'known # good' run. If these fail then a change has impacted the @@ -248,32 +291,32 @@ def test_discrete_sim_ten_nodes(self): # and modify your changes, or to update the hardcoded "known good" # simulation results is up to your judgement for which is # appropriate. Be cautious! - self.assertEqual(messageSeq, 180, "expected number of messages created") + self.assertEqual(messageSeq, 185, "expected number of messages created") sent = results['sent'] potentialReceivers = results['potentialReceivers'] - self.assertEqual(sent, 834, "expected number of packets sent") - self.assertEqual(potentialReceivers, 7506, "expected number of potential receivers") + self.assertEqual(sent, 821, "expected number of packets sent") + self.assertEqual(potentialReceivers, 7389, "expected number of potential receivers") nrCollisions = results['nrCollisions'] - self.assertEqual(nrCollisions, 323, "expected number of collisions") + self.assertEqual(nrCollisions, 272, "expected number of collisions") nrSensed = results['nrSensed'] - self.assertEqual(nrSensed, 2895, "expected number of packets sensed") + self.assertEqual(nrSensed, 2860, "expected number of packets sensed") nrReceived = results['nrReceived'] - self.assertEqual(nrReceived, 2573, "expected number of packets received") + self.assertEqual(nrReceived, 2588, "expected number of packets received") meanDelay = results['meanDelay'] - self.assertEqual(round(meanDelay, 2), 6403.13, "expected rounded delay average") + self.assertEqual(round(meanDelay, 2), 5915.09, "expected rounded delay average") txAirUtilizationRate = results['txAirUtilizationRate'] - self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.83, "expected rounded average tx air utilization") + self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.75, "expected rounded average tx air utilization") nodeReach = results['nodeReach'] - self.assertEqual(round(nodeReach*100, 2), 79.57, "expected rounded percentage of nodes reached") + self.assertEqual(round(nodeReach*100, 2), 80.9, "expected rounded percentage of nodes reached") usefulness = results['usefulness'] - self.assertEqual(round(usefulness*100, 2), 50.1, "expected rounded 'usefulness' percentage") + self.assertEqual(round(usefulness*100, 2), 52.05, "expected rounded 'usefulness' percentage") delayDropped = results['delayDropped'] - self.assertEqual(delayDropped, 1143, "expected number of packets dropped") + self.assertEqual(delayDropped, 1125, "expected number of packets dropped") # default config has both asymmetric links and movement enabled noLinkRate = results['noLinkRate'] self.assertEqual(round(noLinkRate * 100, 2), 55.56, "expected rounded percentage of 'no' links") 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_dtp.py b/tests/test_dtp.py new file mode 100644 index 00000000..8a56e2e3 --- /dev/null +++ b/tests/test_dtp.py @@ -0,0 +1,207 @@ +import unittest + +import simpy + +from lib.config import Config +from lib.discrete_event_sim_components import SimulationDataTracking, SimulationState +from lib.dtp import choose_dynamic_tx_power +from lib.node import MeshNode, NodeConfig +from lib.packet import MeshPacket, NODENUM_BROADCAST +from lib.point import Point + + +class FakePacket: + def __init__( + self, + cr=5, + is_ack=False, + retransmissions=3, + tx_node_id=0, + orig_tx_node_id=0, + hop_limit=3, + dest_id=NODENUM_BROADCAST, + prior_hop_rssi=None, + prior_hop_snr=None, + base_power=30, + ): + self.cr = cr + self.isAck = is_ack + self.retransmissions = retransmissions + self.txNodeId = tx_node_id + self.origTxNodeId = orig_tx_node_id + self.hopLimit = hop_limit + self.destId = dest_id + self.priorHopRssi = prior_hop_rssi + self.priorHopSnr = prior_hop_snr + self.baseTxPower = base_power + + +class FakeTransmitter: + def __init__(self, queue_depth=0): + self.queue = [object()] * queue_depth + + +class FakeNode: + def __init__(self, util=0.0, queue_depth=0): + self.conf = Config() + self.conf.DTP_ENABLED = True + self._util = util + self.transmitter = FakeTransmitter(queue_depth) + self.txAirUtilization = 0.0 + self.prevTxAirUtilization = 0.0 + + def channel_utilization_percent(self): + return self._util + + +class TestDynamicTxPower(unittest.TestCase): + def test_dtp_disabled_keeps_base_power(self): + node = FakeNode() + node.conf.DTP_ENABLED = False + + decision = choose_dynamic_tx_power(node, FakePacket(base_power=27)) + + self.assertEqual(decision.tx_power_dbm, 27) + self.assertEqual(decision.reason, "dtp_off") + + def test_origin_packet_stays_at_max_power(self): + node = FakeNode(util=30.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=1, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_origin", decision.reason) + + def test_busy_relay_lowers_power(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 24) + self.assertIn("busy_relay_power_drop", decision.reason) + + def test_congested_relay_lowers_power_more(self): + node = FakeNode(util=20.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 21) + + def test_direct_relay_without_strong_prior_hop_stays_max_power(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_direct_relay_without_strong_link", decision.reason) + + def test_strong_direct_relay_only_gets_small_drop(self): + node = FakeNode(util=20.0) + prior_rssi = node.conf.current_preset["sensitivity"] + node.conf.DTP_STRONG_LINK_MARGIN_DB + + decision = choose_dynamic_tx_power( + node, + FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7, prior_hop_rssi=prior_rssi, base_power=30), + ) + + self.assertEqual(decision.tx_power_dbm, 27) + self.assertIn("direct_relay_cap", decision.reason) + + def test_prior_hop_strength_is_not_absolute_snr(self): + node = FakeNode(util=20.0) + + decision = choose_dynamic_tx_power( + node, + FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7, prior_hop_snr=6.0, base_power=30), + ) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_direct_relay_without_strong_link", decision.reason) + + def test_final_retry_uses_max_power(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_tx_power( + node, + FakePacket(tx_node_id=2, orig_tx_node_id=1, retransmissions=1, prior_hop_snr=8.0, base_power=30), + ) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_final_retry", decision.reason) + + def test_cr8_relay_packet_can_still_lower_power_when_not_retry(self): + node = FakeNode(util=20.0) + + decision = choose_dynamic_tx_power(node, FakePacket(cr=8, tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 21) + self.assertIn("congested_relay_power_drop", decision.reason) + + def test_power_drop_respects_step_and_minimum(self): + node = FakeNode(util=20.0) + node.conf.DTP_MAX_POWER_DROP_DB = 8 + node.conf.DTP_POWER_STEP_DB = 3 + node.conf.DTP_MIN_TX_POWER_DBM = 24 + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 24) + + def test_minimum_power_clamp_cannot_boost_above_base_power(self): + node = FakeNode(util=20.0) + node.conf.DTP_MIN_TX_POWER_DBM = 35 + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("drop=0dB", decision.reason) + + +class TestDynamicTxPowerPacketPhysics(unittest.TestCase): + def make_nodes(self, distance_m): + conf = Config() + conf.NR_NODES = 2 + conf.PTX = 30 + conf.MODEL_ASYMMETRIC_LINKS = False + conf.LINK_OFFSET = {(0, 1): 0, (1, 0): 0} + env = simpy.Environment() + sim_state = SimulationState(conf, env) + tracking = SimulationDataTracking() + nodes = [ + MeshNode(conf, sim_state, tracking, NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD)), + MeshNode(conf, sim_state, tracking, NodeConfig(1, Point(distance_m, 0, 1.5), conf.PERIOD)), + ] + sim_state.nodes.extend(nodes) + return conf, nodes + + def test_lower_tx_power_recomputes_receiver_visibility(self): + conf, nodes = self.make_nodes(2_000) + connectivity_map = {0: {1}, 1: {0}} + baseline_pathloss_matrix = [[None, None], [None, None]] + packet = MeshPacket( + conf, + nodes, + 0, + NODENUM_BROADCAST, + 0, + 40, + 1, + 0, + True, + False, + None, + 0, + connectivity_map, + baseline_pathloss_matrix, + ) + + self.assertTrue(packet.sensedByN[1]) + + packet.set_tx_power(18) + + self.assertFalse(packet.sensedByN[1]) + self.assertLess(packet.rssiAtN[1], conf.current_preset["sensitivity"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_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_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 4c3e369b..d521fd7e 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -8,15 +8,33 @@ import tempfile import textwrap import unittest +from array import array +from pathlib import Path +from unittest import mock from lib.config import Config +from lib.node import NodeConfig +from lib.point import Point +from lib.srtm import SRTM_DATA_ATTRIBUTION_URL +from lib.terrain import NODE_Z_REFERENCE_GROUND, NODE_Z_REFERENCE_SEA_LEVEL, TerrainGrid import loraMesh +def write_hgt(path, values): + data = array("h", values) + if sys.byteorder == "little": + data.byteswap() + path.write_bytes(data.tobytes()) + + def generated_positions(node_configs): return [ - (round(node.position.x, 6), round(node.position.y, 6), round(node.position.z, 6)) + ( + round(node.position.x, 6), + round(node.position.y, 6), + round(node.position.z, 6), + ) for node in node_configs ] @@ -66,9 +84,123 @@ 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.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() + + _, 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_enables_dtp_with_limits(self): + conf = Config() + + _, output = self.parse_quietly( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-max-drop-db", + "9", + "--dtp-power-step-db", + "3", + "--dtp-min-power-dbm", + "14", + "--dtp-strong-margin-db", + "18", + "--dtp-very-strong-margin-db", + "24", + ], + ) + + self.assertTrue(conf.DTP_ENABLED) + self.assertEqual(conf.DTP_MAX_POWER_DROP_DB, 9) + self.assertEqual(conf.DTP_POWER_STEP_DB, 3) + self.assertEqual(conf.DTP_MIN_TX_POWER_DBM, 14) + self.assertEqual(conf.DTP_STRONG_LINK_MARGIN_DB, 18) + self.assertEqual(conf.DTP_VERY_STRONG_LINK_MARGIN_DB, 24) + self.assertIn("Dynamic TX Power: enabled", output) + self.assertIn("DTP limits:", output) + + def test_parse_params_rejects_inverted_dtp_margins(self): + conf = Config() + + stderr = self.assert_parser_rejects( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-strong-margin-db", + "30", + "--dtp-very-strong-margin-db", + "20", + ], + ) + + self.assertIn("--dtp-very-strong-margin-db", stderr) + + def test_parse_params_reuses_initial_dtp_defaults_after_override_run(self): + conf = Config() + default_max_drop = conf.DTP_MAX_POWER_DROP_DB + default_min_power = conf.DTP_MIN_TX_POWER_DBM + + self.parse_quietly( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-max-drop-db", + "9", + "--dtp-min-power-dbm", + "14", + "--dtp-strong-margin-db", + "18", + "--dtp-very-strong-margin-db", + "24", + ], + ) + + self.parse_quietly(conf, ["2", "--no-gui", "--dtp"]) + + self.assertEqual(conf.DTP_MAX_POWER_DROP_DB, default_max_drop) + self.assertEqual(conf.DTP_MIN_TX_POWER_DBM, default_min_power) + self.assertEqual(conf.DTP_STRONG_LINK_MARGIN_DB, 20.0) + self.assertEqual(conf.DTP_VERY_STRONG_LINK_MARGIN_DB, 24.0) + + def test_parse_params_validates_partial_dtp_margin_override_against_initial_defaults(self): + conf = Config() + + self.parse_quietly( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-strong-margin-db", + "30", + "--dtp-very-strong-margin-db", + "36", + ], + ) + + self.parse_quietly(conf, ["2", "--no-gui", "--dtp", "--dtp-very-strong-margin-db", "24"]) + + self.assertEqual(conf.DTP_STRONG_LINK_MARGIN_DB, 20.0) + self.assertEqual(conf.DTP_VERY_STRONG_LINK_MARGIN_DB, 24.0) def test_parse_params_reuses_initial_defaults_after_override_run(self): conf = Config() @@ -85,7 +217,9 @@ def test_parse_params_reuses_initial_defaults_after_override_run(self): self.assertTrue(conf.PLOT) self.assertEqual(conf.SIMTIME, default_simtime) self.assertEqual(conf.PERIOD, default_period) - self.assertEqual([node.period for node in nodes], [default_period, default_period]) + self.assertEqual( + [node.period for node in nodes], [default_period, default_period] + ) def test_parse_params_preserves_caller_initial_defaults(self): conf = Config() @@ -94,7 +228,9 @@ def test_parse_params_preserves_caller_initial_defaults(self): conf.GUI_ENABLED = False conf.PLOT = False - self.parse_quietly(conf, ["2", "--simtime-seconds", "1", "--period-seconds", "0.5"]) + self.parse_quietly( + conf, ["2", "--simtime-seconds", "1", "--period-seconds", "0.5"] + ) nodes, _ = self.parse_quietly(conf, ["2"]) self.assertFalse(conf.GUI_ENABLED) @@ -106,8 +242,12 @@ def test_parse_params_preserves_caller_initial_defaults(self): def test_parse_params_rejects_sub_centisecond_time_overrides(self): conf = Config() - simtime_error = self.assert_parser_rejects(conf, ["2", "--no-gui", "--simtime-seconds", "0.009"]) - period_error = self.assert_parser_rejects(conf, ["2", "--no-gui", "--period-seconds", "0.009"]) + simtime_error = self.assert_parser_rejects( + conf, ["2", "--no-gui", "--simtime-seconds", "0.009"] + ) + period_error = self.assert_parser_rejects( + conf, ["2", "--no-gui", "--period-seconds", "0.009"] + ) self.assertIn("--simtime-seconds must be at least 0.01 seconds", simtime_error) self.assertIn("--period-seconds must be at least 0.01 seconds", period_error) @@ -169,7 +309,9 @@ def test_parse_params_loads_from_file_as_node_configs(self): ) os.makedirs("out", exist_ok=True) - with tempfile.NamedTemporaryFile("w", dir="out", suffix=".yaml", delete=False, encoding="utf-8") as scenario_file: + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: scenario_file.write(scenario) scenario_filename = os.path.basename(scenario_file.name) @@ -185,6 +327,848 @@ def test_parse_params_loads_from_file_as_node_configs(self): self.assertEqual([node.period for node in nodes], [2000, 2000]) self.assertEqual(conf.NR_NODES, 2) + def test_parse_params_loads_from_map_payload(self): + conf = Config() + conf.HM = 2.5 + conf.hopLimit = 5 + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, _ = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + self.assertEqual(len(nodes), 2) + self.assertEqual([node.position.z for node in nodes], [2.5, 2.5]) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5]) + self.assertEqual([node.hop_limit for node in nodes], [5, 5]) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + + def test_parse_params_expands_bounds_for_wide_map_payload(self): + conf = Config() + payload = [ + { + "latitude": 416200000, + "longitude": 414000000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 418500000, + "role": 0, + }, + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, _ = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + left = conf.OX - conf.XSIZE / 2 + right = conf.OX + conf.XSIZE / 2 + bottom = conf.OY - conf.YSIZE / 2 + top = conf.OY + conf.YSIZE / 2 + self.assertGreater(conf.XSIZE, 15000) + for node in nodes: + self.assertGreaterEqual(node.position.x, left) + self.assertLessEqual(node.position.x, right) + self.assertGreaterEqual(node.position.y, bottom) + self.assertLessEqual(node.position.y, top) + + def test_parse_params_preserves_sufficient_caller_bounds_for_map_payload(self): + conf = Config() + conf.OX = 1000 + conf.OY = -2000 + conf.XSIZE = 100000 + conf.YSIZE = 100000 + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + self.assertEqual(conf.OX, 1000) + self.assertEqual(conf.OY, -2000) + self.assertEqual(conf.XSIZE, 100000) + self.assertEqual(conf.YSIZE, 100000) + + def test_parse_params_loads_from_nodedb_payload(self): + conf = Config() + conf.HM = 2.5 + conf.hopLimit = 5 + payload = { + "nodesByNum": { + 1: { + "num": 1, + "user": {"id": "!00000001", "role": "ROUTER"}, + "position": { + "latitude": 41.62, + "longitude": 41.59, + "altitude": 120, + }, + }, + 2: { + "num": 2, + "user": {"id": "!00000002", "role": "CLIENT"}, + "position": {"latitudeI": 416300000, "longitudeI": 416000000}, + }, + } + } + + with mock.patch( + "loraMesh.fetch_nodedb_payload", return_value=payload + ) as fetch_nodedb: + nodes, _ = self.parse_quietly( + conf, + [ + "--from-nodedb", + "--nodedb-host", + "192.0.2.10", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + fetch_nodedb.assert_called_once_with( + host="192.0.2.10", port=None, serial_port=None + ) + self.assertEqual(len(nodes), 2) + self.assertEqual([node.position.z for node in nodes], [2.5, 2.5]) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5]) + self.assertEqual([node.hop_limit for node in nodes], [5, 5]) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + + def test_parse_params_rejects_nodedb_transport_without_nodedb_source(self): + conf = Config() + + error = self.assert_parser_rejects(conf, ["2", "--nodedb-host", "192.0.2.10"]) + + self.assertIn("--nodedb-* options require --from-nodedb", error) + + def test_parse_params_rejects_nodedb_port_without_host(self): + conf = Config() + + error = self.assert_parser_rejects( + conf, ["--from-nodedb", "--nodedb-port", "4404"] + ) + + self.assertIn("--nodedb-port requires --nodedb-host", error) + + def test_parse_params_can_build_srtm_terrain_for_map_payload(self): + conf = Config() + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "altitude": 500, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = tempfile.TemporaryDirectory() + self.addCleanup(source_dir.cleanup) + source_path = Path(source_dir.name) / "N41E041.hgt" + write_hgt( + source_path, + [10, 20, 30, 40, 50, 60, 70, 80, 90], + ) + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, output = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--terrain-srtm", + "--terrain-srtm-step-meters", + "20000", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-srtm-url-template", + f"{Path(source_dir.name).as_uri()}/{{tile}}.hgt", + "--no-gui", + ], + ) + + self.assertEqual(len(nodes), 2) + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertIsNotNone(conf.TERRAIN_GRID) + self.assertGreater(len(conf.TERRAIN_GRID.samples), 0) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertIn("Terrain data attribution:", output) + self.assertIn(SRTM_DATA_ATTRIBUTION_URL, output) + self.assertEqual(nodes[0].position.z, 500) + self.assertNotEqual(nodes[0].position.z, nodes[1].position.z) + self.assertGreater(nodes[1].position.z, conf.HM) + self.assertEqual([node.antenna_height for node in nodes], [conf.HM, conf.HM]) + + def test_parse_params_ignores_map_altitude_when_applying_srtm(self): + conf = Config() + conf.HM = 2.5 + payload = [ + { + "latitude": -16400000, + "longitude": -26400000, + "altitude": None, + "role": 0, + }, + { + "latitude": -16350000, + "longitude": -26350000, + "altitude": -1, + "role": 0, + }, + { + "latitude": -16300000, + "longitude": -26300000, + "altitude": 42949649, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = tempfile.TemporaryDirectory() + self.addCleanup(source_dir.cleanup) + source_path = Path(source_dir.name) / "S02W003.hgt" + write_hgt( + source_path, + [100, 110, 120, 130, 140, 150, 160, 170, 180], + ) + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, _ = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox=-1.7,-2.7,-1.2,-2.2", + "--terrain-srtm", + "--terrain-srtm-step-meters", + "20000", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-srtm-url-template", + f"{Path(source_dir.name).as_uri()}/{{tile}}.hgt", + "--no-gui", + ], + ) + + self.assertEqual(len(nodes), 3) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5, 2.5]) + self.assertTrue(all(100 < node.position.z < 190 for node in nodes)) + self.assertNotIn(-1, [node.position.z for node in nodes]) + self.assertNotIn(42949649, [node.position.z for node in nodes]) + + def test_parse_params_clears_geo_origin_for_scenarios_without_origin(self): + conf = Config() + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + scenario = textwrap.dedent( + """\ + nodes: + 3944424993: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 3944424994: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + nodes, _ = self.parse_quietly( + conf, ["--from-file", scenario_filename, "--no-gui"] + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual([node.node_id for node in nodes], [0, 1]) + self.assertIsNone(conf.GEO_ORIGIN_LAT) + self.assertIsNone(conf.GEO_ORIGIN_LON) + + def test_auto_srtm_tile_selection_skips_unreachable_link_corridors(self): + conf = Config() + nodes = [ + NodeConfig(0, Point(0, 0, conf.HM), conf.PERIOD), + NodeConfig(1, Point(300000, 0, conf.HM), conf.PERIOD), + ] + + tiles = loraMesh.srtm_tiles_for_node_config_links( + conf, nodes, (0.0, 0.0), margin_m=1.0 + ) + + self.assertIn("N00E000", tiles) + self.assertIn("N00E002", tiles) + self.assertNotIn("N00E001", tiles) + + def test_flat_link_budget_prefilter_includes_both_antenna_gains(self): + conf = Config() + node_a = NodeConfig( + 0, + Point(0, 0, conf.HM), + conf.PERIOD, + conf.PTX, + conf.FREQ, + antenna_gain=10, + ) + node_b = NodeConfig( + 1, + Point(5000, 0, conf.HM), + conf.PERIOD, + conf.PTX, + conf.FREQ, + antenna_gain=10, + ) + + self.assertTrue(loraMesh.nodes_have_flat_link_budget(conf, node_a, node_b)) + + def test_parse_params_rejects_one_node_before_changing_geo_origin(self): + conf = Config() + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + scenario = textwrap.dedent( + """\ + origin: + latitude: 42.0 + longitude: 42.0 + nodes: + 3944424993: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + self.assert_parser_rejects( + conf, ["--from-file", scenario_filename, "--no-gui"] + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + + def test_terrain_srtm_generated_scenario_rejects_before_config_mutation(self): + conf = Config() + original_simtime = conf.SIMTIME + original_period = conf.PERIOD + random.seed(12345) + state_before = random.getstate() + + error = self.assert_parser_rejects( + conf, + [ + "2", + "--terrain-srtm", + "--simtime-seconds", + "1", + "--period-seconds", + "2", + "--no-gui", + ], + ) + + self.assertIn("--terrain-srtm requires", error) + self.assertEqual(conf.SIMTIME, original_simtime) + self.assertEqual(conf.PERIOD, original_period) + self.assertTrue(conf.GUI_ENABLED) + self.assertTrue(conf.PLOT) + self.assertIsNone(conf.NR_NODES) + self.assertFalse(conf.TERRAIN_ENABLED) + self.assertEqual(random.getstate(), state_before) + + def test_rejected_disable_connectivity_map_keeps_previous_config(self): + conf = Config() + conf.ENABLE_CONNECTIVITY_MAP = True + + self.assert_parser_rejects(conf, ["2", "--terrain-srtm", "--disable-connectivity-map", "--no-gui"]) + + self.assertTrue(conf.ENABLE_CONNECTIVITY_MAP) + + def test_terrain_srtm_from_file_rejects_uncovered_bbox_before_config_mutation(self): + conf = Config() + conf.TERRAIN_ENABLED = True + terrain_grid = object() + conf.TERRAIN_GRID = terrain_grid + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + random.seed(12345) + state_before = random.getstate() + scenario = textwrap.dedent( + """\ + origin: + lat: 85.0 + lon: 42.0 + nodes: + 3944424993: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 3944424994: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + error = self.assert_parser_rejects( + conf, ["--from-file", scenario_filename, "--terrain-srtm", "--no-gui"] + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertIn("could not derive SRTM terrain bbox", error) + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertIs(conf.TERRAIN_GRID, terrain_grid) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + self.assertEqual(random.getstate(), state_before) + + def test_terrain_srtm_from_legacy_file_with_bbox_still_requires_origin(self): + conf = Config() + scenario = textwrap.dedent( + """\ + 0: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 1: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + error = self.assert_parser_rejects( + conf, + [ + "--from-file", + scenario_filename, + "--terrain-srtm", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--no-gui", + ], + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertIn("--terrain-srtm requires", error) + self.assertFalse(conf.TERRAIN_ENABLED) + + def test_terrain_srtm_from_file_honors_explicit_bbox(self): + conf = Config() + scenario = textwrap.dedent( + """\ + origin: + lat: 41.62 + lon: 41.59 + nodes: + 0: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 1: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + terrain_grid = TerrainGrid.from_rows([(0, 0, 10), (10, 0, 10)]) + try: + with mock.patch("loraMesh.terrain_grid_from_srtm", return_value=terrain_grid) as terrain_loader: + self.parse_quietly( + conf, + [ + "--from-file", + scenario_filename, + "--terrain-srtm", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--no-gui", + ], + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual(terrain_loader.call_args.args[0], (41.5, 41.5, 41.8, 41.8)) + + def test_failed_srtm_load_keeps_previous_terrain_config(self): + conf = Config() + terrain_grid = object() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = terrain_grid + conf.TERRAIN_PROFILE_SAMPLES = 7 + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + conf.OX = 123 + conf.OY = 456 + conf.XSIZE = 789 + conf.YSIZE = 987 + random.seed(12345) + state_before = random.getstate() + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + error = self.assert_parser_rejects( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--terrain-srtm", + "--terrain-srtm-offline", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-profile-samples", + "12", + "--no-gui", + ], + ) + + self.assertIn("could not load SRTM terrain", error) + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertIs(conf.TERRAIN_GRID, terrain_grid) + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 7) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + self.assertEqual((conf.OX, conf.OY, conf.XSIZE, conf.YSIZE), (123, 456, 789, 987)) + self.assertEqual(random.getstate(), state_before) + + def test_terrain_profile_samples_resets_between_parse_calls(self): + conf = Config() + conf.TERRAIN_PROFILE_SAMPLES = 31 + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = tempfile.TemporaryDirectory() + self.addCleanup(source_dir.cleanup) + source_path = Path(source_dir.name) / "N41E041.hgt" + write_hgt(source_path, [10, 20, 30, 40, 50, 60, 70, 80, 90]) + terrain_args = [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--terrain-srtm", + "--terrain-srtm-step-meters", + "20000", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-srtm-url-template", + f"{Path(source_dir.name).as_uri()}/{{tile}}.hgt", + "--no-gui", + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + self.parse_quietly( + conf, [*terrain_args, "--terrain-profile-samples", "7"] + ) + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 7) + + self.parse_quietly(conf, terrain_args) + + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 31) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + + def test_successful_plain_parse_clears_previous_terrain_state(self): + conf = Config() + loraMesh.get_cli_defaults(conf) + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = object() + conf.TERRAIN_PROFILE_SAMPLES = 7 + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + + self.parse_quietly(conf, ["2", "--no-gui"]) + + self.assertFalse(conf.TERRAIN_ENABLED) + self.assertIsNone(conf.TERRAIN_GRID) + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, Config().TERRAIN_PROFILE_SAMPLES) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_GROUND) + + def test_parse_params_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_clears_preset_radio_calibration_between_runs(self): + conf = Config() + default_noise_level = conf.NOISE_LEVEL + default_path_loss_floor = conf.PATH_LOSS_DISTANCE_FLOOR_M + + self.parse_quietly(conf, ["--preset", "batumi", "--no-gui"]) + self.assertTrue(conf.LINK_CALIBRATION_MODEL_ENABLED) + + self.parse_quietly(conf, ["2", "--no-gui"]) + + self.assertEqual(conf.NOISE_LEVEL, default_noise_level) + self.assertEqual(conf.PATH_LOSS_DISTANCE_FLOOR_M, default_path_loss_floor) + self.assertFalse(conf.LINK_CALIBRATION_MODEL_ENABLED) + self.assertEqual(conf.LINK_CALIBRATION_COEFFICIENTS, {}) + self.assertIsNone(conf.LINK_CALIBRATION_SNR_MIN_DB) + self.assertIsNone(conf.LINK_CALIBRATION_SNR_MAX_DB) + + def test_parse_params_clears_clutter_profile_samples_between_runs(self): + conf = Config() + default_samples = conf.CLUTTER_PROFILE_SAMPLES + + self.parse_quietly( + conf, + [ + "--preset", + "batumi", + "--no-gui", + "--clutter-profile-samples", + "3", + ], + ) + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, 3) + + self.parse_quietly(conf, ["--preset", "batumi", "--no-gui"]) + + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, default_samples) + + def test_parse_params_can_disable_bundled_preset_clutter(self): + conf = Config() + + 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_map_input.py b/tests/test_map_input.py new file mode 100644 index 00000000..29bd3aa4 --- /dev/null +++ b/tests/test_map_input.py @@ -0,0 +1,294 @@ +import unittest + +from lib.map_input import ( + decode_map_altitude, + decode_map_coordinate, + node_configs_from_map_payload, + parse_bbox, + payload_nodes, + role_name_for_node, +) +from lib.nodedb_input import node_configs_from_nodedb_payload, positioned_nodedb_nodes, role_name_for_nodedb_node +from lib.node import MESHTASTIC_ROLE + + +class TestMapInput(unittest.TestCase): + def test_decode_map_coordinate(self): + self.assertEqual(decode_map_coordinate(416219136), 41.6219136) + self.assertEqual(decode_map_coordinate(41.6219136), 41.6219136) + self.assertIsNone(decode_map_coordinate(None)) + + def test_decode_map_altitude_keeps_only_positive_finite_values(self): + self.assertEqual(decode_map_altitude(120), 120) + self.assertEqual(decode_map_altitude("12.5"), 12.5) + self.assertIsNone(decode_map_altitude(None)) + self.assertIsNone(decode_map_altitude(0)) + self.assertIsNone(decode_map_altitude(-1)) + self.assertIsNone(decode_map_altitude(float("inf"))) + + def test_parse_bbox(self): + self.assertEqual(parse_bbox("41.4,41.0,41.9,42.3"), (41.4, 41.0, 41.9, 42.3)) + + def test_parse_bbox_rejects_wrong_order(self): + with self.assertRaises(ValueError): + parse_bbox("41.9,41.0,41.4,42.3") + + def test_parse_bbox_rejects_non_finite_values(self): + with self.assertRaises(ValueError): + parse_bbox("41.4,41.0,nan,42.3") + + def test_parse_bbox_rejects_out_of_range_values(self): + with self.assertRaises(ValueError): + parse_bbox("41.4,41.0,91,42.3") + + def test_payload_nodes_accepts_dict_or_list(self): + rows = [{"latitude": 1, "longitude": 2}] + + self.assertIs(payload_nodes({"nodes": rows}), rows) + self.assertIs(payload_nodes(rows), rows) + + def test_payload_nodes_rejects_malformed_payload(self): + with self.assertRaises(ValueError): + payload_nodes({"nodes": {}}) + with self.assertRaises(ValueError): + payload_nodes("not json") + + def test_map_payload_skips_malformed_node_rows(self): + payload = [ + "not a node", + { + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }, + ] + + configs = node_configs_from_map_payload(payload, 1000) + + self.assertEqual(len(configs), 1) + + def test_map_payload_skips_bad_coordinates(self): + payload = [ + {"latitude": "not a number", "longitude": 415900000, "role": 0}, + {"latitude": 910000000, "longitude": 415900000, "role": 0}, + {"latitude": 416200000, "longitude": 415900000, "role": 0}, + ] + + configs = node_configs_from_map_payload(payload, 1000) + + self.assertEqual(len(configs), 1) + + def test_numeric_role_fallback_accepts_string_values(self): + self.assertEqual(role_name_for_node({"role": "2"}), "ROUTER") + self.assertEqual(role_name_for_node({"role": 12}), "CLIENT_BASE") + + def test_map_payload_builds_projected_node_configs(self): + payload = { + "nodes": [ + { + "node_id": "1", + "node_id_hex": "!00000001", + "long_name": "router", + "short_name": "r", + "latitude": 416200000, + "longitude": 415900000, + "altitude": 120, + "role": 2, + "role_name": "ROUTER", + }, + { + "node_id": "2", + "node_id_hex": "!00000002", + "long_name": "outside", + "short_name": "o", + "latitude": 500000000, + "longitude": 500000000, + "altitude": 5, + "role": 0, + "role_name": "CLIENT", + }, + ], + } + + configs = node_configs_from_map_payload( + payload, + 1000, + bbox=(41.0, 41.0, 42.0, 42.0), + antenna_height=2.5, + hop_limit=5, + origin=(41.62, 41.59), + ) + + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0].node_id, 0) + self.assertEqual(configs[0].role, MESHTASTIC_ROLE.ROUTER) + self.assertEqual(configs[0].position.z, 2.5) + self.assertEqual(configs[0].antenna_height, 2.5) + self.assertEqual(configs[0].absolute_altitude, 120) + self.assertEqual(configs[0].hop_limit, 5) + + def test_map_altitude_placeholders_do_not_override_antenna_height(self): + payload = { + "nodes": [ + { + "latitude": 416200000, + "longitude": 415900000, + "altitude": None, + "role": 0, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "altitude": -1, + "role": 0, + }, + { + "latitude": 416400000, + "longitude": 416100000, + "altitude": 0, + "role": 0, + }, + { + "latitude": 416500000, + "longitude": 416200000, + "altitude": 42949649, + "role": 0, + }, + ], + } + + configs = node_configs_from_map_payload( + payload, + 1000, + antenna_height=2.5, + ) + + self.assertEqual([config.position.z for config in configs], [2.5, 2.5, 2.5, 2.5]) + self.assertEqual([config.antenna_height for config in configs], [2.5, 2.5, 2.5, 2.5]) + self.assertEqual([config.absolute_altitude for config in configs], [None, None, None, 42949649]) + + def test_map_payload_can_return_projection_origin(self): + payload = [{ + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }] + + configs, origin = node_configs_from_map_payload(payload, 1000, return_origin=True) + + self.assertEqual(len(configs), 1) + self.assertEqual(origin, (41.62, 41.59)) + + def test_map_payload_rejects_empty_limit(self): + payload = { + "nodes": [{ + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }], + } + + with self.assertRaises(ValueError): + node_configs_from_map_payload(payload, 1000, limit=0) + + def test_map_payload_rejects_invalid_projection_origin(self): + payload = [{ + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }] + + with self.assertRaises(ValueError): + node_configs_from_map_payload(payload, 1000, origin=(91.0, 41.59)) + with self.assertRaises(ValueError): + node_configs_from_map_payload(payload, 1000, origin=("bad", 41.59)) + + def test_nodedb_payload_builds_projected_node_configs(self): + payload = { + "nodesByNum": { + 1: { + "num": 1, + "user": {"id": "!00000001", "role": "ROUTER"}, + "position": {"latitude": 41.62, "longitude": 41.59, "altitude": 120}, + }, + 2: { + "num": 2, + "user": {"id": "!00000002", "role": "CLIENT"}, + "position": {"latitudeI": 416300000, "longitudeI": 416000000}, + }, + 3: { + "num": 3, + "user": {"id": "!00000003", "role": "CLIENT"}, + "position": {"latitude": 50.0, "longitude": 50.0}, + }, + } + } + + configs = node_configs_from_nodedb_payload( + payload, + 1000, + bbox=(41.0, 41.0, 42.0, 42.0), + antenna_height=2.5, + hop_limit=5, + origin=(41.62, 41.59), + ) + + self.assertEqual(len(configs), 2) + self.assertEqual([config.node_id for config in configs], [0, 1]) + self.assertEqual(configs[0].role, MESHTASTIC_ROLE.ROUTER) + self.assertEqual(configs[0].absolute_altitude, 120) + self.assertEqual([config.antenna_height for config in configs], [2.5, 2.5]) + self.assertEqual([config.hop_limit for config in configs], [5, 5]) + + def test_nodedb_payload_uses_supplied_radio_defaults(self): + payload = [ + { + "num": 1, + "user": {"role": "ROUTER"}, + "position": { + "latitude": 41.62, + "longitude": 41.59, + "altitude": 120, + }, + }, + { + "num": 2, + "user": {"role": "CLIENT"}, + "position": { + "latitude": 41.63, + "longitude": 41.60, + "altitude": 10, + }, + }, + ] + + configs = node_configs_from_nodedb_payload( + payload, + 1000, + tx_power=14, + freq=433e6, + ) + + self.assertEqual([config.tx_power for config in configs], [14, 14]) + self.assertEqual([config.freq for config in configs], [433e6, 433e6]) + + def test_nodedb_payload_skips_unpositioned_nodes(self): + payload = [ + {"num": 1, "position": {"time": 1640206266}}, + {"num": 2, "position": {"latitude": 41.62, "longitude": 41.59}}, + ] + + positioned = positioned_nodedb_nodes(payload) + + self.assertEqual(len(positioned), 1) + self.assertEqual(positioned[0][1:], (41.62, 41.59)) + + def test_nodedb_role_defaults_to_client(self): + self.assertEqual(role_name_for_nodedb_node({}), "CLIENT") + self.assertEqual(role_name_for_nodedb_node({"user": {"role": "router_client"}}), "ROUTER_CLIENT") + self.assertEqual(role_name_for_nodedb_node({"user": {"role": 2}}), "ROUTER") + self.assertEqual(role_name_for_nodedb_node({"user": {"role": "12"}}), "CLIENT_BASE") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_node.py b/tests/test_node.py index 75f4c2e4..5208d91a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,6 +1,35 @@ import unittest import lib.node +import simpy + +from lib.config import Config +from lib.discrete_event_sim_components import SimulationDataTracking, SimulationState +from lib.node import ( + MESHTASTIC_ROLE, + MeshNode, + NodeConfig, + node_configs_from_yaml, + origin_from_yaml, + packet_is_rx_candidate, +) +from lib.point import Point +from lib.terrain import NODE_Z_REFERENCE_SEA_LEVEL, TerrainGrid, apply_terrain_altitude + + +def sample_node(x): + return { + "x": x, + "y": 0, + "z": 1.5, + "isRouter": False, + "isRepeater": False, + "isClientMute": False, + "hopLimit": 3, + "antennaGain": 0, + "neighborInfo": False, + } + class TestNodeConf(unittest.TestCase): def test_reject_rssi_and_pathloss_between_identical_nodes(self): @@ -14,3 +43,279 @@ def test_reject_rssi_and_pathloss_between_identical_nodes(self): with self.assertRaises(ValueError, msg="cannot compute rssi/pathloss between the same nodes (by id)"): nodeconf.compute_rssi_and_pathloss_to(nodeconf, conf) + + def test_pathloss_includes_configured_terrain_obstruction(self): + from lib.config import Config + from lib.point import Point + from lib.terrain import TerrainGrid + + conf = Config() + tx = lib.node.NodeConfig(0, Point(0, 0, 2), 1, conf.PTX, conf.FREQ) + rx = lib.node.NodeConfig(1, Point(1000, 0, 2), 1, conf.PTX, conf.FREQ) + plain_rssi, plain_loss = tx.compute_rssi_and_pathloss_to(rx, conf) + + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = TerrainGrid.from_rows( + [(0, 0, 0), (500, 0, 500), (1000, 0, 0)] + ) + terrain_rssi, terrain_loss = tx.compute_rssi_and_pathloss_to(rx, conf) + + self.assertGreater(terrain_loss, plain_loss) + self.assertLess(terrain_rssi, plain_rssi) + + +class TestNodeConfigYaml(unittest.TestCase): + def test_plain_gui_node_map_is_accepted(self): + configs = node_configs_from_yaml({0: sample_node(10), 1: sample_node(20)}, 1000) + + self.assertEqual([cfg.node_id for cfg in configs], [0, 1]) + self.assertEqual(configs[0].role, MESHTASTIC_ROLE.CLIENT) + self.assertEqual(configs[1].position.x, 20) + + def test_wrapped_real_mesh_node_map_is_accepted(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": {"0": sample_node(10), "1": sample_node(20)}, + } + + configs = node_configs_from_yaml(raw, 1000) + + self.assertEqual([cfg.node_id for cfg in configs], [0, 1]) + self.assertEqual(configs[0].period, 1000) + + def test_wrapped_real_mesh_node_ids_are_remapped_to_sim_indices(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": { + "3944424993": sample_node(10), + "3944424994": sample_node(20), + }, + } + + configs = node_configs_from_yaml(raw, 1000) + + self.assertEqual([cfg.node_id for cfg in configs], [0, 1]) + self.assertEqual([cfg.position.x for cfg in configs], [10, 20]) + + def test_wrapped_node_map_origin_is_available_for_terrain(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": {"0": sample_node(10)}, + } + + self.assertEqual(origin_from_yaml(raw), (41.64, 41.62)) + + def test_node_yaml_must_be_a_node_map(self): + with self.assertRaisesRegex(ValueError, "node YAML"): + node_configs_from_yaml(["not", "a", "node", "map"], 1000) + + def test_wrapped_node_map_must_be_a_map(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": ["not", "a", "node", "map"], + } + + with self.assertRaisesRegex(ValueError, "node YAML"): + node_configs_from_yaml(raw, 1000) + + def test_wrapped_node_map_origin_must_be_finite(self): + raw = { + "origin": {"lat": "nan", "lon": 41.62}, + "nodes": {"0": sample_node(10)}, + } + + with self.assertRaisesRegex(ValueError, "origin.lat"): + origin_from_yaml(raw) + + def test_wrapped_node_map_origin_must_be_in_coordinate_range(self): + raw = { + "origin": {"lat": 91, "lon": 41.62}, + "nodes": {"0": sample_node(10)}, + } + + with self.assertRaisesRegex(ValueError, "latitude/longitude"): + origin_from_yaml(raw) + + +class TestMeshNodeTerrain(unittest.TestCase): + def test_mesh_node_preserves_absolute_altitude_for_terrain_recompute(self): + conf = Config() + conf.NR_NODES = 1 + conf.MOVEMENT_ENABLED = False + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + conf.TERRAIN_GRID = TerrainGrid.from_rows([(0, 0, 100), (100, 0, 120)]) + + node_config = NodeConfig(0, Point(0, 0, 2.5), conf.PERIOD, conf.PTX, conf.FREQ, absolute_altitude=150) + sim_state = SimulationState(conf, simpy.Environment()) + node = MeshNode(conf, sim_state, SimulationDataTracking(), node_config) + + apply_terrain_altitude(conf.TERRAIN_GRID, node) + self.assertEqual(node.position.z, 150) + + node.position.update_xy(100, 0) + apply_terrain_altitude(conf.TERRAIN_GRID, node) + self.assertEqual(node.position.z, 150) + + +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)) + + +class TestMeshNodeReceive(unittest.TestCase): + def test_capture_model_does_not_decode_while_transmitting(self): + conf = Config() + conf.NR_NODES = 1 + conf.CAPTURE_COLLISION_MODEL_ENABLED = True + env = simpy.Environment() + node_config = NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD) + node = MeshNode(conf, SimulationState(conf, env), SimulationDataTracking(), node_config) + node.isTransmitting = True + pipe = simpy.Store(env) + packet = type("Packet", (), { + "sensedByN": [True], + "onAirToN": [True], + "collidedAtN": [False], + "phyLostAtN": [False], + "receivedAtN": [False], + "seq": 1, + "txNodeId": 7, + })() + + env.process(node.receive(pipe)) + pipe.put(packet) + env.run(until=0.001) + node.isTransmitting = False + pipe.put(packet) + env.run(until=0.002) + + self.assertFalse(packet.sensedByN[0]) + self.assertFalse(packet.receivedAtN[0]) + + def test_capture_model_preserves_sensed_collision_casualty(self): + conf = Config() + conf.NR_NODES = 1 + conf.CAPTURE_COLLISION_MODEL_ENABLED = True + env = simpy.Environment() + node_config = NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD) + node = MeshNode(conf, SimulationState(conf, env), SimulationDataTracking(), node_config) + pipe = simpy.Store(env) + packet = type("Packet", (), { + "sensedByN": [True], + "onAirToN": [True], + "collidedAtN": [True], + "phyLostAtN": [False], + "receivedAtN": [False], + "timeOnAir": 1.0, + "seq": 1, + "txNodeId": 7, + })() + + env.process(node.receive(pipe)) + pipe.put(packet) + env.run(until=0.001) + pipe.put(packet) + env.run(until=0.002) + + self.assertTrue(packet.sensedByN[0]) + self.assertFalse(packet.receivedAtN[0]) + + +class TestMeshNodeRetryTimer(unittest.TestCase): + def make_node(self, dcr_enabled=True): + node = MeshNode.__new__(MeshNode) + node.nodeid = 3 + node.conf = Config() + node.conf.DCR_ENABLED = dcr_enabled + node.env = simpy.Environment() + node.packets = [] + return node + + def make_packet(self, retransmissions, ready=False): + return type("Packet", (), { + "origTxNodeId": 3, + "seq": 9, + "retransmissions": retransmissions, + "retryTimerAirtimeReady": ready, + })() + + def test_latest_retry_timer_packet_uses_newest_attempt(self): + node = self.make_node() + first = self.make_packet(retransmissions=3, ready=True) + retry = self.make_packet(retransmissions=1, ready=True) + node.packets = [first, retry] + + self.assertIs(node.latest_retry_timer_packet(first), retry) + + def test_wait_for_retry_timer_airtime_blocks_until_dcr_selected(self): + node = self.make_node() + packet = self.make_packet(retransmissions=3, ready=False) + node.packets = [packet] + events = [] + + def waiter(): + yield from node.wait_for_retry_timer_airtime(packet) + events.append(node.env.now) + + def selector(): + yield node.env.timeout(3) + packet.retryTimerAirtimeReady = True + + node.env.process(waiter()) + node.env.process(selector()) + node.env.run(until=4) + + self.assertEqual(events, [3]) + + def test_wait_for_retry_timer_airtime_does_not_block_static_policy(self): + node = self.make_node(dcr_enabled=False) + packet = self.make_packet(retransmissions=3, ready=False) + node.packets = [packet] + events = [] + + def waiter(): + yield from node.wait_for_retry_timer_airtime(packet) + events.append(node.env.now) + + node.env.process(waiter()) + node.env.run(until=1) + + self.assertEqual(events, [0]) + + +class TestMeshNodeRandomness(unittest.TestCase): + def make_node(self, seed): + conf = Config() + 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_osm_clutter.py b/tests/test_osm_clutter.py new file mode 100644 index 00000000..eafc40fe --- /dev/null +++ b/tests/test_osm_clutter.py @@ -0,0 +1,146 @@ +import contextlib +import io +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from lib.osm_clutter import ( + classify_osm_element, + main as osm_clutter_main, + parse_origin, + payload_elements, + point_in_polygon, + rasterize_clutter, + write_clutter_csv, +) + + +class TestOsmClutter(unittest.TestCase): + def test_classifies_osm_tags_to_radio_clutter_classes(self): + self.assertEqual(classify_osm_element({"building": "yes"}), "urban") + self.assertEqual(classify_osm_element({"landuse": "residential"}), "urban") + self.assertEqual(classify_osm_element({"natural": "wood"}), "forest") + self.assertEqual(classify_osm_element({"natural": "water"}), "water") + self.assertEqual(classify_osm_element({"natural": "beach"}), "open") + + def test_parse_origin_rejects_non_finite_values(self): + self.assertEqual(parse_origin("41.6,41.6"), (41.6, 41.6)) + with self.assertRaises(ValueError): + parse_origin("41.6,nan") + + def test_parse_origin_rejects_out_of_range_values(self): + with self.assertRaises(ValueError): + parse_origin("91,41.6") + + def test_point_in_polygon(self): + polygon = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)] + + self.assertTrue(point_in_polygon(5, 5, polygon)) + self.assertFalse(point_in_polygon(15, 5, polygon)) + + def test_payload_elements_rejects_malformed_overpass_shape(self): + with self.assertRaisesRegex(ValueError, "JSON object"): + payload_elements([]) + with self.assertRaisesRegex(ValueError, "elements"): + payload_elements({"elements": {}}) + + def test_rasterize_clutter_marks_building_cells_urban(self): + payload = { + "elements": [{ + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0005}, + ], + }], + } + + rows = rasterize_clutter(payload, (0.0, 0.0, 0.002, 0.002), origin=(0.0, 0.0), step_m=100.0) + + self.assertIn("urban", {row["clutter_class"] for row in rows}) + + def test_rasterize_clutter_skips_malformed_osm_elements(self): + payload = { + "elements": [ + "not an element", + { + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": "bad", "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + ], + }, + { + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + ], + }, + ], + } + + rows = rasterize_clutter(payload, (0.0, 0.0, 0.002, 0.002), origin=(0.0, 0.0), step_m=100.0) + + self.assertIn("urban", {row["clutter_class"] for row in rows}) + + def test_rasterize_clutter_rejects_non_positive_step(self): + with self.assertRaises(ValueError): + rasterize_clutter({"elements": []}, (0.0, 0.0, 0.002, 0.002), step_m=0) + + def test_write_clutter_csv_uses_lf_line_endings(self): + with TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "clutter.csv" + + write_clutter_csv([{ + "x_m": 0, + "y_m": 0, + "lat": 41.0, + "lon": 41.0, + "clutter_class": "open", + }], output) + + raw = output.read_bytes() + + self.assertIn(b"\n", raw) + self.assertNotIn(b"\r\n", raw) + + def test_write_clutter_csv_creates_parent_directories(self): + with TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "nested" / "clutter.csv" + + write_clutter_csv([{ + "x_m": 0, + "y_m": 0, + "lat": 41.0, + "lon": 41.0, + "clutter_class": "open", + }], output) + + self.assertTrue(output.exists()) + + def test_cli_rejects_invalid_bbox_without_traceback(self): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + osm_clutter_main([ + "--bbox", "0,0,nan,0.002", + "--input-json", "/dev/null", + "--output", "/tmp/unused-clutter.csv", + ]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("map bbox values must be finite", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_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() 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() diff --git a/tests/test_radio_policy_compare.py b/tests/test_radio_policy_compare.py new file mode 100644 index 00000000..d95ea52c --- /dev/null +++ b/tests/test_radio_policy_compare.py @@ -0,0 +1,266 @@ +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_parse_policy_names_accepts_current_policy_names(self): + self.assertEqual(radio_policy_compare.parse_policy_names("static,dcr,dtp"), ["static", "dcr", "dtp"]) + + def test_parse_args_rejects_thresholds_without_candidate_policy(self): + with self.assertRaises(SystemExit): + radio_policy_compare.parse_args(["--max-reach-drop-pp", "1"]) + + def test_parse_args_rejects_thresholds_without_static_baseline(self): + with self.assertRaises(SystemExit): + radio_policy_compare.parse_args([ + "--policies", + "dcr,static", + "--max-reach-drop-pp", + "1", + ]) + + def test_build_lora_args_adds_shared_physics_flags(self): + args = radio_policy_compare.parse_args([ + "--preset", + "batumi", + "--simtime-seconds", + "12", + "--period-seconds", + "3", + "--policies", + "static", + "--", + "--no-clutter", + ]) + + lora_args = radio_policy_compare.build_lora_args(args, "static") + + self.assertEqual(lora_args[:2], ["--preset", "batumi"]) + self.assertIn("--no-gui", lora_args) + self.assertIn("--phy-loss-model", lora_args) + self.assertIn("--capture-collision-model", lora_args) + self.assertIn("--no-clutter", lora_args) + self.assertNotIn("--dcr", lora_args) + self.assertNotIn("--dtp", lora_args) + self.assertNotIn("--", lora_args) + + def test_build_lora_args_adds_dcr_policy_flag(self): + args = radio_policy_compare.parse_args([ + "--policies", + "dcr", + ]) + + lora_args = radio_policy_compare.build_lora_args(args, "dcr") + + self.assertIn("--dcr", lora_args) + + def test_build_lora_args_adds_dtp_policy_flag(self): + args = radio_policy_compare.parse_args([ + "--policies", + "dtp", + ]) + + lora_args = radio_policy_compare.build_lora_args(args, "dtp") + + self.assertIn("--dtp", lora_args) + self.assertNotIn("--dcr", lora_args) + + def test_summarize_results_formats_table_and_deltas(self): + static = radio_policy_compare.summarize_results( + "static", + "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_summarize_results_tolerates_missing_future_policy_counters(self): + summary = radio_policy_compare.summarize_results( + "static", + "static CR", + { + "messageSeq": 10, + "sent": 100, + "nrReceived": 40, + "nrCollisions": 5, + "nrPhyLoss": 7, + "nodeReach": 0.25, + "usefulness": 0.5, + "txAirUtilizationRate": 0.07, + }, + "raw", + ) + + self.assertEqual(summary.cr_mix, "n/a") + self.assertEqual(summary.dtp_power_mix, "n/a") + self.assertEqual(summary.dtp_detected, 0.0) + self.assertEqual(summary.dtp_decodable, 0.0) + + def test_thresholds_flag_reach_and_airtime_regressions(self): + args = radio_policy_compare.parse_args([]) + args.max_reach_drop_pp = 1 + args.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", + "--", + "--no-clutter", + ]) + args.max_reach_drop_pp = 2 + 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/tests/test_srtm.py b/tests/test_srtm.py new file mode 100644 index 00000000..67f805e1 --- /dev/null +++ b/tests/test_srtm.py @@ -0,0 +1,251 @@ +import gzip +import sys +import tempfile +import unittest +import zipfile +from array import array +from pathlib import Path + +from lib.srtm import ( + HGT_VOID, + SrtmTile, + clamp_bbox_to_srtm_coverage, + ensure_hgt_tile, + terrain_grid_from_srtm, + terrain_rows_from_srtm, + srtm_tile_name, + tiles_for_bbox, +) + + +def write_hgt(path, values): + data = array("h", values) + if sys.byteorder == "little": + data.byteswap() + Path(path).write_bytes(data.tobytes()) + + +class TestSrtm(unittest.TestCase): + def test_tile_name_uses_srtm_flooring(self): + self.assertEqual(srtm_tile_name(41.64, 41.61), "N41E041") + self.assertEqual(srtm_tile_name(-0.1, -1.2), "S01W002") + + def test_tile_name_covers_all_hemispheres(self): + cases = [ + ((41.64, 41.61), "N41E041"), + ((41.64, -41.61), "N41W042"), + ((-41.64, 41.61), "S42E041"), + ((-41.64, -41.61), "S42W042"), + ((0.0, 0.0), "N00E000"), + ((-0.0001, -0.0001), "S01W001"), + ] + + for (lat, lon), tile_name in cases: + with self.subTest(lat=lat, lon=lon): + self.assertEqual(srtm_tile_name(lat, lon), tile_name) + + def test_tiles_for_bbox_covers_crossed_integer_degrees(self): + self.assertEqual( + tiles_for_bbox((41.5, 41.5, 42.2, 42.2)), + ["N41E041", "N41E042", "N42E041", "N42E042"], + ) + + def test_tiles_for_bbox_covers_equator_and_prime_meridian_crossing(self): + self.assertEqual( + tiles_for_bbox((-0.2, -0.2, 0.2, 0.2)), + ["N00E000", "N00W001", "S01E000", "S01W001"], + ) + + def test_tiles_for_bbox_excludes_global_edge_tiles(self): + self.assertEqual(tiles_for_bbox((59.5, 179.5, 60.0, 180.0)), ["N59E179"]) + + def test_tiles_for_bbox_maps_zero_span_global_edges_to_existing_tiles(self): + self.assertEqual(tiles_for_bbox((60.0, 41.0, 60.0, 41.1)), ["N59E041"]) + self.assertEqual(tiles_for_bbox((59.9, 180.0, 60.0, 180.0)), ["N59E179"]) + + def test_tiles_for_bbox_rejects_outside_srtm_latitude_coverage(self): + with self.assertRaisesRegex(ValueError, "56°S and 60°N"): + tiles_for_bbox((60.0, 41.0, 60.1, 41.1)) + + with self.assertRaisesRegex(ValueError, "56°S and 60°N"): + tiles_for_bbox((-56.1, 41.0, -56.0, 41.1)) + + def test_clamp_bbox_to_srtm_coverage_preserves_overlap(self): + self.assertEqual( + clamp_bbox_to_srtm_coverage((59.9, 179.9, 60.1, 180.1)), + (59.9, 179.9, 60.0, 180.0), + ) + + def test_clamp_bbox_to_srtm_coverage_rejects_no_overlap(self): + with self.assertRaisesRegex(ValueError, "does not overlap"): + clamp_bbox_to_srtm_coverage((60.1, 41.0, 60.2, 41.1)) + + def test_hgt_tile_reads_big_endian_elevation_samples(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "N41E041.hgt" + write_hgt(path, [10, 20, 30, 40, 50, 60, 70, 80, 90]) + + tile = SrtmTile.from_hgt(path) + + self.assertEqual(tile.elevation_at(42.0, 41.0), 10) + self.assertEqual(tile.elevation_at(41.0, 42.0), 90) + + def test_hgt_void_uses_nearby_sample(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "N41E041.hgt" + write_hgt(path, [10, 20, 30, 40, HGT_VOID, 60, 70, 80, 90]) + + tile = SrtmTile.from_hgt(path) + + self.assertIsNotNone(tile.elevation_at(41.5, 41.5)) + + def test_terrain_grid_from_srtm_avoids_csv_intermediate(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + cache_dir.mkdir() + write_hgt(cache_dir / "N41E041.hgt", [10, 20, 30, 40, 50, 60, 70, 80, 90]) + + grid = terrain_grid_from_srtm( + (41.0, 41.0, 41.1, 41.1), + step_meters=20000, + cache_dir=cache_dir, + origin_lat=41.0, + origin_lon=41.0, + download_missing=False, + ) + + self.assertGreater(len(grid.samples), 0) + self.assertIsNotNone(grid.elevation_at(0, 0)) + + def test_terrain_rows_samples_global_edges_from_existing_tiles(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + cache_dir.mkdir() + write_hgt(cache_dir / "N59E179.hgt", [10, 20, 30, 40]) + + rows = list( + terrain_rows_from_srtm( + (60.0, 179.9, 60.0, 180.0), + step_meters=20000, + cache_dir=cache_dir, + download_missing=False, + ) + ) + + self.assertGreater(len(rows), 0) + self.assertIn("180.0000000", {row["lon"] for row in rows}) + + def test_terrain_rows_can_limit_requested_tiles(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + cache_dir.mkdir() + write_hgt(cache_dir / "N41E041.hgt", [10, 20, 30, 40]) + + rows = list( + terrain_rows_from_srtm( + (41.0, 41.0, 43.1, 43.1), + step_meters=20000, + cache_dir=cache_dir, + download_missing=False, + tile_names=["N41E041"], + ) + ) + + self.assertGreater(len(rows), 0) + self.assertEqual({row["lat"][:2] for row in rows}, {"41"}) + self.assertEqual({row["lon"][:2] for row in rows}, {"41"}) + + def test_terrain_rows_rejects_non_finite_step(self): + with self.assertRaises(ValueError): + list(terrain_rows_from_srtm((41.0, 41.0, 41.1, 41.1), float("nan"), "/tmp")) + + def test_ensure_hgt_tile_downloads_and_unpacks_gzip_template(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + raw_hgt = source_dir / "N41E041.hgt" + write_hgt(raw_hgt, [1, 2, 3, 4]) + with ( + raw_hgt.open("rb") as src, + gzip.open(source_dir / "N41E041.hgt.gz", "wb") as dst, + ): + dst.write(src.read()) + + path = ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.gz", + ) + + self.assertEqual(path.name, "N41E041.hgt") + self.assertTrue(path.exists()) + + def test_ensure_hgt_tile_selects_requested_member_from_zip(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + wrong_hgt = source_dir / "N40E040.hgt" + requested_hgt = source_dir / "N41E041.hgt" + write_hgt(wrong_hgt, [1, 2, 3, 4]) + write_hgt(requested_hgt, [10, 20, 30, 40]) + + with zipfile.ZipFile(source_dir / "N41E041.hgt.zip", "w") as archive: + archive.write(wrong_hgt, "nested/N40E040.hgt") + archive.write(requested_hgt, "nested/N41E041.hgt") + + path = ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.zip", + ) + + tile = SrtmTile.from_hgt(path) + self.assertEqual(tile.elevation_at(42.0, 41.0), 10) + + def test_ensure_hgt_tile_rejects_zip_without_requested_member(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + wrong_hgt = source_dir / "N40E040.hgt" + write_hgt(wrong_hgt, [1, 2, 3, 4]) + + with zipfile.ZipFile(source_dir / "N41E041.hgt.zip", "w") as archive: + archive.write(wrong_hgt, "N40E040.hgt") + + with self.assertRaisesRegex(ValueError, "N41E041.hgt"): + ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.zip", + ) + + def test_ensure_hgt_tile_rejects_unknown_template_placeholder(self): + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaisesRegex(ValueError, "tile"): + ensure_hgt_tile( + "N41E041", tmpdir, url_template="file:///tmp/{missing}.hgt" + ) + + def test_ensure_hgt_tile_does_not_cache_failed_unpack(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + (source_dir / "N41E041.hgt.gz").write_bytes(b"not gzip") + + with self.assertRaisesRegex(ValueError, "could not unpack"): + ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.gz", + ) + + self.assertFalse((cache_dir / "N41E041.hgt").exists()) + self.assertFalse((cache_dir / "N41E041.hgt.tmp").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terrain.py b/tests/test_terrain.py new file mode 100644 index 00000000..64c62408 --- /dev/null +++ b/tests/test_terrain.py @@ -0,0 +1,165 @@ +import unittest + +from lib.config import Config +from lib.point import Point +from lib.terrain import ( + NODE_Z_REFERENCE_SEA_LEVEL, + TerrainGrid, + apply_terrain_altitude, + apply_terrain_altitudes, + latlon_to_xy, + terrain_ground_elevation, + terrain_obstruction_loss, + xy_to_latlon, +) + + +class TerrainNode: + def __init__(self, position, antenna_height=None, absolute_altitude=None): + self.position = position + self.antenna_height = position.z if antenna_height is None else antenna_height + self.absolute_altitude = absolute_altitude + + +class TestTerrain(unittest.TestCase): + def test_latlon_projection_preserves_origin(self): + x, y = latlon_to_xy(41.6, 41.6, 41.6, 41.6) + + self.assertAlmostEqual(x, 0.0) + self.assertAlmostEqual(y, 0.0) + + def test_latlon_projection_round_trips(self): + lat, lon = 41.65, 41.62 + x, y = latlon_to_xy(lat, lon, 41.6, 41.6) + + out_lat, out_lon = xy_to_latlon(x, y, 41.6, 41.6) + + self.assertAlmostEqual(out_lat, lat) + self.assertAlmostEqual(out_lon, lon) + + def test_xy_projection_rejects_polar_origin(self): + with self.assertRaisesRegex(ValueError, "pole"): + xy_to_latlon(100, 100, 90.0, 0.0) + + def test_grid_interpolates_exact_sample(self): + grid = TerrainGrid.from_rows([ + (0, 0, 5), + (100, 0, 25), + ]) + + self.assertEqual(grid.elevation_at(100, 0), 25) + + def test_grid_rejects_non_finite_samples(self): + with self.assertRaisesRegex(ValueError, "finite"): + TerrainGrid.from_rows([(0, float("nan"), 5)]) + + def test_configured_grid_provides_ground_elevation(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 5), + (100, 0, 25), + ]) + + self.assertEqual(terrain_ground_elevation(conf, Point(0, 0, 1)), 5) + + def test_terrain_altitudes_keep_antenna_height_separate(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 100), + (100, 0, 120), + ]) + nodes = [ + TerrainNode(Point(0, 0, 2.5)), + TerrainNode(Point(100, 0, 3.0)), + ] + + apply_terrain_altitudes(conf.TERRAIN_GRID, nodes) + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 3.0]) + self.assertEqual([node.position.z for node in nodes], [102.5, 123.0]) + + def test_terrain_altitude_recomputes_after_node_moves(self): + class LiveNode: + def __init__(self): + self.position = Point(0, 0, 0) + self.antennaHeight = 2.0 + + grid = TerrainGrid.from_rows([ + (0, 0, 100), + (100, 0, 120), + ]) + node = LiveNode() + + apply_terrain_altitude(grid, node) + self.assertEqual(node.position.z, 102.0) + + node.position.update_xy(100, 0) + apply_terrain_altitude(grid, node) + + self.assertEqual(node.position.z, 122.0) + + def test_terrain_altitudes_use_plausible_per_node_map_altitudes(self): + grid = TerrainGrid.from_rows([ + (0, 0, 100), + (100, 0, 100), + (200, 0, 100), + (300, 0, 100), + ]) + nodes = [ + TerrainNode(Point(0, 0, 2.5), absolute_altitude=150), + TerrainNode(Point(100, 0, 2.5)), + TerrainNode(Point(200, 0, 2.5), absolute_altitude=50), + TerrainNode(Point(300, 0, 2.5), absolute_altitude=1000), + ] + + apply_terrain_altitudes(grid, nodes) + + self.assertEqual([node.position.z for node in nodes], [150, 102.5, 102.5, 102.5]) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5, 2.5, 2.5]) + + def test_ridge_adds_obstruction_loss(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_PROFILE_SAMPLES = 10 + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 0), + (500, 0, 120), + (1000, 0, 0), + ]) + + loss = terrain_obstruction_loss( + conf, + Point(0, 0, 2), + Point(1000, 0, 2), + conf.FREQ, + ) + + self.assertGreater(loss, 0) + + def test_effective_earth_radius_adds_curvature_loss_on_long_flat_link(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_PROFILE_SAMPLES = 10 + conf.TERRAIN_FRESNEL_CLEARANCE = 0.0 + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 0), + (25000, 0, 0), + (50000, 0, 0), + ]) + + loss = terrain_obstruction_loss( + conf, + Point(0, 0, 2), + Point(50000, 0, 2), + conf.FREQ, + ) + + self.assertGreater(loss, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/osm_to_clutter_csv.py b/tools/osm_to_clutter_csv.py new file mode 100644 index 00000000..b17e4a42 --- /dev/null +++ b/tools/osm_to_clutter_csv.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""CLI wrapper for exporting OSM land-cover clutter into CSV format.""" + +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from lib.osm_clutter import main # noqa: E402 + + +if __name__ == "__main__": + main() diff --git a/tools/radio_policy_compare.py b/tools/radio_policy_compare.py new file mode 100644 index 00000000..cc4fc78c --- /dev/null +++ b/tools/radio_policy_compare.py @@ -0,0 +1,533 @@ +#!/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"]), + "dtp": ("Dynamic TX Power", ["--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,dtp -- --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"), + help="Comma-separated policies: static,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 --", + ) + args = parser.parse_args(argv) + if len(args.policies) < 2 and threshold_requested(args): + parser.error("--max-* thresholds require at least two policies so there is a baseline and a candidate") + if threshold_requested(args) and args.policies[0] != "static": + parser.error("--max-* thresholds require static as the first policy baseline") + return args + + +def threshold_requested(args): + return ( + args.max_reach_drop_pp is not None + or args.max_useful_drop_pp is not None + or args.max_tx_air_increase_pp is not None + ) + + +def positive_float(value): + 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(result_value(results, "dcrTxByCr", {})), + dtp_power_mix=format_power_mix(result_value(results, "dtpTxByPower", {})), + dtp_detected=float(result_value(results, "dtpMeanDetectedByTx", 0.0)), + dtp_decodable=float(result_value(results, "dtpMeanSensedByTx", 0.0)), + output=output, + ) + + +def result_value(results, key, default): + try: + return results[key] + except KeyError: + return default + + +def as_percent(value): + numeric = float(value) + if math.isnan(numeric): + 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())