From 07d727c6aa844f669bb79881bd5fbcda9edf2b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Mon, 7 Sep 2026 23:17:05 +0200 Subject: [PATCH 1/2] feat(web-standalone): antenna height from the roof a repeater stands on The line-of-sight check put every observer's antenna at 12 m (#74). Sampled against 3D BAG, 32 of 60 fixture observers stand within 12 m of a building, roof median 10.2 m above ground, p90 25 m; the constant is right for about half of them and low for a quarter. bag3d.py (stdlib, tested): WGS84 to RD New and back (within 1 m on the Westertoren and the Martinitoren), the buildings in a 3D BAG CityJSON response with their footprints in metres via the response transform, point-in-footprint, and roof_for_point(). server.py serves /proxy/3dbag/roof?lat=&lon=, one 30 m bbox query per node cached for a day, no roof outside the Netherlands, 400 on bad input. On activation the page looks up the same nodes the link budget covers and sets roofHeightM where the node stands inside a footprint; terrainLosFactor() starts the beam at roof plus a 3 m mast for those and at 12 m for the rest. The cluster status says how many, the observer popup shows the antenna height and its source, and a dragged node drops its roof with its radius. Dockerfile and .dockerignore ship the module; README and AGENTS name the source. The other half of #74, building geometry in the line-of-sight profile, is measured and not built: dense sampling calls 6 of 10 real receptions blocked or partial, so more resolution penalises the true cell under the current knife-edge model. Numbers in the issue. Verified: pytest 11 passed; the live route returns 35.5 m for the fixture building, 75.9 m on the Westertoren, no roof for Paris; in the page a locked cluster of 14 nodes makes 14 lookups, 3 stand on a building, two of them active with antennas at 39 m and 13.8 m, the rest at 12 m, status "3 of 14 observers stand on a building per 3D BAG, the rest assume 12 m", no console errors. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 3 +- README.md | 1 + web-standalone/.dockerignore | 1 + web-standalone/Dockerfile | 2 +- web-standalone/bag3d.py | 142 ++++++++++++++++++ web-standalone/changelog.json | 6 + web-standalone/index.html | 63 +++++++- web-standalone/server.py | 39 +++++ web-standalone/tests/fixtures/bag3d-pand.json | 1 + web-standalone/tests/test_bag3d.py | 60 ++++++++ 10 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 web-standalone/bag3d.py create mode 100644 web-standalone/tests/fixtures/bag3d-pand.json create mode 100644 web-standalone/tests/test_bag3d.py diff --git a/AGENTS.md b/AGENTS.md index 62c311b..73a3126 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ web-standalone/ Self-contained map tool (no DB) changelog.json "What's new" entries, newest first; every user-visible change adds one server.py stdlib-only HTTP server + proxy for upstream feeds (mc-radar, meshcore.io, PDOK, mc-spamdetector.nl) spamdetector.py parses a spam-detector attack page into Step 1 clues (pure logic, tested) + bag3d.py 3D BAG: RD conversion, footprints, the roof a node stands on (pure logic, tested) tests/ pytest for the pure logic; fixtures are trimmed copies of real upstream pages tools/ accuracy.mjs (estimator error against known targets), its fixture, build-fixture.py Dockerfile Alpine, non-root, stdlib-only @@ -179,7 +180,7 @@ new hardcoded hex values. ### Don't hammer upstream feeds per-node -The proxied upstream APIs (mc-radar, map.meshcore.io, PDOK, mc-spamdetector.nl) are third-party +The proxied upstream APIs (mc-radar, map.meshcore.io, PDOK, mc-spamdetector.nl, 3D BAG) are third-party and rate-limitable; mc-radar answers 429 after a few hundred proven-link fetches in a day. Batch/cache lookups (as the existing code does, including the in-memory cache in `server.py` for the spam detector) rather than firing one request per node/prefix. diff --git a/README.md b/README.md index 13bc38e..4e619a0 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ control in front if you do; the proxy has no auth of its own. | map.meshcore.io | Global node feed (fallback). | | PDOK AHN | Terrain/surface elevation (NL). | | mc-spamdetector.nl | Incident list, and an incident's entry hops as Step 1 clues. | +| 3D BAG (api.3dbag.nl) | The building an observer stands on, for its antenna height in the line-of-sight check (NL). | Loaded by the page directly, not through the proxy: OpenFreeMap vector tiles (the Liberty basemap, no key) and AWS Terrain Tiles (relief in the 3D view, for looking at; line of sight is measured on AHN). diff --git a/web-standalone/.dockerignore b/web-standalone/.dockerignore index cb174c1..9913164 100644 --- a/web-standalone/.dockerignore +++ b/web-standalone/.dockerignore @@ -1,5 +1,6 @@ * !server.py !spamdetector.py +!bag3d.py !index.html !changelog.json diff --git a/web-standalone/Dockerfile b/web-standalone/Dockerfile index 61141b7..2c88333 100644 --- a/web-standalone/Dockerfile +++ b/web-standalone/Dockerfile @@ -7,7 +7,7 @@ LABEL org.opencontainers.image.source="https://github.com/khagele/meshcore-trian org.opencontainers.image.description="MeshCore triangulator (web-standalone): static UI + stdlib proxy to mc-radar/meshcore/PDOK" WORKDIR /app -COPY server.py spamdetector.py index.html changelog.json ./ +COPY server.py spamdetector.py bag3d.py index.html changelog.json ./ # Inside the container we must bind all interfaces; restrict exposure when # publishing the port instead (e.g. -p 127.0.0.1:8000:8000). diff --git a/web-standalone/bag3d.py b/web-standalone/bag3d.py new file mode 100644 index 0000000..0a88f96 --- /dev/null +++ b/web-standalone/bag3d.py @@ -0,0 +1,142 @@ +"""3D BAG lookups for the triangulator (#74). + +A repeater on a roof has its antenna at roof height plus a mast, not at the +12 m the estimator assumed for every node. 3D BAG (TU Delft, CC BY 4.0) +publishes per-building footprints with roof and ground heights. This module +turns one response of its OGC API Features endpoint into "the building this +point stands on, and how high its roof is above the ground". + +Pure logic, stdlib only, so it is unit-testable (tests/test_bag3d.py). +server.py fetches the payload and serves the result under /proxy/3dbag/. + +The API takes bounding boxes in EPSG:7415 (RD New + NAP), so lat/lon is +converted with the usual polynomial approximation, good to about 25 cm. +""" +import math + +# Schreutelkops-Adema approximation, WGS84 -> RD New. +_RD_X0, _RD_Y0 = 155000.0, 463000.0 +_LAT0, _LON0 = 52.15517440, 5.38720621 + +_RD_X_TERMS = [(0, 1, 190094.945), (1, 1, -11832.228), (2, 1, -114.221), (0, 3, -32.391), + (1, 0, -0.705), (3, 1, -2.340), (1, 3, -0.608), (0, 2, -0.008), (2, 3, 0.148)] +_RD_Y_TERMS = [(1, 0, 309056.544), (0, 2, 3638.893), (2, 0, 73.077), (1, 2, -157.984), + (3, 0, 59.788), (0, 1, 0.433), (2, 2, -6.439), (1, 1, -0.032), (0, 4, 0.092), + (1, 4, -0.054)] +_WGS_LAT_TERMS = [(0, 1, 3235.65389), (2, 0, -32.58297), (0, 2, -0.24750), (2, 1, -0.84978), + (0, 3, -0.06550), (2, 2, -0.01709), (1, 0, -0.00738), (4, 0, 0.00530), + (2, 3, -0.00039), (4, 1, 0.00033), (1, 1, -0.00012)] +_WGS_LON_TERMS = [(1, 0, 5260.52916), (1, 1, 105.94684), (1, 2, 2.45656), (3, 0, -0.81885), + (1, 3, 0.05594), (3, 1, -0.05607), (0, 1, 0.01199), (3, 2, -0.00256), + (1, 4, 0.00128), (0, 2, 0.00022), (2, 0, -0.00022), (5, 0, 0.00026)] + + +def wgs84_to_rd(lat, lon): + """RD New x, y in metres for a WGS84 position.""" + dlat = 0.36 * (lat - _LAT0) + dlon = 0.36 * (lon - _LON0) + x = _RD_X0 + sum(c * dlat ** p * dlon ** q for p, q, c in _RD_X_TERMS) + y = _RD_Y0 + sum(c * dlat ** p * dlon ** q for p, q, c in _RD_Y_TERMS) + return x, y + + +def rd_to_wgs84(x, y): + """WGS84 lat, lon for an RD New position. Used by the tests to build a + lat/lon inside a known footprint; the app only goes the other way.""" + dx = (x - _RD_X0) * 1e-5 + dy = (y - _RD_Y0) * 1e-5 + lat = _LAT0 + sum(c * dx ** p * dy ** q for p, q, c in _WGS_LAT_TERMS) / 3600.0 + lon = _LON0 + sum(c * dx ** p * dy ** q for p, q, c in _WGS_LON_TERMS) / 3600.0 + return lat, lon + + +def bbox_around(lat, lon, radius_m): + """An RD bbox string for the API, radius_m either side of the point.""" + x, y = wgs84_to_rd(lat, lon) + return "%.0f,%.0f,%.0f,%.0f" % (x - radius_m, y - radius_m, x + radius_m, y + radius_m) + + +def _transform(payload, feature): + meta = payload.get("metadata") or {} + transform = meta.get("transform") or feature.get("transform") or {} + scale = transform.get("scale") or [1, 1, 1] + translate = transform.get("translate") or [0, 0, 0] + return scale, translate + + +def buildings(payload): + """Every Building in a response: id, roof height above ground, the raw + heights, and its footprint rings in RD metres (outer ring first per + surface, inner rings are holes).""" + out = [] + for feature in payload.get("features") or []: + scale, translate = _transform(payload, feature) + vertices = feature.get("vertices") or [] + + def xy(index): + v = vertices[index] + return (v[0] * scale[0] + translate[0], v[1] * scale[1] + translate[1]) + + for object_id, obj in (feature.get("CityObjects") or {}).items(): + if obj.get("type") != "Building": + continue + attrs = obj.get("attributes") or {} + roof = attrs.get("b3_h_dak_max") + ground = attrs.get("b3_h_maaiveld") + surfaces = [] + for geometry in obj.get("geometry") or []: + if str(geometry.get("lod")) != "0": + continue + for surface in geometry.get("boundaries") or []: + rings = [[xy(i) for i in ring] for ring in surface if ring] + if rings: + surfaces.append(rings) + out.append({ + "id": object_id, + "roof_m": (roof - ground) if isinstance(roof, (int, float)) and isinstance(ground, (int, float)) else None, + "h_dak_max": roof, + "h_maaiveld": ground, + "h_nok": attrs.get("b3_h_nok"), + "surfaces": surfaces, + }) + return out + + +def point_in_ring(x, y, ring): + """Ray casting; a point on the edge counts as inside.""" + inside = False + n = len(ring) + for i in range(n): + x1, y1 = ring[i] + x2, y2 = ring[(i + 1) % n] + if (y1 > y) != (y2 > y): + cross = (x2 - x1) * (y - y1) / (y2 - y1) + x1 + if x <= cross: + inside = not inside + return inside + + +def point_in_surface(x, y, rings): + if not rings or not point_in_ring(x, y, rings[0]): + return False + return not any(point_in_ring(x, y, hole) for hole in rings[1:]) + + +def building_at(payload, lat, lon): + """The building whose footprint contains the point, or None.""" + x, y = wgs84_to_rd(lat, lon) + for building in buildings(payload): + if any(point_in_surface(x, y, rings) for rings in building["surfaces"]): + return building + return None + + +def roof_for_point(payload, lat, lon): + """What the page asks for: the roof height above ground of the building + the point stands on, or null when it stands on none.""" + building = building_at(payload, lat, lon) + return { + "roof_m": round(building["roof_m"], 1) if building and building["roof_m"] is not None else None, + "building": building["id"] if building else None, + "buildings": len(buildings(payload)), + } diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index 732746b..900d054 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -1,4 +1,10 @@ [ + { + "id": "2026-09-07-antenna-height-from-the-roof", + "date": "2026-09-07", + "title": "A repeater on a roof gets its real antenna height", + "body": "The line-of-sight check assumed every observer's antenna at 12 m. When a cluster is locked the tool now looks up, in 3D BAG, whether each observer stands on a building and how high its roof is; those get roof plus a 3 m mast, the others keep 12 m. The cluster status says how many, and an observer's popup shows its antenna height. Netherlands only, like the terrain check." + }, { "id": "2026-09-07-fresh-visit", "date": "2026-09-07", diff --git a/web-standalone/index.html b/web-standalone/index.html index f7ac6fb..27384e6 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -1737,8 +1737,45 @@

Unknown Node Estimate

const panelCluster = document.getElementById("panel-cluster"); const panelTerrain = document.getElementById("panel-terrain"); + // Assumed when the node stands on no known building. Half of the fixture's + // observers stand on one (32 of 60 sampled against 3D BAG), with a roof + // median 10.2 m above ground, p90 25 m; for those the roof plus a mast + // replaces this constant (#74). const REPEATER_ANTENNA_HEIGHT_M = 12; + const ROOFTOP_MAST_M = 3; const TARGET_ANTENNA_HEIGHT_M = 1.5; + + // Antenna height above the ground for an observer: its roof plus a mast + // when 3D BAG put it on a building, the flat constant otherwise. + function observerAntennaHeightM(node) { + return Number.isFinite(node.roofHeightM) ? node.roofHeightM + ROOFTOP_MAST_M : REPEATER_ANTENNA_HEIGHT_M; + } + + // One 3D BAG lookup per node, through server.py, which caches by + // position. Sets node.roofHeightM (metres above ground) when the node + // stands inside a building footprint; leaves it unset otherwise or when + // the lookup fails. Counts are returned for the status line. + async function fetchRoofHeights(nodes) { + let onRoof = 0; + let answered = 0; + await Promise.all(nodes.map(async (node) => { + if (!Number.isFinite(node.lat) || !Number.isFinite(node.lon)) return; + try { + const response = await fetch(`/proxy/3dbag/roof?lat=${node.lat.toFixed(5)}&lon=${node.lon.toFixed(5)}`); + if (!response.ok) return; + const data = await response.json(); + answered += 1; + if (Number.isFinite(data.roof_m) && data.roof_m > 0) { + node.roofHeightM = data.roof_m; + node.roofBuilding = data.building; + onRoof += 1; + } + } catch (error) { + // A failed lookup leaves the constant in place; the status line says how many answered. + } + })); + return { onRoof, answered, asked: nodes.length }; + } // Swept against the accuracy fixture (#53, #69): 2 km to 20 km moves median // error 1.9 -> 2.0 km and p90 9.0 -> 9.1 km. This constant is inert. Error // is set by observer geometry, not by assumed coverage size. @@ -2905,6 +2942,9 @@

Unknown Node Estimate

if (moved) { node.moved = true; delete node.provenRadiusKm; + // The roof belonged to the position it left. + delete node.roofHeightM; + delete node.roofBuilding; } else { delete node.moved; } @@ -3361,7 +3401,7 @@

Unknown Node Estimate

fetchAhnSurfaceElevation(to.lat, to.lon), Promise.all(interior.map((point) => fetchAhnLayerValue(point.lat, point.lon, "dsm_05m", surfaceCache))) ]); - const startHeight = fromGround + REPEATER_ANTENNA_HEIGHT_M; + const startHeight = fromGround + observerAntennaHeightM(from); const endHeight = targetGround + TARGET_ANTENNA_HEIGHT_M; const pathM = distanceKm * 1000; let worstLossDb = 0; @@ -4193,6 +4233,13 @@

Unknown Node Estimate

if (runId !== discoveryRunId) { return; } + // Roof heights for the same nodes the link budget covers (#74). The + // cluster copies are what the pools are built from below, so the value + // travels with the node from here on. + const roofs = await fetchRoofHeights(fetchTargets); + if (runId !== discoveryRunId) { + return; + } const supportNodes = Array.from(supportNodeMap.values()).slice(0, 40); const observerPoolMap = new Map(); clusterNodes.forEach((node) => { @@ -4285,7 +4332,11 @@

Unknown Node Estimate

radiusNotes.push(`${observersWithoutReceiveQuorum} observer${observersWithoutReceiveQuorum === 1 ? "" : "s"} had under ${RECEIVE_QUORUM} inbound links, so used all directions`); } const implausibleNote = radiusNotes.length ? ` Coverage radii: ${radiusNotes.join("; ")}.` : ""; - clusterStatus.textContent = `Cluster near ${centroid.lat.toFixed(3)}, ${centroid.lon.toFixed(3)}: ${uniquePrefixCount(dedupedNodes)} observers active.${hullNote}${dedupNote}${linkCapNote}${implausibleNote}`; + // Which observers got a roof height, and which stay on the constant. + const roofNote = roofs.answered + ? ` Antenna heights: ${roofs.onRoof} of ${roofs.answered} observers stand on a building per 3D BAG, the rest assume ${REPEATER_ANTENNA_HEIGHT_M} m.` + : ` Antenna heights: 3D BAG did not answer, all observers assume ${REPEATER_ANTENNA_HEIGHT_M} m.`; + clusterStatus.textContent = `Cluster near ${centroid.lat.toFixed(3)}, ${centroid.lon.toFixed(3)}: ${uniquePrefixCount(dedupedNodes)} observers active.${hullNote}${dedupNote}${linkCapNote}${implausibleNote}${roofNote}`; syncClusterEditorFromLockedRegion(); setDiscoveryCollapsed(true); renderCandidateMap([]); @@ -4677,6 +4728,8 @@

Unknown Node Estimate

lat: n.lat, lon: n.lon, weight: Number.isFinite(n.weight) ? n.weight : 1, provenRadiusKm: Number.isFinite(n.provenRadiusKm) ? n.provenRadiusKm : null, + antennaM: observerAntennaHeightM(n), + onRoof: Number.isFinite(n.roofHeightM) ? 1 : 0, moved: n.moved ? 1 : 0, movedKm: n.moved && positionOverrides.get(nodeId(n)) ? haversineKm(positionOverrides.get(nodeId(n)).upstream, n) : null @@ -5285,10 +5338,14 @@

Unknown Node Estimate

? "" : `
Proven-link radius: ${Number(p.provenRadiusKm).toFixed(1)} km`; const moved = p.moved === 1 || p.moved === "1"; const movedLine = moved ? `
Moved ${Number(p.movedKm).toFixed(1)} km from its advertised position; range from link count.` : ""; + const onRoof = p.onRoof === 1 || p.onRoof === "1"; + const antennaLine = Number.isFinite(Number(p.antennaM)) + ? `
Antenna ${Number(p.antennaM).toFixed(0)} m${onRoof ? " (roof per 3D BAG + mast)" : " (assumed)"}` + : ""; const popup = show3dPopup(e.lngLat, `
- + ${moved ? `` : ""}
`); diff --git a/web-standalone/server.py b/web-standalone/server.py index af9e797..d66d30e 100644 --- a/web-standalone/server.py +++ b/web-standalone/server.py @@ -9,6 +9,7 @@ import threading import time +import bag3d import spamdetector @@ -46,6 +47,13 @@ SPAMDETECTOR_CACHE_SIZE = 20 INCIDENT_ID = re.compile(r"^[0-9]{1,9}$") +# 3D BAG (#74): the building a repeater stands on, for its antenna height. +# One bbox query per node, keyed on the rounded position; buildings do not +# move, so the cache lives a day. +BAG3D_ITEMS_URL = "https://api.3dbag.nl/collections/pand/items?bbox={bbox}&limit=10" +BAG3D_RADIUS_M = 15 +BAG3D_TTL = 86400 + _cache = {} _cache_lock = threading.Lock() @@ -107,8 +115,39 @@ def do_GET(self): if self.path.startswith("/proxy/spamdetector/incident/"): self._spamdetector_incident(self.path[len("/proxy/spamdetector/incident/"):]) return + if self.path.startswith("/proxy/3dbag/roof?"): + self._bag3d_roof(self.path.split("?", 1)[1]) + return super().do_GET() + def _bag3d_roof(self, query): + params = dict(part.split("=", 1) for part in query.split("&") if "=" in part) + try: + lat = float(params.get("lat", "")) + lon = float(params.get("lon", "")) + except ValueError: + self._send_json({"error": "lat and lon must be numbers"}, 400) + return + if not (50.0 <= lat <= 54.0 and 3.0 <= lon <= 7.5): + # Outside the Netherlands there is no 3D BAG; not an error, no roof. + self._send_json({"roof_m": None, "building": None, "buildings": 0, "covered": False}, 200) + return + key = "bag3d:%.5f,%.5f" % (lat, lon) + url = BAG3D_ITEMS_URL.format(bbox=bag3d.bbox_around(lat, lon, BAG3D_RADIUS_M)) + try: + body = _cached(key, BAG3D_TTL, lambda: _fetch(url)) + except UpstreamError as error: + self._send_json({"error": error.message}, error.status) + return + try: + payload = json.loads(body.decode("utf-8", "replace")) + except ValueError: + self._send_json({"error": "3D BAG answered something that is not JSON"}, 502) + return + result = bag3d.roof_for_point(payload, lat, lon) + result["covered"] = True + self._send_json(result, 200) + def _spamdetector_alerts(self): try: body = _cached("alerts", SPAMDETECTOR_ALERTS_TTL, lambda: _fetch(SPAMDETECTOR_ALERTS_URL)) diff --git a/web-standalone/tests/fixtures/bag3d-pand.json b/web-standalone/tests/fixtures/bag3d-pand.json new file mode 100644 index 0000000..0edb591 --- /dev/null +++ b/web-standalone/tests/fixtures/bag3d-pand.json @@ -0,0 +1 @@ +{"type":"FeatureCollection","numberMatched":1,"numberReturned":1,"metadata":{"transform":{"scale":[0.001,0.001,0.001],"translate":[121651.2191875,487466.72225,1.2664794901695586e-06]}},"features":[{"id":"NL.IMBAG.Pand.0363100012178469","type":"CityJSONFeature","vertices":[[81299,39690,0],[76346,45912,0],[69161,48332,0],[68531,61810,0],[58691,64030,0],[52281,64710,0],[50761,64621,0],[50671,65800,0],[49662,66031,0],[49595,66027,0],[49694,64591,0],[39636,63894,0],[39531,65422,0],[38551,65351,0],[38657,63826,0],[37165,63723,0],[37184,63305,0],[34038,63164,0],[34055,62616,0],[31531,63871,0],[31481,64301,0],[31163,64263,0],[31220,63780,0],[28981,62202,0],[28545,62416,0],[28404,62131,0],[28791,61938,0],[28878,60021,0],[27990,59981,0],[27300,59181,0],[24186,61861,0],[24165,61946,0],[8806,58141,0],[8842,57507,0],[8761,57521,0],[7852,57452,0],[7917,56511,0],[6993,56441,0],[7030,55561,0],[5225,55473,0],[5232,55386,0],[3133,53169,0],[3037,53172,0],[2681,49501,0],[6912,49280,0],[6820,50109,0],[8358,50318,0],[8931,38833,0],[1471,38461,0],[102,38442,0],[149,35121,0],[0,35118,0],[13,34269,0],[163,34270,0],[202,31621,0],[52,31618,0],[66,30668,0],[216,30671,0],[261,27570,0],[1631,27590,0],[9471,27982,0],[9898,19444,0],[5895,19246,0],[4959,18047,0],[5070,15811,0],[6121,14711,0],[10123,14909,0],[10502,7313,0],[26546,8463,0],[27060,1280,0],[32932,1702,0],[32809,3386,0],[35312,2499,0],[35267,2013,0],[35711,1971,0],[35751,2390,0],[40790,2680,0],[41013,2482,0],[42040,2540,0],[42121,1350,0],[42640,1096,0],[42946,1109,0],[42955,863,0],[43055,869,0],[43397,100,0],[43401,0,0],[52765,385,0],[52761,485,0],[53038,1277,0],[53137,1281,0],[53128,1526,0],[53916,1558,0],[53862,3108,0],[55392,3162,0],[55357,4151,0],[53827,4096,0],[53372,16996,0],[67354,17490,0],[67601,10465,0],[67882,10480,0],[72191,15811,0],[70601,17604,0],[70327,23436,0],[77363,26564,0],[81700,33179,0],[31531,63871,1133],[34055,62616,1133],[34046,62907,1133],[34350,63178,1133],[37184,63305,1133],[37165,63723,1133],[38657,63826,1133],[38551,65351,1133],[39531,65422,1133],[39636,63894,1133],[49694,64591,1133],[49595,66027,1133],[49662,66031,1133],[50671,65800,1133],[50761,64621,1133],[52281,64710,1133],[58691,64030,1133],[68531,61810,1133],[69161,48332,1133],[76346,45912,1133],[81299,39690,1133],[81700,33179,1133],[77363,26564,1133],[70327,23436,1133],[70601,17604,1133],[72191,15811,1133],[67882,10480,1133],[67601,10465,1133],[67354,17490,1133],[53372,16996,1133],[53827,4096,1133],[55357,4151,1133],[55392,3162,1133],[53862,3108,1133],[53916,1558,1133],[53128,1526,1133],[53137,1281,1133],[53038,1277,1133],[52761,485,1133],[52765,385,1133],[43624,10,1133],[43760,1307,1133],[42335,1246,1133],[42121,1350,1133],[42040,2540,1133],[41013,2482,1133],[40790,2680,1133],[35751,2390,1133],[35711,1971,1133],[35267,2013,1133],[35312,2499,1133],[32809,3386,1133],[32932,1702,1133],[27060,1280,1133],[26546,8463,1133],[10502,7313,1133],[10123,14909,1133],[6121,14711,1133],[5070,15811,1133],[4959,18047,1133],[5895,19246,1133],[9898,19444,1133],[9471,27982,1133],[1631,27590,1133],[261,27570,1133],[216,30671,1133],[66,30668,1133],[52,31618,1133],[202,31621,1133],[163,34270,1133],[13,34269,1133],[0,35118,1133],[149,35121,1133],[102,38442,1133],[1471,38461,1133],[8931,38833,1133],[8358,50318,1133],[6820,50109,1133],[6912,49280,1133],[2681,49501,1133],[3037,53172,1133],[3133,53169,1133],[5232,55386,1133],[5225,55473,1133],[7030,55561,1133],[6993,56441,1133],[7917,56511,1133],[7852,57452,1133],[8761,57521,1133],[8842,57507,1133],[8806,58141,1133],[24165,61946,1133],[24186,61861,1133],[27300,59181,1133],[27990,59981,1133],[28878,60021,1133],[28791,61938,1133],[28404,62131,1133],[28545,62416,1133],[28981,62202,1133],[31220,63780,1133],[31163,64263,1133],[31481,64301,1133],[76346,45912,21774],[69161,48332,21774],[81299,39690,21774],[68531,61810,21774],[58691,64030,21774],[52281,64710,21774],[50761,64621,21774],[50671,65800,21774],[49662,66031,21774],[49595,66027,21774],[49694,64591,21774],[39636,63894,21774],[38657,63826,21774],[37165,63723,21774],[37184,63305,21774],[39531,65422,21774],[38551,65351,21774],[31220,63780,21774],[28981,62202,21774],[34055,62616,21774],[31531,63871,21774],[31481,64301,21774],[31163,64263,21774],[28545,62416,21774],[28404,62131,21774],[28791,61938,21774],[28878,60021,21774],[27990,59981,21774],[27300,59181,21774],[2681,49501,21774],[6912,49280,21774],[6820,50109,21774],[24186,61861,21774],[24165,61946,21774],[8806,58141,21774],[8842,57507,21774],[8761,57521,21774],[7852,57452,21774],[7917,56511,21774],[6993,56441,21774],[7030,55561,21774],[8358,50318,21774],[5225,55473,21774],[5232,55386,21774],[3133,53169,21774],[3037,53172,21774],[8931,38833,21774],[1471,38461,21774],[13,34269,21774],[163,34270,21774],[102,38442,21774],[149,35121,21774],[0,35118,21774],[66,30668,21774],[216,30671,21774],[202,31621,21774],[1631,27590,21774],[9471,27982,21774],[52,31618,21774],[261,27570,21774],[9898,19444,21774],[5895,19246,21774],[4959,18047,21774],[5070,15811,21774],[6121,14711,21774],[10123,14909,21774],[10502,7313,21774],[26546,8463,21774],[27060,1280,21774],[32932,1702,21774],[32809,3386,21774],[35312,2499,21774],[35751,2390,21774],[40790,2680,21774],[35267,2013,21774],[35711,1971,21774],[41013,2482,21774],[42040,2540,21774],[42121,1350,21774],[42335,1246,21774],[53862,3108,21774],[55392,3162,21774],[53916,1558,21774],[52765,385,21774],[52761,485,21774],[53038,1277,21774],[53137,1281,21774],[53128,1526,21774],[55357,4151,21774],[53827,4096,21774],[53372,16996,21774],[67354,17490,21774],[67601,10465,21774],[67882,10480,21774],[72191,15811,21774],[70601,17604,21774],[70327,23436,21774],[77363,26564,21774],[81700,33179,21774],[34350,63178,21774],[34046,62907,21774],[43624,10,21774],[43760,1307,21774],[1455,27588,1133],[322,27571,1133],[86,31619,1133],[180,33076,1133],[129,36509,1133],[204,38443,1133],[3961,38586,1133],[5595,38667,1133],[5947,38685,1133],[6359,38705,1133],[8627,44938,1133],[8417,49143,1133],[7002,56228,1133],[7114,56450,1133],[28707,61979,1133],[28741,62320,1133],[33368,62957,1133],[33638,62823,1133],[38616,63824,1133],[43712,64177,1133],[44987,64265,1133],[53433,64588,1133],[68550,61399,1133],[70417,47909,1133],[70740,23619,1133],[70584,17969,1133],[67405,16037,1133],[53910,1744,1133],[40379,2657,1133],[26585,7927,1133],[6106,14725,1133],[9885,19444,1133],[9894,19512,1133],[9563,26149,1133],[8233,27920,1133],[2944,27656,1133],[38618,63694,21110],[38618,63694,13449],[38967,63668,13449],[38967,63668,21110],[38616,63824,21110],[38616,63824,13449],[76346,45912,16552],[70417,47909,16552],[70437,47636,21110],[70437,47636,16552],[70417,47909,21110],[81299,39690,16552],[68550,61399,21110],[68550,61399,4535],[60982,61156,4535],[60982,61156,21110],[68531,61810,4535],[58691,64030,4535],[52281,64710,21110],[50761,64621,21110],[39169,58365,13449],[39169,58365,7211],[39490,58733,7211],[39490,58733,13449],[39490,58733,21110],[39460,57655,7211],[39460,57655,21110],[50671,65800,21110],[49662,66031,21110],[49595,66027,21110],[39531,65422,21110],[38551,65351,21110],[39636,63894,21110],[28791,61938,7211],[28878,60021,7211],[31481,64301,7211],[31163,64263,7211],[28741,62320,7211],[28741,62320,1516],[28707,61979,1516],[28707,61979,7211],[31220,63780,7211],[28404,62131,1516],[27990,59981,7211],[27300,59181,7211],[2681,49501,10301],[6912,49280,10301],[6820,50109,10301],[24186,61861,7211],[24165,61946,7211],[8806,58141,7211],[8842,57507,7211],[8761,57521,7211],[7852,57452,7211],[7917,56511,7211],[5232,55386,10301],[3133,53169,10301],[8358,50318,10301],[5225,55473,10301],[3037,53172,10301],[216,30671,9268],[261,27570,9268],[66,30668,9268],[0,35118,9319],[13,34269,9319],[1631,27590,9275],[2944,27656,9275],[202,31621,37034],[86,31619,9268],[86,31619,37034],[10285,36104,37230],[10285,36104,25796],[10821,35584,25796],[10821,35584,37230],[26585,7927,21090],[26585,7927,9070],[27203,7343,9070],[27203,7343,21090],[4959,18047,15729],[5070,15811,15729],[27060,1280,9070],[32932,1702,9070],[32809,3386,9070],[35711,1971,9070],[35751,2390,9070],[35312,2499,9070],[35267,2013,9070],[32711,8346,21090],[32711,8346,9070],[33023,8055,9070],[33023,8055,21090],[40790,2680,21090],[41013,2482,21090],[53433,64588,21110],[53433,64588,4535],[51941,60453,21110],[51941,60453,4535],[42040,2540,21090],[42121,1350,21090],[42335,1246,21090],[53916,1558,9306],[53910,1744,9306],[52765,385,9306],[52761,485,9306],[48568,3972,21090],[48568,3972,9306],[52008,1662,9306],[52008,1662,21090],[53038,1277,9306],[53137,1281,9306],[53128,1526,9306],[55357,4151,21090],[53827,4096,21090],[53862,3108,21090],[55392,3162,21090],[53372,16996,21090],[67354,17490,21090],[67405,16037,4603],[67405,16037,21090],[72191,15811,4603],[70601,17604,4603],[67601,10465,4603],[67882,10480,4603],[70051,17925,4603],[70051,17925,21090],[70584,17969,4603],[70584,17969,21090],[70644,35967,25796],[70644,35967,21090],[69635,38362,21090],[69635,38362,25796],[81700,33179,16552],[77363,26564,16552],[10123,14909,21090],[10502,7313,21090],[6106,14725,21090],[6106,14725,15729],[8981,16669,21090],[8981,16669,15729],[6703,16487,15729],[6703,16487,21090],[1832,37330,37006],[1832,37330,9456],[846,36981,9456],[846,36981,37006],[920,36871,9456],[920,36871,37006],[6359,38705,35559],[6359,38705,7648],[5947,38685,35559],[8620,52538,21110],[8620,52538,10301],[8123,51694,10301],[8123,51694,21110],[8651,49115,10301],[8651,49115,21110],[102,38442,9456],[129,36509,9319],[129,36509,9456],[7929,36722,45078],[7929,36722,37230],[7870,36889,37230],[7870,36889,45078],[72765,28211,21090],[72765,28211,16552],[72971,28202,16552],[72971,28202,21090],[62284,29709,25796],[62284,29709,21090],[62132,29415,21090],[62132,29415,25796],[56727,30256,25796],[56727,30256,21090],[26112,37652,25796],[26112,37652,15114],[25364,39280,15114],[25364,39280,21110],[25364,39280,25796],[13437,32513,36631],[13437,32513,25796],[11596,32411,25796],[11596,32411,36631],[33759,33933,25796],[33759,33933,15798],[30527,39580,15798],[30527,39580,21110],[30527,39580,25796],[26546,8463,21090],[70327,23436,21090],[70740,23619,16552],[70740,23619,21090],[27199,39305,21110],[27199,39305,25796],[8627,44938,21110],[8627,44938,7648],[9263,44959,7648],[9263,44959,21110],[41889,39908,25796],[41889,39908,21110],[42421,38088,21110],[42421,38088,25796],[1471,38461,37006],[204,38443,9456],[204,38443,37006],[1913,37500,9456],[1913,37500,37006],[67027,41720,25796],[67027,41720,21110],[67027,41720,15256],[66275,40433,15256],[66275,40433,25796],[69130,41664,25796],[69130,41664,21110],[72502,30552,25796],[72502,30552,21090],[70575,33982,21090],[70575,33982,25796],[60653,41323,25796],[60653,41323,21110],[60117,40549,21110],[60117,40549,25796],[70207,41756,25796],[70207,41756,21110],[69179,41710,21110],[69179,41710,25796],[62852,41423,25796],[62852,41423,21110],[50887,60371,21110],[50887,60371,14456],[51737,51845,14456],[51737,51845,21110],[69161,48332,21110],[7114,56450,21110],[7114,56450,7211],[6993,56441,21110],[7002,56228,10301],[7002,56228,21110],[53910,1744,21090],[52970,60533,21110],[52970,60533,4535],[5947,38685,45078],[5595,38667,9250],[5595,38667,37006],[5595,38667,45078],[12366,25873,21090],[12366,25873,15055],[12691,26280,15055],[12691,26280,21090],[9563,26149,36631],[9563,26149,21090],[12691,26280,36631],[17158,26468,25796],[17158,26468,21090],[25836,26834,21090],[25836,26834,25796],[16222,27988,25796],[16222,27988,15055],[17158,26468,15055],[17397,26079,15055],[17397,26079,21090],[9885,19444,21090],[9885,19444,15729],[9662,17709,15729],[9662,17709,21090],[35486,38342,25796],[35486,38342,15798],[34076,32121,25796],[34076,32121,21090],[39501,29223,21090],[39501,29223,25796],[32343,39730,15798],[32343,39730,21110],[40199,38533,25796],[40199,38533,21110],[40199,38533,15798],[40168,38448,15798],[40168,38448,25796],[69677,29948,25796],[69677,29948,21090],[70025,29559,21090],[70025,29559,25796],[70442,28314,16552],[70442,28314,21090],[5895,19246,15729],[2387,28429,37034],[2387,28429,35549],[2387,28429,9275],[2944,27656,37034],[2018,32697,45078],[2018,32697,37034],[3337,30039,37034],[3337,30039,45078],[7030,55561,10301],[7281,55591,10301],[7281,55591,21110],[13788,39021,25796],[13788,39021,21110],[13586,37029,21110],[13586,37029,25796],[41680,16589,21090],[41680,16589,14747],[42549,16705,14747],[42549,16705,21090],[27150,8750,9070],[27150,8750,21090],[31995,9012,9070],[31995,9012,21090],[51223,40208,25796],[51223,40208,21110],[50651,40871,21110],[50651,40871,25796],[58204,40492,21110],[58204,40492,25511],[58204,40492,25796],[57689,41189,21110],[57689,41189,25511],[51969,40930,25796],[51969,40930,21110],[52109,40091,21110],[52109,40091,25796],[37184,63305,13449],[37184,63305,7211],[37931,61444,7211],[37931,61444,13449],[31531,63871,7211],[33368,62957,7211],[33368,62957,1487],[34350,63178,7211],[34046,62907,7211],[34055,62616,7211],[25806,39314,21110],[25806,39314,15114],[26468,39244,15114],[26468,39244,21110],[27199,39305,15114],[10212,36710,25796],[10212,36710,37230],[1563,35916,45078],[1563,35916,37006],[2220,34940,37006],[2220,34940,45078],[63414,42074,21110],[63414,42074,15256],[67312,42209,15256],[67312,42209,21110],[58005,40486,25796],[58005,40486,25511],[56508,38482,25511],[56508,38482,25796],[32409,39824,15798],[32409,39824,21110],[43760,1307,9306],[43624,10,9306],[43760,1307,21090],[48185,1498,9306],[48185,1498,21090],[48079,3908,9306],[48079,3908,21090],[40545,39471,21110],[40545,39471,15798],[36608,40585,21110],[36608,40585,15798],[39306,40819,15798],[39306,40819,21110],[41202,40244,21110],[41202,40244,15798],[51349,40096,25796],[51349,40096,21110],[50651,60353,21110],[50651,60353,14456],[42413,6548,21090],[42413,6548,9070],[42225,6276,9070],[42225,6276,21090],[47293,34916,34962],[47293,34916,25796],[45496,34744,25796],[45496,34744,34962],[19529,39972,25796],[19529,39972,21110],[17201,40216,21110],[17201,40216,25796],[30026,58377,21110],[30026,58377,7211],[29519,57394,7211],[29519,57394,21110],[9288,38680,7648],[9288,38680,21110],[28981,62202,7211],[28545,62416,1516],[25325,39364,25796],[25325,39364,21110],[21887,39725,21110],[21887,39725,25796],[3961,38586,9250],[3868,38049,9250],[3868,38049,37006],[47540,40090,25796],[47540,40090,21110],[43351,40483,21110],[43351,40483,25796],[50572,59062,21110],[50572,59062,14456],[50784,51855,21110],[50784,51855,14456],[49694,64591,21110],[44987,64265,5150],[44987,64265,21110],[29552,57084,7211],[29552,57084,21110],[28491,56946,7211],[28491,56946,21110],[29684,55816,21110],[29684,55816,14047],[30229,50620,14047],[30229,50620,21110],[29438,50604,14047],[29438,50604,21110],[29184,55890,21110],[29184,55890,14047],[32184,8333,21090],[32184,8333,9070],[32154,8864,21090],[32154,8864,9070],[3411,35403,57215],[3411,35403,45078],[3134,33794,45078],[3134,33794,57215],[4120,35244,57215],[4120,35244,45078],[3961,38586,37006],[4758,36259,57215],[4758,36259,45078],[10660,33536,45078],[10660,33536,25796],[9731,35007,25796],[9731,35007,37230],[9731,35007,45078],[9301,35688,37230],[9301,35688,45078],[8417,49143,21110],[8417,49143,10301],[7037,37891,45078],[7037,37891,37230],[7037,37891,35559],[6601,37624,35559],[6601,37624,45078],[49064,28491,25796],[49064,28491,21090],[51495,28663,21090],[51495,28663,25796],[49039,28835,25796],[49039,28835,21090],[37165,63723,13449],[34870,40014,15798],[34870,40014,21110],[40156,57764,21110],[40156,57764,14279],[40759,51179,14279],[40759,51179,21110],[39583,57674,21110],[39583,57674,14279],[39984,51165,21110],[39984,51165,14279],[55605,41095,25796],[55605,41095,25511],[55605,41095,21110],[48117,34020,34962],[48117,34020,25796],[5833,33397,62022],[5833,33397,57215],[5680,33580,57215],[5680,33580,62022],[5674,33949,57215],[5674,33949,62022],[8738,37606,37230],[8738,37606,7648],[7899,38419,7648],[7899,38419,37230],[10472,37002,25796],[10472,37002,37230],[10472,37002,21110],[9579,38269,21110],[9579,38269,37230],[53288,60558,4535],[53288,60558,21110],[9579,38269,7648],[53401,59694,4535],[53401,59694,21110],[42226,9564,21090],[42226,9564,14747],[42644,9586,21090],[42644,9586,14747],[41490,27493,25796],[41490,27493,21090],[42868,27553,21090],[42868,27553,25796],[42226,9564,9070],[42421,7047,9070],[42421,7047,21090],[38305,9352,21090],[38305,9352,9070],[37471,9564,21090],[37471,9564,9070],[38294,9452,9070],[38294,9452,21090],[41615,5768,9070],[41615,5768,21090],[35175,9183,21090],[35175,9183,9070],[37481,9308,9070],[37481,9308,21090],[40379,2657,9070],[40315,4686,21090],[40315,4686,9070],[40379,2657,21090],[62852,41423,15256],[64504,41498,25796],[64504,41498,15256],[33648,57188,21110],[33648,57188,7211],[33136,57548,7211],[33136,57548,21110],[34475,57451,21110],[34475,57451,7211],[10780,37347,25796],[10780,37347,21110],[15484,41754,21110],[15484,41754,25796],[15642,28930,36631],[15642,28930,25796],[21054,37936,25796],[21054,37936,21110],[47664,33353,34962],[47664,33353,25796],[47604,33409,34962],[47604,33409,25796],[46342,33269,34962],[46342,33269,25796],[51302,29945,25796],[51302,29945,21090],[52601,29999,21090],[52601,29999,25796],[5540,33380,57215],[5540,33380,62022],[35000,39824,21110],[35000,39824,15798],[39665,40511,15798],[39665,40511,21110],[41025,40775,15798],[41025,40775,21110],[14636,41716,21110],[14636,41716,25796],[45011,63893,21110],[45011,63893,5150],[43712,64177,5150],[43712,64177,21110],[73739,30485,16552],[73739,30485,21090],[75464,34064,21090],[75464,34064,16552],[75740,34458,16552],[75740,34458,21090],[75419,34525,16552],[75419,34525,21090],[75365,34660,21090],[75365,34660,16552],[75287,37649,16552],[75287,37649,21090],[40778,29241,21090],[40778,29241,25796],[41176,28269,21090],[41176,28269,25796],[43153,28042,25796],[43153,28042,21090],[43343,27651,21090],[43343,27651,25796],[40971,40628,15798],[40971,40628,21110],[9386,17625,15729],[9386,17625,21090],[9894,19512,21090],[74513,32785,21090],[74513,32785,16552],[75403,33998,16552],[75403,33998,21090],[8931,38833,7648],[8000,36733,45078],[8000,36733,37230],[9078,35437,45078],[9078,35437,37230],[8109,34351,53806],[8109,34351,45078],[8412,34690,45078],[8412,34690,53806],[31235,57302,21110],[31235,57302,7211],[33388,62757,7211],[33388,62757,1487],[33638,62823,1487],[5467,36363,57215],[5467,36363,45078],[12975,40759,25796],[12975,40759,21110],[48559,40123,25796],[48559,40123,21110],[48589,40777,25796],[48589,40777,21110],[52626,29142,25796],[52626,29142,21090],[53057,29259,21090],[53057,29259,25796],[73917,31013,21090],[73917,31013,16552],[39266,62816,21110],[39266,62816,13449],[39498,59048,13449],[39498,59048,21110],[8438,55926,10301],[8438,55926,21110],[8761,53566,10301],[8761,53566,21110],[5618,37270,45078],[5618,37270,37006],[28458,56693,7211],[28458,56693,21110],[34562,56890,7211],[34562,56890,21110],[14209,28179,36631],[14209,28179,25796],[14041,28653,25796],[14041,28653,36631],[70335,41762,25796],[70335,41762,16552],[70207,41756,16552],[5936,35055,57215],[5936,35055,53806],[5936,35055,45078],[71210,28280,16552],[71210,28280,21090],[73713,31038,21090],[73713,31038,16552],[73370,30900,16552],[73370,30900,21090],[65348,40705,25796],[65348,40705,15256],[65361,40949,15256],[65361,40949,25796],[65723,40717,25796],[65723,40717,15256],[66126,40459,25796],[66126,40459,15256],[129,36509,37006],[1211,34322,37006],[1211,34322,45078],[7037,37891,7648],[14209,28179,15055],[14849,26371,15055],[14849,26371,25796],[5730,28862,45078],[5730,28862,37034],[6394,29333,37034],[6394,29333,45078],[9471,27982,36631],[8233,27920,36631],[6635,29505,57215],[6635,29505,45078],[6635,29505,36631],[7127,29854,36631],[7127,29854,45078],[7127,29854,57215],[9033,33625,57215],[9033,33625,45078],[8982,33766,45078],[8982,33766,57215],[11272,32800,36631],[11272,32800,45078],[11272,32800,25796],[1211,34322,9319],[149,35121,9319],[43067,39946,25796],[43067,39946,21110],[42049,4093,9070],[42049,4093,21090],[33661,62678,7211],[33661,62678,1487],[34176,8267,9070],[34176,8267,21090],[34139,8380,9070],[34139,8380,21090],[70099,41117,21090],[70099,41117,25796],[6036,35477,53806],[6036,35477,45078],[6021,34819,57215],[6021,34819,53806],[7308,36479,53806],[7308,36479,45078],[71277,41591,21090],[71277,41591,16552],[70283,41621,16552],[70283,41621,21090],[70283,41621,25796],[74938,38612,16552],[74938,38612,21090],[5109,30884,57215],[5109,30884,45078],[5396,31023,45078],[5396,31023,57215],[8233,27920,37034],[40400,4757,21090],[40400,4757,9070],[70353,42622,16552],[70353,42622,21110],[70785,42958,16552],[70785,42958,21110],[70510,46649,16552],[70510,46649,21110],[69000,47560,16552],[69000,47560,21110],[71749,29016,21090],[71749,29016,16552],[45692,34602,34962],[45692,34602,25796],[45086,34547,25796],[45086,34547,34962],[1069,30257,9268],[1069,30257,37034],[53666,28816,21090],[53666,28816,25796],[56625,29025,21090],[56625,29025,25796],[56546,30232,21090],[56546,30232,25796],[73823,39459,16552],[73823,39459,21090],[8258,53362,10301],[8258,53362,21110],[33638,62823,7211],[163,34270,9319],[180,33076,9319],[8109,34351,57215],[73451,40517,21090],[73451,40517,16552],[27552,30992,25796],[27552,30992,21090],[1432,33147,37034],[1432,33147,9319],[180,33076,37034],[483,33876,45078],[483,33876,9319],[1432,33147,45078],[6394,29333,36631],[8703,34218,45078],[8703,34218,57215],[322,27571,9268],[859,29915,9268],[859,29915,37034],[1522,29628,37034],[1522,29628,9268],[1750,29312,9268],[1750,29312,37034],[14907,26374,25796],[14907,26374,15055],[15846,27767,15055],[15846,27767,25796],[1664,29208,9268],[1664,29208,35549],[1664,29208,37034],[322,27571,35549],[1455,27588,9275],[1455,27588,35549],[1783,29266,37034],[1783,29266,35549],[5340,31092,57215],[5340,31092,45078],[34883,8397,9070],[34883,8397,21090],[35444,8501,21090],[35444,8501,9070],[38650,61237,7211],[38650,61237,13449],[38671,59603,13449],[38671,59603,7211],[68601,60295,1133],[68808,55883,1133],[68926,53371,1133],[72932,47061,1133],[76448,45782,1133],[78527,43171,1133],[80612,40552,1133],[81053,39998,1133],[81494,36518,1133],[80931,32007,1133],[79347,29592,1133],[77493,26763,1133],[74321,25212,1133],[53387,16563,1133],[38982,2576,1133],[33172,3257,1133],[32913,1961,1133],[27636,1322,1133],[21431,8096,1133],[16178,7720,1133],[10161,14159,1133],[5007,17099,1133],[6119,19257,1133],[9839,20616,1133],[8921,39045,1133],[3476,49459,1133],[4200,54296,1133],[25702,60555,1133],[28853,60564,1133],[29139,62313,1133],[31129,63716,1133],[33968,62659,1133],[36033,63254,1133],[38021,58311,7279],[38021,58311,6431],[38136,57598,7155],[38159,57452,7303],[38159,57452,7129],[80612,40552,13998],[80612,40552,13931],[80383,40496,14005],[75925,39405,15452],[75925,39405,14138],[78527,43171,19136],[76448,45782,12993],[76448,45782,13787],[68531,61810,4546],[58691,64030,4513],[61219,61163,17244],[61219,61163,4556],[60982,61156,4557],[60982,61156,17221],[52281,64710,10496],[50761,64621,13238],[39169,58365,7851],[39169,58365,5353],[39490,58733,4781],[39490,58733,8692],[39490,58733,13181],[39460,57655,4934],[39460,57655,12952],[45972,46697,23361],[45972,46697,23662],[46053,46703,23514],[49694,64591,15157],[44987,64265,4965],[44987,64265,23653],[45011,63893,5425],[45011,63893,23647],[50671,65800,13278],[49662,66031,15071],[49595,66027,15190],[39531,65422,14342],[38551,65351,12497],[39636,63894,14293],[38618,63694,13910],[38618,63694,12355],[38657,63826,12450],[38657,63826,14123],[28791,61938,6128],[28853,60564,6115],[31481,64301,5624],[31163,64263,5782],[34350,63178,7428],[34046,62907,6885],[31531,63871,6165],[28741,62320,6084],[28741,62320,1423],[28707,61979,1759],[28707,61979,6012],[28878,60021,8108],[27990,59981,8295],[24165,61946,4857],[8806,58141,5143],[8842,57507,5153],[8761,57521,5154],[7852,57452,5168],[7917,56511,5182],[6820,50109,9044],[8358,50318,9501],[3037,53172,6856],[2681,49501,7380],[4200,54296,8335],[3133,53169,7006],[0,35118,8634],[13,34269,8634],[86,31619,8675],[66,30668,8631],[27552,30992,24270],[27552,30992,22675],[29053,31252,23166],[29053,31252,24519],[29053,31252,24573],[5070,15811,13268],[6106,14725,15099],[28495,33276,27933],[28495,33276,27648],[33409,33662,27774],[35312,2499,5054],[35267,2013,4234],[35711,1971,4271],[38305,9352,22225],[38305,9352,6672],[42226,9564,6752],[42226,9564,13810],[42226,9564,14519],[40790,2680,10854],[41013,2482,11232],[53433,64588,8437],[53433,64588,4505],[51941,60453,11538],[51941,60453,4568],[42040,2540,13036],[42121,1350,13100],[42335,1246,13469],[53916,1558,9473],[53910,1744,9823],[52765,385,7371],[52761,485,7560],[53038,1277,9027],[53137,1281,9025],[53128,1526,9487],[68720,47545,23677],[68720,47545,23031],[69000,47560,23683],[55357,4151,11543],[53827,4096,14100],[41490,27493,15302],[41490,27493,15763],[28331,26939,14804],[25836,26834,14622],[25836,26834,14710],[53862,3108,14128],[55392,3162,11571],[65478,29651,27193],[65478,29651,14443],[69677,29948,14418],[69677,29948,18748],[72191,15811,4506],[70601,17604,4630],[53387,16563,13748],[53372,16996,14367],[67354,17490,13537],[81700,33179,14376],[81700,33179,14323],[76036,34397,14405],[70051,17925,13862],[70051,17925,4662],[70584,17969,4644],[70584,17969,13866],[76346,45912,13250],[75108,43587,13115],[75108,43587,13927],[80931,32007,14452],[81494,36518,19197],[68741,47392,23937],[68741,47392,22796],[10123,14909,15205],[1832,37330,35872],[1832,37330,11049],[846,36981,9765],[846,36981,35290],[920,36871,9859],[920,36871,35454],[163,34270,8826],[180,33076,8826],[25998,45107,23423],[25998,45107,23354],[27340,45214,23390],[28278,45289,23415],[28278,45289,23367],[70740,23619,21910],[70740,23619,12942],[70663,24822,13171],[70663,24822,22409],[70663,24822,23635],[74321,25212,18956],[57810,48473,20924],[57810,48473,20755],[57702,48464,20926],[33219,21501,23676],[33219,21501,23643],[35033,21613,23609],[35033,21613,23690],[46436,35644,25819],[46436,35644,27397],[45329,35650,25707],[28465,33203,28047],[28465,33203,27541],[8412,34690,45142],[8412,34690,43873],[9078,35437,43882],[9078,35437,44968],[77363,26564,13639],[77493,26763,13260],[29552,57084,13961],[29552,57084,13008],[29684,55816,12874],[29684,55816,13988],[29184,55890,13947],[40168,38448,20750],[40168,38448,15926],[35486,38342,15710],[35486,38342,16518],[35486,38342,20484],[8627,44938,7209],[8921,39045,7347],[47278,22036,22715],[47278,22036,22228],[47258,22494,22885],[47258,22494,23458],[33759,33933,27374],[33759,33933,20217],[30527,39580,15410],[30527,39580,18041],[38481,33869,27916],[38481,33869,27678],[38511,33950,27788],[16217,45191,22109],[16217,45191,22131],[16937,46149,20508],[19977,50192,13659],[19977,50192,13751],[15587,45170,23357],[15587,45170,22094],[16206,45190,22130],[13437,32513,27749],[13437,32513,27000],[13671,32132,26470],[13671,32132,26663],[13671,32132,28380],[14064,32690,27462],[14064,32690,27525],[28162,33191,28038],[28162,33191,27551],[28162,33191,27467],[28706,32007,25701],[16409,20334,23746],[16409,20334,23339],[24335,20889,23506],[24335,20889,23706],[70327,23436,21694],[8627,44938,22041],[9263,44959,7536],[9263,44959,22057],[48419,34464,27289],[48419,34464,27054],[56124,34969,26742],[56124,34969,27034],[15667,44478,23356],[15667,44478,23336],[15627,44800,22758],[15627,44800,22824],[15627,44800,23347],[15309,44789,22817],[26112,37652,20713],[26112,37652,15135],[25364,39280,15104],[25364,39280,18040],[39665,40511,15628],[39665,40511,15067],[39306,40819,15570],[25806,39314,15120],[25806,39314,14553],[25364,39280,14543],[30527,39580,14512],[27199,39305,14407],[27199,39305,15168],[27199,39305,18171],[37294,45802,23507],[37294,45802,23345],[38671,45939,23424],[9563,26149,14417],[9839,20616,23426],[33148,3721,6565],[33148,3721,6558],[33148,3721,6124],[33161,3465,6142],[33172,3257,6157],[33172,3257,5799],[9839,20616,23754],[9894,19512,22099],[56268,46706,23713],[56268,46706,22929],[65493,34630,26120],[65493,34630,27745],[65165,33769,26955],[26700,20960,23537],[26700,20960,23488],[27483,20170,22130],[26600,20964,23822],[26600,20964,23670],[26700,20960,23839],[73713,31038,17939],[73713,31038,14183],[73917,31013,14394],[73917,31013,17697],[58204,40492,14993],[58204,40492,13286],[57689,41189,14397],[60117,40549,19734],[60117,40549,13193],[58204,40492,23333],[72932,47061,19171],[70417,47909,15213],[8931,38833,8246],[6359,38705,8488],[39583,57674,14223],[39583,57674,13184],[39460,57655,14436],[56048,46955,23271],[56048,46955,23330],[56063,46938,23302],[53288,60558,16919],[53288,60558,4566],[53401,59694,4579],[53401,59694,18096],[61963,58873,19531],[61963,58873,20204],[63161,59589,19424],[68601,60295,19269],[68601,60295,18583],[68550,61399,4552],[68550,61399,19127],[7114,56450,11623],[7114,56450,5195],[6993,56441,11420],[7002,56228,6705],[7002,56228,11391],[31377,45438,23463],[31377,45438,23350],[53910,1744,14166],[52970,60533,18716],[52970,60533,4566],[51941,60453,16446],[5887,34848,56970],[5887,34848,56042],[5658,34900,55712],[5658,34900,56971],[6119,19257,12980],[6119,19257,12930],[5895,19246,12694],[9563,26149,37456],[12691,26280,14421],[12691,26280,14863],[12691,26280,29680],[15652,20841,22887],[15652,20841,23845],[15953,20540,23382],[25836,26834,18253],[17158,26468,14381],[17158,26468,15041],[17397,26079,15005],[16401,19935,22697],[16401,19935,22906],[16514,19945,22703],[16401,19935,24122],[16409,20334,24127],[29206,45333,23396],[35113,45833,23197],[35113,45833,23224],[30229,50620,14095],[35113,45833,23236],[34232,56696,23528],[43153,28042,21963],[43153,28042,17054],[43343,27651,17331],[43343,27651,22183],[44419,27875,18961],[44419,27875,19151],[44419,27875,23695],[9267,44078,23503],[9267,44078,23368],[14993,44436,23371],[15667,44478,23372],[27636,1322,6444],[32932,1702,6469],[32913,1961,6900],[32913,1961,6939],[30113,4589,12030],[27060,1280,6269],[27636,1322,7301],[7030,55561,7573],[5225,55473,7135],[21157,32913,27828],[21157,32913,27423],[27150,8750,23766],[27150,8750,6692],[31995,9012,7114],[31995,9012,14899],[28853,60564,8117],[27489,1658,7069],[60653,41323,18770],[60653,41323,14322],[60117,40549,19775],[56271,34990,27014],[56271,34990,26982],[56229,35042,26908],[56229,35042,26941],[56224,34951,27065],[56224,34951,26961],[10502,7313,14764],[16178,7720,23764],[16178,7720,23987],[32343,39730,15306],[32343,39730,14569],[32409,39824,15227],[32409,39824,14706],[47258,22494,23588],[43467,22229,23608],[47286,21857,23464],[47286,21857,21972],[47278,22036,23462],[46870,22016,22746],[43760,1307,9961],[43624,10,7530],[43760,1307,15970],[48185,1498,9900],[48185,1498,23737],[129,36509,8828],[129,36509,35060],[19716,56594,13084],[19716,56594,5010],[14368,56533,5088],[14368,56533,23666],[10161,14159,14081],[7281,55591,7611],[7281,55591,11724],[36874,21636,23843],[36874,21636,23657],[36933,21641,23655],[36933,21641,23842],[36874,21636,23424],[36910,21306,22872],[36910,21306,23119],[36985,21704,23750],[32139,8878,14017],[32139,8878,7289],[32184,8333,7920],[32184,8333,14084],[50634,40870,19695],[50634,40870,19656],[50634,40870,14614],[48704,40782,18811],[48704,40782,23157],[50634,40870,14577],[49301,42258,16817],[49301,42258,17217],[51223,40208,19820],[51223,40208,19691],[51349,40096,19849],[56508,38482,26525],[56508,38482,27810],[56175,38035,27153],[56043,37859,26892],[56043,37859,27401],[56192,35593,27124],[56192,35593,26931],[56192,35593,26196],[56192,35593,26162],[56682,35590,26203],[56686,35590,26204],[56686,35590,26195],[55605,41095,26424],[55605,41095,14450],[51969,40930,14543],[51969,40930,19729],[56224,34951,26923],[25035,45485,22638],[8322,50723,12519],[8322,50723,12451],[8322,50723,10230],[8651,49115,7349],[8651,49115,15186],[20134,50402,13987],[20134,50402,13402],[19998,50221,13704],[50913,40556,19205],[50913,40556,19733],[50653,40848,19661],[24186,61861,4858],[63503,35407,26851],[63503,35407,26979],[57722,35074,26981],[56273,34374,26953],[56273,34374,26121],[56226,34926,26925],[56723,34424,26146],[62132,29415,20014],[62132,29415,14464],[62284,29709,20369],[62284,29709,14026],[35228,21631,23614],[35228,21631,23683],[3476,49459,7699],[3476,49459,8627],[60887,41334,18774],[60887,41334,14316],[46018,46409,23608],[46018,46409,23445],[46113,46346,23341],[46113,46346,23443],[45972,46697,22927],[51495,28663,19781],[51495,28663,18739],[51495,28663,14530],[51932,28694,14527],[51932,28694,19748],[47664,33353,35275],[47664,33353,25488],[48117,34020,26428],[48117,34020,27870],[48117,34020,35261],[51523,28477,18745],[51523,28477,14817],[62852,41423,22573],[62852,41423,15151],[62852,41423,14266],[32809,3386,6858],[61595,29777,18902],[61595,29777,13851],[33023,8055,15642],[33023,8055,6919],[34176,8267,6694],[34176,8267,17805],[34139,8380,6541],[34139,8380,17740],[32184,8333,5771],[32711,8346,6515],[32711,8346,15069],[10285,36104,36610],[10285,36104,21713],[10821,35584,22594],[10821,35584,36626],[9731,35007,38059],[9731,35007,23416],[10660,33536,43895],[10660,33536,25855],[9731,35007,43888],[36033,63254,10130],[25702,60555,8784],[25702,60555,4857],[28390,56933,4878],[28390,56933,8187],[45692,34602,34777],[45692,34602,27417],[45086,34547,27448],[45086,34547,34683],[50308,26763,18878],[50308,26763,17331],[28404,62131,1575],[28981,62202,6413],[28545,62416,1305],[26546,8463,22994],[26585,7927,6039],[26585,7927,23034],[47293,34916,26618],[47293,34916,35003],[27150,8750,24035],[27135,9141,23757],[27135,9141,24028],[27053,9272,23895],[27203,7343,7088],[27203,7343,8313],[27203,7343,24059],[29840,4848,11567],[30113,4589,11903],[30233,4723,11768],[2018,32697,43796],[2018,32697,38810],[3337,30039,37749],[3337,30039,43805],[56546,30232,27025],[56546,30232,20944],[56727,30256,20988],[56727,30256,26700],[16572,44523,23363],[16572,44523,23354],[17547,44588,23361],[86,31619,36245],[1069,30257,9920],[1069,30257,35966],[25325,39364,14675],[25325,39364,17901],[26468,39244,15143],[26468,39244,14385],[20267,50990,14319],[20267,50990,13031],[19969,50571,13696],[24705,56650,23529],[24705,56650,4936],[19716,56594,14032],[5595,38667,8559],[3961,38586,8713],[5595,38667,38083],[3868,38049,9941],[3868,38049,37072],[46351,46359,23338],[46351,46359,23202],[46248,46354,23200],[46248,46354,23339],[52008,1662,17346],[52008,1662,9849],[56121,47504,23521],[56121,47504,22359],[56866,47564,22342],[50887,60371,13441],[50887,60371,14122],[34055,62616,6836],[33968,62659,6705],[50651,60353,13866],[50651,60353,14093],[45813,33807,26268],[45813,33807,34904],[31661,60996,9851],[31661,60996,10108],[31791,60885,9951],[46342,33269,26940],[46342,33269,35065],[31463,61165,9699],[31463,61165,9841],[46615,33299,27321],[46615,33299,35107],[47293,34916,25887],[48024,36498,24522],[46462,34828,27378],[46462,34828,34875],[44346,27183,18834],[44346,27183,19182],[25408,45497,22660],[25408,45497,23350],[30026,58377,15190],[30026,58377,7852],[29519,57394,7951],[29519,57394,13955],[31235,57302,17472],[31235,57302,6701],[30846,57647,6585],[30846,57647,16739],[65057,35577,26932],[65057,35577,26915],[65057,35577,26887],[65057,35577,26870],[64546,35521,26906],[10212,36710,20737],[10212,36710,36128],[202,31621,36346],[38481,33869,27615],[1631,27590,8904],[2944,27656,8958],[33638,62823,1453],[33638,62823,7123],[38159,57452,16694],[34562,56890,7473],[34562,56890,22939],[33388,62757,7472],[33388,62757,6975],[33429,62353,7485],[16175,20318,23747],[32139,8878,14650],[32296,9275,14326],[36910,21306,23369],[36654,21298,22889],[37471,9564,24010],[37471,9564,23834],[37006,21310,23391],[37006,21310,23549],[4661,30666,45105],[4661,30666,43821],[6394,29333,43838],[6394,29333,44891],[6394,29333,45058],[4758,36259,53120],[4758,36259,43835],[4120,35244,43825],[4120,35244,53498],[41490,27493,19603],[42015,27516,15322],[42015,27516,20331],[64615,48176,22170],[64615,48176,23260],[64002,48122,22193],[32258,7042,7323],[32258,7042,9411],[31406,7311,8976],[40400,4757,11677],[40400,4757,10304],[40615,4671,10674],[40615,4671,11353],[40615,4671,11823],[49064,28491,24039],[49064,28491,18876],[9267,44078,7536],[16403,44514,23358],[1455,27588,8903],[43712,64177,4960],[44816,63935,23993],[44816,63935,5355],[43712,64177,21967],[45745,46679,22933],[25719,45507,22678],[60431,29892,20249],[60431,29892,20169],[60380,29897,20259],[36775,21713,23564],[36775,21713,23714],[26456,20959,23815],[26456,20959,23424],[21431,8096,14266],[51877,41484,15395],[51877,41484,19617],[32676,5803,6704],[32676,5803,6151],[32538,6953,6099],[32538,6953,6779],[33051,5779,5960],[33051,5779,6676],[32839,5793,6380],[32258,7042,5715],[35678,38829,15655],[35678,38829,16109],[35000,39824,15490],[35000,39824,15261],[35228,39490,15546],[41025,40775,15660],[41025,40775,15341],[6912,49280,7557],[9301,35688,37810],[9301,35688,43885],[13586,37029,20540],[13586,37029,12229],[10780,37347,12978],[10780,37347,19770],[13788,39021,17374],[13788,39021,15242],[35175,9183,19705],[35175,9183,6609],[37481,9308,6656],[37481,9308,24020],[37006,21310,23109],[37157,21315,23095],[42644,9586,14544],[33843,58284,6446],[33843,58284,7616],[33749,59208,7586],[4959,18047,12931],[9898,19444,13415],[5007,17099,13951],[38682,5828,6461],[38682,5828,9097],[38422,8254,6606],[7929,36722,44902],[7929,36722,43872],[5467,36363,43843],[5467,36363,45204],[18611,32926,27572],[63414,42074,15317],[63414,42074,15203],[64504,41498,25767],[64504,41498,15137],[34646,57506,6868],[34646,57506,6704],[34475,57451,6718],[34475,57451,6904],[34533,58368,6433],[34533,58368,6669],[34533,58368,5996],[34615,57740,6631],[36480,58603,9828],[36480,58603,5972],[36528,58105,6474],[36528,58105,9796],[37918,58548,6184],[37918,58548,7428],[38962,5818,9888],[38962,5818,9148],[38682,5828,9450],[15484,41754,19242],[15484,41754,13161],[17201,40216,15781],[17201,40216,16738],[21054,37936,19787],[21054,37936,12903],[19529,39972,16147],[19529,39972,16389],[19060,40021,16266],[21887,39725,17004],[21887,39725,15548],[47604,33409,35257],[47604,33409,25580],[3175,33743,45201],[3175,33743,43811],[2476,32631,43801],[2476,32631,46582],[3134,33794,53949],[3134,33794,43811],[3175,33743,54056],[3411,35403,43818],[3411,35403,52478],[3563,33268,45890],[3563,33268,55049],[5674,33949,61945],[5674,33949,56975],[5674,33949,56796],[5540,33380,57273],[5540,33380,61939],[74197,43617,14465],[74197,43617,13940],[74554,43605,13935],[37165,63723,12234],[6553,16759,16664],[6553,16759,17221],[6155,19049,13287],[70442,28314,13835],[70442,28314,17011],[74513,32785,18618],[74513,32785,13243],[75256,33798,13402],[75256,33798,18711],[46170,46349,23340],[42868,27553,16611],[42868,27553,21515],[49071,25602,18991],[49039,28835,24012],[49039,28835,18864],[47099,28432,27463],[47099,28432,18984],[75925,39405,15582],[75926,39658,15819],[74913,39157,14168],[65057,35561,26909],[65061,35352,26606],[65061,35352,27201],[70613,35070,24837],[70613,35070,25366],[70575,33982,23918],[41025,40775,19322],[40971,40628,15677],[40971,40628,19407],[41202,40244,15739],[41202,40244,19732],[5232,55386,7249],[68926,53371,11766],[68808,55883,18867],[68808,55883,19207],[48559,40123,23440],[48559,40123,18795],[47540,40090,18816],[47540,40090,25267],[48589,40777,23363],[48589,40777,18813],[46290,40207,18845],[46290,40207,27503],[9662,17709,19413],[9662,17709,16283],[9386,17625,16366],[9386,17625,19298],[8981,16669,17886],[9898,19444,21999],[73917,31013,13928],[66295,37632,24080],[66295,37632,24649],[66295,37632,29281],[65259,39061,27259],[66988,60086,19315],[66988,60086,20192],[67893,60203,19289],[9078,35437,38222],[8000,36733,37967],[8000,36733,44893],[13304,34243,27099],[13304,34243,24970],[13197,34167,25082],[13197,34167,27374],[11596,32411,27741],[11596,32411,31578],[13136,32496,27748],[5540,33380,56978],[5278,33365,56979],[3563,33268,56988],[5340,31092,49047],[5340,31092,54014],[5340,31092,56987],[5833,33397,56976],[5833,33397,61949],[5680,33580,56976],[5680,33580,61944],[8109,34351,50772],[8109,34351,43869],[8412,34690,50109],[42291,8723,13871],[42291,8723,6701],[42421,7047,8415],[42421,7047,13992],[42096,39915,20204],[42096,39915,19582],[41979,40526,18752],[41979,40526,19757],[42096,39915,21075],[41889,39908,20152],[41889,39908,20759],[40545,39471,20075],[40545,39471,15809],[40199,38533,15916],[40199,38533,20617],[33368,62957,1289],[33388,62757,1491],[33368,62957,6722],[33661,62678,1599],[33661,62678,7117],[27300,59181,8435],[10472,37002,12481],[10472,37002,20294],[13511,36289,21718],[13586,37029,19770],[65058,35130,26285],[65058,35130,26867],[65058,35130,26905],[64764,35074,26234],[65067,35119,26887],[204,38443,33440],[204,38443,8963],[1913,37500,11157],[1913,37500,35796],[68263,35899,27093],[68263,35899,26572],[66732,35538,27011],[53401,59694,19526],[53554,58534,19674],[46462,34828,27127],[52626,29142,19714],[52626,29142,13909],[53057,29259,13773],[53057,29259,20517],[8620,52538,13317],[8620,52538,11238],[8123,51694,11235],[8123,51694,12321],[8417,49143,7388],[8417,49143,15121],[51561,41465,19600],[51561,41465,15396],[51154,40893,14564],[51154,40893,19684],[48659,42278,16907],[48659,42278,18604],[48659,42278,18854],[48624,41522,18834],[48702,43209,18320],[48702,43209,18880],[56605,29326,19185],[56605,29326,14033],[56978,29050,14496],[56978,29050,18647],[52152,29013,19746],[52152,29013,14058],[13586,37029,14527],[15589,36802,21090],[70417,47909,23255],[69161,48332,22409],[70335,41762,18502],[70335,41762,13317],[70207,41756,13245],[70207,41756,14078],[70207,41756,18505],[43067,39946,22560],[43067,39946,19594],[74971,30889,15479],[48211,21890,21918],[39498,59048,13248],[39498,59048,9099],[50572,59062,14140],[55777,35595,26170],[28458,56693,15418],[28458,56693,4881],[29552,57084,7942],[28491,56946,8165],[28491,56946,15315],[28491,56946,4876],[39984,51165,14266],[67312,42209,15273],[67312,42209,15041],[38671,59603,8823],[38671,59603,6066],[40156,57764,14272],[14209,28179,25663],[14209,28179,25069],[14041,28653,25497],[14041,28653,26016],[15642,28930,21859],[15642,28930,22008],[11787,20692,23784],[11787,20692,23439],[11787,20692,22721],[10661,23741,17912],[10661,23741,18406],[12366,25873,15060],[12366,25873,14805],[12485,26022,14826],[48402,43569,18896],[8206,51290,12375],[8206,51290,11245],[8375,50714,12538],[70606,36058,26447],[70606,36058,24726],[70381,36591,24956],[70381,36591,25697],[70283,41621,18625],[70283,41621,18695],[70099,41117,19390],[69130,41664,18588],[69130,41664,19180],[69072,41212,19215],[69130,41664,14040],[67027,41720,14324],[67027,41720,15145],[67027,41720,23881],[5618,37270,43847],[5618,37270,39322],[1563,35916,36864],[1563,35916,43798],[6036,35477,54714],[6036,35477,45273],[5936,35055,45343],[5936,35055,54987],[5936,35055,55869],[5467,36363,53846],[61219,61163,19244],[3961,38586,36687],[1471,38461,34561],[5947,38685,43854],[5947,38685,35261],[5595,38667,43850],[37471,9564,6671],[38294,9452,6678],[38294,9452,22235],[41671,5549,9898],[41671,5549,10371],[41621,5746,12505],[41621,5746,10042],[41671,5549,12582],[38195,5845,6454],[38195,5845,8687],[35751,2390,4978],[38982,2576,6064],[38291,5448,8757],[38291,5448,10670],[38549,4375,8949],[38982,2576,9269],[13218,33773,25714],[13218,33773,27374],[13321,33757,27121],[13321,33757,25749],[13843,33738,25828],[19600,32968,27583],[19600,32968,27597],[20413,32865,27391],[20413,32865,27836],[71210,28280,15016],[71210,28280,17142],[71749,29016,16065],[70025,29559,15052],[70025,29559,18135],[72502,30552,13778],[72502,30552,18815],[73739,30485,14009],[73739,30485,13519],[73502,30751,13574],[73370,30900,13606],[73370,30900,13332],[73370,30900,18187],[73931,30494,13756],[48079,3908,23705],[48079,3908,3558],[48568,3972,3474],[48568,3972,22883],[48185,1498,9403],[52008,1662,9565],[75256,33798,18888],[75403,33998,13434],[75403,33998,18671],[75464,34064,18583],[75464,34064,14433],[75740,34458,14407],[75740,34458,18172],[76036,34397,14612],[75908,34423,14406],[75740,34458,14136],[77986,36172,17621],[77986,36172,17795],[76866,37148,15775],[76360,37588,14862],[76360,37588,14941],[75302,37045,14275],[75302,37045,13284],[75918,37361,14248],[76360,37588,14228],[76019,34517,14578],[81299,39690,14500],[81053,39998,13920],[35182,5264,9618],[35583,5672,10234],[35583,5672,10392],[35644,5615,10313],[35444,8501,6571],[35444,8501,6445],[35480,8412,6566],[36206,6306,9417],[36206,6306,6448],[37406,7528,6544],[37406,7528,7844],[37164,8452,6597],[65348,40705,27409],[65348,40705,14903],[65361,40949,14969],[65361,40949,27431],[65723,40717,26574],[65723,40717,14898],[66275,40433,14810],[66275,40433,25285],[66126,40459,25622],[66126,40459,14820],[29139,62313,6645],[47452,22731,23244],[47452,22731,23202],[48220,22801,23216],[34475,57451,24175],[33648,57188,6784],[33648,57188,22420],[34483,56878,23076],[34483,56878,24081],[34562,56890,24246],[9286,39269,7535],[9286,39269,16044],[14636,41716,19265],[14636,41716,13142],[1455,27588,16301],[2387,28429,9810],[2387,28429,17924],[12975,40759,17965],[12975,40759,14518],[13477,39685,16282],[60431,29892,13556],[61077,29828,13720],[61077,29828,19031],[2220,34940,38304],[2220,34940,43803],[1211,34322,37938],[1211,34322,43791],[6601,37624,43859],[6601,37624,35730],[6359,38705,35251],[7037,37891,35611],[7037,37891,10367],[7899,38419,35375],[7899,38419,9174],[7862,36899,43871],[7862,36899,37934],[7862,36899,36049],[7037,37891,43864],[14849,26371,23439],[14849,26371,14949],[14907,26374,14951],[14907,26374,23305],[8921,39045,7761],[9286,39269,7256],[9139,39179,7459],[9288,38680,8604],[9288,38680,15148],[14786,30320,23945],[15846,27767,15143],[15846,27767,21286],[14209,28179,15132],[56605,29326,27041],[60878,29332,14463],[60878,29332,19159],[57318,58033,20454],[57318,58033,20888],[56906,57810,21128],[57318,58033,19695],[57301,58362,19652],[57301,58362,20444],[57584,58756,19598],[57584,58756,19955],[3878,29773,43811],[3878,29773,37997],[3878,29773,37703],[5730,28862,36262],[5730,28862,43830],[9471,27982,37438],[8233,27920,40518],[8233,27920,34746],[6635,29505,56987],[6635,29505,45102],[6635,29505,44269],[7127,29854,43003],[7127,29854,43847],[7127,29854,56984],[9033,33625,56960],[9033,33625,43877],[8982,33766,43877],[8982,33766,56959],[11272,32800,32329],[11272,32800,43901],[11272,32800,27090],[1211,34322,10185],[102,38442,8830],[149,35121,8827],[31220,63780,6388],[31129,63716,6388],[31129,63716,6503],[31567,62059,8550],[31567,62059,8503],[31441,62537,7927],[42470,27106,15999],[42470,27106,19286],[41176,28269,19252],[41176,28269,17270],[64615,48176,23839],[65250,48231,22147],[41176,28269,19259],[40778,29241,20709],[40778,29241,19161],[39501,29223,20914],[39501,29223,19137],[41576,27284,19327],[41576,27284,15633],[41954,26356,17101],[41954,26356,19343],[42141,27402,19292],[42141,27402,15510],[42421,38088,21536],[42421,38088,21440],[42349,38337,21347],[42508,38071,21572],[72971,28202,17726],[72971,28202,17444],[72765,28211,17408],[72943,28116,17573],[72943,28116,17657],[73129,27671,17817],[40315,4686,11793],[40315,4686,10150],[40379,2657,10132],[42413,6548,13945],[42413,6548,8918],[42225,6276,9165],[42225,6276,13598],[30522,59389,7755],[30522,59389,8340],[30522,59389,8385],[30123,59670,7842],[30026,58377,7189],[30289,58915,7801],[33136,57548,6661],[33136,57548,21436],[35000,39824,14461],[34870,40014,14764],[34870,40014,15098],[36608,40585,15468],[34883,8397,6555],[34883,8397,19131],[71277,41591,18000],[71277,41591,13524],[70283,41621,13079],[72204,44061,17732],[72911,42052,15033],[72911,42052,14046],[74079,40984,14884],[74079,40984,14084],[74606,39530,14153],[74528,38923,19221],[74528,38923,14187],[73823,39459,14170],[73823,39459,19011],[78064,36238,17742],[73451,40517,14120],[73451,40517,17914],[7308,36479,52001],[7308,36479,45000],[10472,37002,35647],[9579,38269,14494],[9579,38269,35262],[9579,38269,9553],[8738,37606,11054],[8738,37606,35734],[8738,37606,36564],[75287,37649,18380],[75287,37649,14243],[74938,38612,14197],[74938,38612,18720],[36127,5585,10378],[36127,5585,6402],[38291,5448,6431],[40379,2657,11486],[72169,41150,17965],[72169,41150,14107],[72169,41150,13296],[2917,28907,36447],[2387,28429,35592],[2387,28429,35755],[2944,27656,34575],[2996,31945,47572],[2996,31945,45150],[4661,30666,49517],[5109,30884,45173],[5109,30884,49291],[216,30671,8824],[261,27570,8820],[67405,16037,4689],[67601,10465,4476],[67882,10480,4466],[67405,16037,11459],[69635,38362,23202],[4519,50650,9899],[41824,40976,19408],[41824,40976,15571],[41188,41218,15999],[41188,41218,19067],[41979,40526,15740],[41899,40948,15680],[41899,40948,19447],[44066,40416,24121],[44066,40416,18898],[43351,40483,17820],[43351,40483,23033],[42172,40844,18332],[42172,40844,16080],[43313,40411,17752],[43313,40411,18980],[43313,40411,22970],[69179,41710,18527],[69179,41710,14104],[70353,42622,14632],[70353,42622,15382],[70727,43734,17039],[70727,43734,20495],[69234,43784,17257],[72503,41557,14079],[70785,42958,15355],[70785,42958,15853],[70727,43734,15333],[31463,61165,9489],[31299,61306,9221],[31299,61306,9620],[31619,61468,9252],[31619,61468,9251],[31008,61154,9198],[31618,61482,9235],[55600,47463,22371],[58625,59183,19530],[30846,57647,6165],[31520,58003,6487],[70510,46649,25376],[70510,46649,15249],[69000,47560,12909],[70437,47636,15221],[70437,47636,23714],[5007,17099,14172],[5162,17124,14348],[6553,16759,17518],[6047,16605,16180],[6579,16606,17484],[64203,56800,23783],[64203,56800,23292],[63247,57494,22228],[67155,59523,20090],[64565,56537,24371],[64565,56537,22965],[64600,50614,23584],[57721,50211,20700],[68926,53371,13945],[68366,53777,13203],[45496,34744,27171],[45496,34744,34725],[53666,28816,14516],[53666,28816,21590],[56625,29025,14498],[56625,29025,27046],[70606,36058,26692],[70644,35967,26560],[70644,35967,24687],[81053,39998,14021],[80410,39668,14049],[68511,55825,19165],[75365,34660,18641],[75365,34660,13525],[75302,37045,18433],[8761,53566,13761],[8761,53566,10667],[8258,53362,10776],[8258,53362,12887],[8396,53048,11222],[8396,53048,13051],[8438,55926,13710],[8438,55926,7533],[61610,58662,19562],[61471,59548,19450],[5936,35055,56969],[6021,34819,56970],[6021,34819,54858],[8109,34351,56961],[58005,40486,14995],[58005,40486,23707],[56508,38482,16667],[73871,41560,14056],[79347,29592,19007],[76182,33388,14457],[76182,33388,15021],[76355,32189,16276],[34076,32121,24808],[34076,32121,25371],[37219,30442,23053],[37219,30442,22523],[37219,30442,21523],[1432,33147,38676],[1432,33147,10449],[180,33076,37533],[483,33876,43781],[483,33876,9234],[1432,33147,43791],[2996,31945,43805],[4154,30419,43815],[4154,30419,45027],[6394,29333,36939],[66422,37457,24329],[7929,36722,38035],[8703,34218,43875],[8703,34218,56959],[37184,63305,11731],[37184,63305,8198],[37931,61444,7128],[37931,61444,10268],[39266,62816,13568],[39266,62816,13425],[38967,63668,13003],[38967,63668,14288],[39274,62690,13419],[44159,25973,19240],[44159,25973,17916],[44332,25616,18483],[44332,25616,19245],[45273,25206,19211],[322,27571,8900],[859,29915,9642],[859,29915,35501],[1522,29628,10494],[1522,29628,35838],[16222,27988,20446],[16222,27988,15181],[17158,26468,18166],[42049,4093,13150],[42049,4093,9192],[40825,4586,11037],[43408,42839,18257],[43408,42839,18980],[43837,43344,18986],[44002,41242,18923],[1750,29312,35773],[1750,29312,10783],[1664,29208,10671],[1664,29208,19442],[1664,29208,35613],[322,27571,16278],[1783,29266,35764],[1783,29266,19553],[5109,30884,54058],[5396,31023,45217],[5396,31023,54071],[5396,31023,56987],[41621,5746,9612],[35444,8501,20184],[71683,34818,23461],[75419,34525,18586],[75419,34525,13619],[75464,34064,13462],[75905,33468,14572],[75905,33468,14457],[75858,33531,14455],[38650,61237,5945],[38650,61237,10855],[29438,50604,14032],[21266,15007,14308],[41680,16589,14779],[42549,16705,14833],[51302,29945,19857],[76382,41281,17858],[52109,40091,19899],[52601,29999,19757],[50784,51855,14487],[51737,51845,14609],[40759,51179,14332],[6703,16487,17709],[32080,15694,14144],[13586,37029,26027],[5454,51321,11157],[5661,52458,11145],[55513,55867,23537],[63137,48893,20806],[76809,31057,17858]],"CityObjects":{"NL.IMBAG.Pand.0363100012178469":{"type":"Building","attributes":{"b3_bag_bag_overlap":0.0,"b3_bouwlagen":4,"b3_dak_type":"slanted","b3_extrusie":"standard","b3_h_dak_50p":19.957000732421875,"b3_h_dak_70p":21.899999618530273,"b3_h_dak_max":36.62099838256836,"b3_h_dak_min":4.909999847412109,"b3_h_maaiveld":1.1339999437332153,"b3_h_nok":45.603050231933594,"b3_is_glas_dak":false,"b3_kas_warenhuis":false,"b3_kwaliteitsindicator":false,"b3_mutatie_ahn3_ahn4":false,"b3_mutatie_ahn4_ahn5":false,"b3_n_nok":90,"b3_n_vlakken":239,"b3_nodata_fractie_ahn3":0.005787738133221865,"b3_nodata_fractie_ahn4":0.052291542291641235,"b3_nodata_fractie_ahn5":0.0334477424621582,"b3_nodata_radius_ahn3":0.12066808342933655,"b3_nodata_radius_ahn4":0.0027194751892238855,"b3_nodata_radius_ahn5":0.0,"b3_opp_buitenmuur":6440.75,"b3_opp_dak_plat":200.76,"b3_opp_dak_schuin":6836.25,"b3_opp_grond":3712.65,"b3_opp_scheidingsmuur":1308.18,"b3_puntdichtheid_ahn3":19.848913192749023,"b3_puntdichtheid_ahn4":48.224117279052734,"b3_puntdichtheid_ahn5":19.94652557373047,"b3_pw_bron":"ahn5","b3_pw_datum":2023,"b3_pw_onvoldoende":false,"b3_pw_selectie_reden":"PREFERRED_AND_LATEST","b3_rmse_lod12":6.978324890136719,"b3_rmse_lod13":2.2730486392974854,"b3_rmse_lod22":0.6088934540748596,"b3_t_run":10208,"b3_val3dity_lod12":"[]","b3_val3dity_lod13":"[104]","b3_val3dity_lod22":"[102,104,203]","b3_volume_lod12":76630.6015625,"b3_volume_lod13":74041.015625,"b3_volume_lod22":67988.84375,"begingeldigheid":"2023-09-28","documentdatum":"2023-09-28","documentnummer":"GV00007182_AC00AC","eindgeldigheid":null,"eindregistratie":null,"fid":8473678,"geconstateerd":false,"identificatie":"NL.IMBAG.Pand.0363100012178469","oorspronkelijkbouwjaar":1300,"status":"Pand in gebruik","tijdstipeindregistratielv":null,"tijdstipinactief":null,"tijdstipinactieflv":null,"tijdstipnietbaglv":null,"tijdstipregistratie":"2023-09-28T18:55:25Z","tijdstipregistratielv":"2023-09-28T21:49:20Z","voorkomenidentificatie":3},"geometry":[{"boundaries":[[[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104]]],"lod":"0","type":"MultiSurface"}]}}}]} \ No newline at end of file diff --git a/web-standalone/tests/test_bag3d.py b/web-standalone/tests/test_bag3d.py new file mode 100644 index 0000000..09e11dd --- /dev/null +++ b/web-standalone/tests/test_bag3d.py @@ -0,0 +1,60 @@ +"""Run with: python3 -m pytest web-standalone/tests""" +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import bag3d # noqa: E402 + +FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "bag3d-pand.json").read_text(encoding="utf-8")) + + +def test_rd_conversion_against_two_church_towers(): + # Westertoren, Amsterdam: RD (120700.7, 487525.5); Martinitoren, Groningen: RD (233883.1, 582065.2). + for (lat, lon), (rx, ry) in (((52.37453, 4.88352), (120700.7, 487525.5)), ((53.21938, 6.56820), (233883.1, 582065.2))): + x, y = bag3d.wgs84_to_rd(lat, lon) + assert abs(x - rx) < 1 and abs(y - ry) < 1 + back_lat, back_lon = bag3d.rd_to_wgs84(rx, ry) + assert abs(back_lat - lat) < 1e-5 and abs(back_lon - lon) < 1e-5 + + +def test_bbox_is_rd_metres_around_the_point(): + parts = [float(p) for p in bag3d.bbox_around(52.3731, 4.8932, 12).split(",")] + assert parts[2] - parts[0] == 24 and parts[3] - parts[1] == 24 + + +def test_buildings_carry_heights_and_a_footprint_in_metres(): + b = bag3d.buildings(FIXTURE) + assert len(b) == 1 + building = b[0] + assert building["id"] == "NL.IMBAG.Pand.0363100012178469" + assert round(building["roof_m"], 2) == round(36.62099838256836 - 1.1339999437332153, 2) + ring = building["surfaces"][0][0] + assert len(ring) == 105 + xs = [p[0] for p in ring] + ys = [p[1] for p in ring] + # The footprint sits where the transform puts it, in RD metres, not in scaled ints. + assert 121600 < min(xs) < max(xs) < 121800 + assert 487400 < min(ys) < max(ys) < 487600 + + +def test_point_inside_and_outside_the_footprint(): + building = bag3d.buildings(FIXTURE)[0] + ring = building["surfaces"][0][0] + cx = sum(p[0] for p in ring) / len(ring) + cy = sum(p[1] for p in ring) / len(ring) + assert bag3d.point_in_ring(cx, cy, ring) + assert not bag3d.point_in_ring(cx + 500, cy, ring) + lat, lon = bag3d.rd_to_wgs84(cx, cy) + inside = bag3d.roof_for_point(FIXTURE, lat, lon) + assert inside["building"] == building["id"] + assert inside["roof_m"] == 35.5 + assert inside["buildings"] == 1 + lat2, lon2 = bag3d.rd_to_wgs84(cx + 500, cy) + outside = bag3d.roof_for_point(FIXTURE, lat2, lon2) + assert outside["building"] is None and outside["roof_m"] is None + + +def test_empty_response_means_no_building(): + assert bag3d.roof_for_point({"features": []}, 52.0, 5.0) == {"roof_m": None, "building": None, "buildings": 0} From b0b3688fa3c6bf944378bc887b71d5a392284d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Mon, 7 Sep 2026 23:32:03 +0200 Subject: [PATCH 2/2] feat(web-standalone): use the tallest building within 50 m, not only the one underfoot Advertised positions are metres off and a mast can stand next to the building it serves, so "the footprint the point is inside" missed most of them. The roof now comes from the tallest building whose footprint comes within 50 m of the node, ties to the nearer one, and the popup says how far that roof is. bag3d.py: footprint_distance() (0 inside, else the nearest edge), tallest_near() over one or more response pages, roof_for_point() with a radius and a distance_m. server.py follows rel=next up to 4 pages of 100 features, radius 50 m. Tests for the distance, the radius cut-off and the taller building on a later page winning: 13 passed. Verified live: on the fixture deep link's region 1, 13 of 14 observers have a building within 50 m, the farthest at 47.2 m; antennas range from 10.6 m (a 7.6 m roof 23 m away) to 39 m (a 36 m roof underfoot). The Amsterdam-centre lookup takes 7.5 s for two pages the first time and 0.01 s from cache after. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- README.md | 2 +- web-standalone/bag3d.py | 72 ++++++++++++++++++++++++++---- web-standalone/changelog.json | 2 +- web-standalone/index.html | 28 ++++++++---- web-standalone/server.py | 37 ++++++++++----- web-standalone/tests/test_bag3d.py | 39 +++++++++++++++- 7 files changed, 148 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73a3126..afc33ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ web-standalone/ Self-contained map tool (no DB) changelog.json "What's new" entries, newest first; every user-visible change adds one server.py stdlib-only HTTP server + proxy for upstream feeds (mc-radar, meshcore.io, PDOK, mc-spamdetector.nl) spamdetector.py parses a spam-detector attack page into Step 1 clues (pure logic, tested) - bag3d.py 3D BAG: RD conversion, footprints, the roof a node stands on (pure logic, tested) + bag3d.py 3D BAG: RD conversion, footprints, the tallest roof within reach of a node (pure logic, tested) tests/ pytest for the pure logic; fixtures are trimmed copies of real upstream pages tools/ accuracy.mjs (estimator error against known targets), its fixture, build-fixture.py Dockerfile Alpine, non-root, stdlib-only diff --git a/README.md b/README.md index 4e619a0..1599d5c 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ control in front if you do; the proxy has no auth of its own. | map.meshcore.io | Global node feed (fallback). | | PDOK AHN | Terrain/surface elevation (NL). | | mc-spamdetector.nl | Incident list, and an incident's entry hops as Step 1 clues. | -| 3D BAG (api.3dbag.nl) | The building an observer stands on, for its antenna height in the line-of-sight check (NL). | +| 3D BAG (api.3dbag.nl) | The tallest building within 50 m of an observer, for its antenna height in the line-of-sight check (NL). | Loaded by the page directly, not through the proxy: OpenFreeMap vector tiles (the Liberty basemap, no key) and AWS Terrain Tiles (relief in the 3D view, for looking at; line of sight is measured on AHN). diff --git a/web-standalone/bag3d.py b/web-standalone/bag3d.py index 0a88f96..1ad0e31 100644 --- a/web-standalone/bag3d.py +++ b/web-standalone/bag3d.py @@ -3,8 +3,11 @@ A repeater on a roof has its antenna at roof height plus a mast, not at the 12 m the estimator assumed for every node. 3D BAG (TU Delft, CC BY 4.0) publishes per-building footprints with roof and ground heights. This module -turns one response of its OGC API Features endpoint into "the building this -point stands on, and how high its roof is above the ground". +turns the responses of its OGC API Features endpoint into "the tallest +building within reach of this point, how high its roof is above the ground, +and how far away it is". Advertised positions are metres off and a mast can +stand next to the building it serves, so the tallest building within +50 m is the antenna's structure rather than the footprint under the point. Pure logic, stdlib only, so it is unit-testable (tests/test_bag3d.py). server.py fetches the payload and serves the result under /proxy/3dbag/. @@ -131,12 +134,63 @@ def building_at(payload, lat, lon): return None -def roof_for_point(payload, lat, lon): - """What the page asks for: the roof height above ground of the building - the point stands on, or null when it stands on none.""" - building = building_at(payload, lat, lon) +def _segment_distance(px, py, ax, ay, bx, by): + dx, dy = bx - ax, by - ay + if dx == 0 and dy == 0: + return math.hypot(px - ax, py - ay) + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy))) + return math.hypot(px - (ax + t * dx), py - (ay + t * dy)) + + +def footprint_distance(x, y, surfaces): + """Metres from the point to the nearest footprint edge, 0 inside.""" + best = None + for rings in surfaces: + if point_in_surface(x, y, rings): + return 0.0 + outer = rings[0] if rings else [] + n = len(outer) + for i in range(n): + ax, ay = outer[i] + bx, by = outer[(i + 1) % n] + d = _segment_distance(x, y, ax, ay, bx, by) + if best is None or d < best: + best = d + return best + + +def tallest_near(payloads, lat, lon, radius_m): + """The tallest building whose footprint comes within radius_m of the + point, over one or more response pages. Ties go to the nearer one.""" + if isinstance(payloads, dict): + payloads = [payloads] + x, y = wgs84_to_rd(lat, lon) + best = None + total = 0 + for payload in payloads: + for building in buildings(payload): + total += 1 + if building["roof_m"] is None or not building["surfaces"]: + continue + distance = footprint_distance(x, y, building["surfaces"]) + if distance is None or distance > radius_m: + continue + key = (round(building["roof_m"], 1), -distance) + if best is None or key > best[0]: + best = (key, building, distance) + return best, total + + +def roof_for_point(payloads, lat, lon, radius_m=50.0): + """What the page asks for: the roof height above ground of the tallest + building within radius_m, how far its footprint is, or nulls.""" + best, total = tallest_near(payloads, lat, lon, radius_m) + if not best: + return {"roof_m": None, "building": None, "distance_m": None, "buildings": total} + _, building, distance = best return { - "roof_m": round(building["roof_m"], 1) if building and building["roof_m"] is not None else None, - "building": building["id"] if building else None, - "buildings": len(buildings(payload)), + "roof_m": round(building["roof_m"], 1), + "building": building["id"], + "distance_m": round(distance, 1), + "buildings": total, } diff --git a/web-standalone/changelog.json b/web-standalone/changelog.json index 900d054..1304e30 100644 --- a/web-standalone/changelog.json +++ b/web-standalone/changelog.json @@ -3,7 +3,7 @@ "id": "2026-09-07-antenna-height-from-the-roof", "date": "2026-09-07", "title": "A repeater on a roof gets its real antenna height", - "body": "The line-of-sight check assumed every observer's antenna at 12 m. When a cluster is locked the tool now looks up, in 3D BAG, whether each observer stands on a building and how high its roof is; those get roof plus a 3 m mast, the others keep 12 m. The cluster status says how many, and an observer's popup shows its antenna height. Netherlands only, like the terrain check." + "body": "The line-of-sight check assumed every observer's antenna at 12 m. When a cluster is locked the tool now looks up, in 3D BAG, the tallest building within 50 m of each observer; those get its roof plus a 3 m mast, the others keep 12 m. The cluster status says how many, and an observer's popup shows its antenna height and how far that roof is. Netherlands only, like the terrain check." }, { "id": "2026-09-07-fresh-visit", diff --git a/web-standalone/index.html b/web-standalone/index.html index 27384e6..2084fe8 100644 --- a/web-standalone/index.html +++ b/web-standalone/index.html @@ -1737,12 +1737,15 @@

Unknown Node Estimate

const panelCluster = document.getElementById("panel-cluster"); const panelTerrain = document.getElementById("panel-terrain"); - // Assumed when the node stands on no known building. Half of the fixture's - // observers stand on one (32 of 60 sampled against 3D BAG), with a roof - // median 10.2 m above ground, p90 25 m; for those the roof plus a mast - // replaces this constant (#74). + // Assumed when no building stands within ROOF_SEARCH_M of the node. Half + // of the fixture's observers stand within 12 m of one (32 of 60 sampled + // against 3D BAG), roof median 10.2 m above ground, p90 25 m. Advertised + // positions are metres off and a mast can stand next to the building it + // serves, so the tallest building within 50 m counts, and its roof plus + // a mast replaces this constant (#74). const REPEATER_ANTENNA_HEIGHT_M = 12; const ROOFTOP_MAST_M = 3; + const ROOF_SEARCH_M = 50; const TARGET_ANTENNA_HEIGHT_M = 1.5; // Antenna height above the ground for an observer: its roof plus a mast @@ -1752,9 +1755,10 @@

Unknown Node Estimate

} // One 3D BAG lookup per node, through server.py, which caches by - // position. Sets node.roofHeightM (metres above ground) when the node - // stands inside a building footprint; leaves it unset otherwise or when - // the lookup fails. Counts are returned for the status line. + // position. Sets node.roofHeightM (metres above ground) and + // node.roofDistanceM when a building stands within ROOF_SEARCH_M; leaves + // them unset otherwise or when the lookup fails. Counts are returned for + // the status line. async function fetchRoofHeights(nodes) { let onRoof = 0; let answered = 0; @@ -1767,6 +1771,7 @@

Unknown Node Estimate

answered += 1; if (Number.isFinite(data.roof_m) && data.roof_m > 0) { node.roofHeightM = data.roof_m; + node.roofDistanceM = Number.isFinite(data.distance_m) ? data.distance_m : null; node.roofBuilding = data.building; onRoof += 1; } @@ -2944,6 +2949,7 @@

Unknown Node Estimate

delete node.provenRadiusKm; // The roof belonged to the position it left. delete node.roofHeightM; + delete node.roofDistanceM; delete node.roofBuilding; } else { delete node.moved; @@ -4334,7 +4340,7 @@

Unknown Node Estimate

const implausibleNote = radiusNotes.length ? ` Coverage radii: ${radiusNotes.join("; ")}.` : ""; // Which observers got a roof height, and which stay on the constant. const roofNote = roofs.answered - ? ` Antenna heights: ${roofs.onRoof} of ${roofs.answered} observers stand on a building per 3D BAG, the rest assume ${REPEATER_ANTENNA_HEIGHT_M} m.` + ? ` Antenna heights: ${roofs.onRoof} of ${roofs.answered} observers have a building within ${ROOF_SEARCH_M} m per 3D BAG and use its roof, the rest assume ${REPEATER_ANTENNA_HEIGHT_M} m.` : ` Antenna heights: 3D BAG did not answer, all observers assume ${REPEATER_ANTENNA_HEIGHT_M} m.`; clusterStatus.textContent = `Cluster near ${centroid.lat.toFixed(3)}, ${centroid.lon.toFixed(3)}: ${uniquePrefixCount(dedupedNodes)} observers active.${hullNote}${dedupNote}${linkCapNote}${implausibleNote}${roofNote}`; syncClusterEditorFromLockedRegion(); @@ -4730,6 +4736,7 @@

Unknown Node Estimate

provenRadiusKm: Number.isFinite(n.provenRadiusKm) ? n.provenRadiusKm : null, antennaM: observerAntennaHeightM(n), onRoof: Number.isFinite(n.roofHeightM) ? 1 : 0, + roofDistanceM: Number.isFinite(n.roofDistanceM) ? n.roofDistanceM : null, moved: n.moved ? 1 : 0, movedKm: n.moved && positionOverrides.get(nodeId(n)) ? haversineKm(positionOverrides.get(nodeId(n)).upstream, n) : null @@ -5339,8 +5346,11 @@

Unknown Node Estimate

const moved = p.moved === 1 || p.moved === "1"; const movedLine = moved ? `
Moved ${Number(p.movedKm).toFixed(1)} km from its advertised position; range from link count.` : ""; const onRoof = p.onRoof === 1 || p.onRoof === "1"; + const roofWhere = onRoof + ? (Number(p.roofDistanceM) > 0 ? `tallest roof within ${Number(p.roofDistanceM).toFixed(0)} m, per 3D BAG, + mast` : "its roof per 3D BAG + mast") + : "assumed"; const antennaLine = Number.isFinite(Number(p.antennaM)) - ? `
Antenna ${Number(p.antennaM).toFixed(0)} m${onRoof ? " (roof per 3D BAG + mast)" : " (assumed)"}` + ? `
Antenna ${Number(p.antennaM).toFixed(0)} m (${roofWhere})` : ""; const popup = show3dPopup(e.lngLat, `
diff --git a/web-standalone/server.py b/web-standalone/server.py index d66d30e..73e5024 100644 --- a/web-standalone/server.py +++ b/web-standalone/server.py @@ -47,13 +47,32 @@ SPAMDETECTOR_CACHE_SIZE = 20 INCIDENT_ID = re.compile(r"^[0-9]{1,9}$") -# 3D BAG (#74): the building a repeater stands on, for its antenna height. -# One bbox query per node, keyed on the rounded position; buildings do not -# move, so the cache lives a day. -BAG3D_ITEMS_URL = "https://api.3dbag.nl/collections/pand/items?bbox={bbox}&limit=10" -BAG3D_RADIUS_M = 15 +# 3D BAG (#74): the tallest building within BAG3D_RADIUS_M of a repeater, +# for its antenna height. The API pages at 100 features; a 100 m box holds +# up to about 100 buildings in a Dutch town centre, so up to BAG3D_MAX_PAGES +# are followed. +# Keyed on the rounded position; buildings do not move, so the cache lives a +# day. +BAG3D_ITEMS_URL = "https://api.3dbag.nl/collections/pand/items?bbox={bbox}&limit=100" +BAG3D_RADIUS_M = 50 +BAG3D_MAX_PAGES = 4 BAG3D_TTL = 86400 + +def _bag3d_pages(url): + """Every page of a 3D BAG items query, following rel=next, capped.""" + pages = [] + next_url = url + while next_url and len(pages) < BAG3D_MAX_PAGES: + page = json.loads(_fetch(next_url).decode("utf-8", "replace")) + pages.append(page) + next_url = None + for link in page.get("links") or []: + if link.get("rel") == "next" and link.get("href"): + next_url = link["href"] + break + return pages + _cache = {} _cache_lock = threading.Lock() @@ -135,18 +154,14 @@ def _bag3d_roof(self, query): key = "bag3d:%.5f,%.5f" % (lat, lon) url = BAG3D_ITEMS_URL.format(bbox=bag3d.bbox_around(lat, lon, BAG3D_RADIUS_M)) try: - body = _cached(key, BAG3D_TTL, lambda: _fetch(url)) + result = _cached(key, BAG3D_TTL, lambda: bag3d.roof_for_point(_bag3d_pages(url), lat, lon, BAG3D_RADIUS_M)) except UpstreamError as error: self._send_json({"error": error.message}, error.status) return - try: - payload = json.loads(body.decode("utf-8", "replace")) except ValueError: self._send_json({"error": "3D BAG answered something that is not JSON"}, 502) return - result = bag3d.roof_for_point(payload, lat, lon) - result["covered"] = True - self._send_json(result, 200) + self._send_json(dict(result, covered=True, radius_m=BAG3D_RADIUS_M), 200) def _spamdetector_alerts(self): try: diff --git a/web-standalone/tests/test_bag3d.py b/web-standalone/tests/test_bag3d.py index 09e11dd..bc5a8ab 100644 --- a/web-standalone/tests/test_bag3d.py +++ b/web-standalone/tests/test_bag3d.py @@ -50,11 +50,46 @@ def test_point_inside_and_outside_the_footprint(): inside = bag3d.roof_for_point(FIXTURE, lat, lon) assert inside["building"] == building["id"] assert inside["roof_m"] == 35.5 + assert inside["distance_m"] == 0.0 assert inside["buildings"] == 1 lat2, lon2 = bag3d.rd_to_wgs84(cx + 500, cy) outside = bag3d.roof_for_point(FIXTURE, lat2, lon2) - assert outside["building"] is None and outside["roof_m"] is None + assert outside["building"] is None and outside["roof_m"] is None and outside["distance_m"] is None + + +def test_the_tallest_building_within_the_radius_counts_with_its_distance(): + building = bag3d.buildings(FIXTURE)[0] + ring = building["surfaces"][0][0] + east = max(p[0] for p in ring) + cy = sum(p[1] for p in ring) / len(ring) + # 40 m east of the footprint's eastern edge: inside a 100 m radius, outside a 10 m one. + x, y = east + 40, cy + d = bag3d.footprint_distance(x, y, building["surfaces"]) + assert 35 < d <= 40.5 + lat, lon = bag3d.rd_to_wgs84(x, y) + near = bag3d.roof_for_point(FIXTURE, lat, lon, radius_m=100) + assert near["building"] == building["id"] and near["roof_m"] == 35.5 + assert 35 < near["distance_m"] <= 40.5 + far = bag3d.roof_for_point(FIXTURE, lat, lon, radius_m=10) + assert far["building"] is None + + +def test_pages_are_searched_together_and_the_taller_one_wins(): + page2 = json.loads(json.dumps(FIXTURE)) + obj = page2["features"][0]["CityObjects"] + key = next(iter(obj)) + obj[key]["attributes"]["b3_h_dak_max"] = 60.0 + obj["NL.IMBAG.Pand.taller"] = obj.pop(key) + building = bag3d.buildings(FIXTURE)[0] + ring = building["surfaces"][0][0] + cx = sum(p[0] for p in ring) / len(ring) + cy = sum(p[1] for p in ring) / len(ring) + lat, lon = bag3d.rd_to_wgs84(cx, cy) + result = bag3d.roof_for_point([FIXTURE, page2], lat, lon) + assert result["building"] == "NL.IMBAG.Pand.taller" + assert result["roof_m"] == round(60.0 - 1.1339999437332153, 1) + assert result["buildings"] == 2 def test_empty_response_means_no_building(): - assert bag3d.roof_for_point({"features": []}, 52.0, 5.0) == {"roof_m": None, "building": None, "buildings": 0} + assert bag3d.roof_for_point({"features": []}, 52.0, 5.0) == {"roof_m": None, "building": None, "distance_m": None, "buildings": 0}