landcheck 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ natural-earth-vector/
5
+ data/*.geojson
6
+ data/*.topojson
7
+ data/*.pmtiles
8
+ data/*.pkl
9
+ !data/sample_*.geojson
10
+ .venv/
11
+ poetry.lock
12
+ .DS_Store
13
+ docs/data/
14
+ .wrangler/
15
+ osm-vector/osm_simplified_land_polygons.geojson.zip
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: landcheck
3
+ Version: 0.1.0
4
+ Summary: Offline land/sea point lookup: 182 KB global dataset, microsecond answers with confidence, built on the Trifold T3 triangular DGGS
5
+ Project-URL: Homepage, https://jaakla.github.io/trifold/landcheck.html
6
+ Project-URL: Repository, https://github.com/jaakla/trifold
7
+ License-Expression: MIT
8
+ Keywords: coastline,dggs,geospatial,land,ocean,offline,point-in-polygon,sea
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: GIS
18
+ Requires-Python: >=3.10
19
+ Provides-Extra: batch
20
+ Requires-Dist: numpy; extra == 'batch'
21
+ Requires-Dist: t3grid; extra == 'batch'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # landcheck — offline land/sea lookup
25
+
26
+ This is created as Trifold application (also test and demonstration)
27
+
28
+ **Is this lat/long point on land or in the sea?** gets answered anywhere on
29
+ Earth in ~1–13 µs, fully offline, from a small **182 KB** bundled dataset —
30
+ with a confidence value for every answer. Works with Python and JavaScript.
31
+
32
+ **[Live in-browser demo](https://jaakla.github.io/trifold/landcheck.html)** —
33
+ classify sample or your own points (CSV / GeoJSON) on a map and watch the
34
+ measured lookup rate; the page embeds the real JS library and dataset.
35
+
36
+ ```python
37
+ import sys; sys.path.insert(0, "landcheck/python")
38
+ from landcheck import LandCheck
39
+
40
+ lc = LandCheck()
41
+ lc.is_land(24.7536, 59.4370) # True (lon, lat — Tallinn)
42
+ lc.check(-0.1276, 51.5072)
43
+ # LandResult(land=True, kind='land', confidence=1.0, land_fraction=1.0,
44
+ # cell='TFA95BM', refined=False)
45
+ ```
46
+
47
+ ```js
48
+ import { LandCheck } from "./landcheck/js/landcheck.mjs";
49
+ const lc = await LandCheck.fromFile(); // NodeJs takes bundled data directly
50
+ // OR:
51
+ const lc = await LandCheck.fromUrl("/data/landsea_L10.tfls"); // browser needs to load data file online
52
+ lc.isLand(24.7536, 59.437); // true
53
+ lc.check(-0.1276, 51.5072);
54
+ // { land: true, kind: 'land', confidence: 1, landFraction: 1,
55
+ // cell: 'TFA95BM', refined: false }
56
+ ```
57
+
58
+ ## How it works
59
+
60
+ The Trifold level-10 grid (~7 km triangles, 21M cells globally) is
61
+ classified against Natural Earth 1:50m land: 6.15M cells touch land,
62
+ of which 168,833 are *coastal* (mixed land/sea) and the rest are wholly
63
+ interior. Because Trifold addresses sort hierarchically, every cell at
64
+ any level maps to a contiguous range in the canonical level-10 index
65
+ space (`face·4¹⁰ + path`), so the entire classification collapses to
66
+ **153,884 run-length intervals** — 182 KB compressed, including a 4-bit
67
+ land-area fraction for every coastal cell.
68
+
69
+ A lookup is: locate the point's level-10 triangle (pure float math, no
70
+ dependencies), then binary-search the runs.
71
+
72
+ | answer kind | meaning | `land` | `confidence` |
73
+ |---|---|---|---|
74
+ | `land` | cell wholly inside land | `True` | 1.0 |
75
+ | `sea` | cell absent from dataset | `False` | 1.0 |
76
+ | `coast` | mixed cell | `land_fraction >= 0.5` | `max(f, 1−f)` |
77
+ | `coast` + `refined` | decided by OSM polygon test | exact | 0.99 |
78
+
79
+ **Measured accuracy** (30,000 uniform random points vs. exact polygon
80
+ containment in the source dataset): 99.82% agreement overall; `land` and
81
+ `sea` answers 100% correct; *all* residual error lives in `coast`
82
+ answers, which self-report their lower confidence (mean calibration
83
+ credit 0.997).
84
+
85
+ Caveats inherited from the source data: Natural Earth 1:50m treats **lakes as land and omits islets** below its resolution; `coast` cells flag exactly where such risk is concentrated.
86
+
87
+ ## Optional coastal refinement (OSM)
88
+
89
+ For applications that need near-exact coastlines, a second dataset
90
+ (`coastal_osm_L10.tflr`, **12.7 MB**) stores OSM simplified land polygons
91
+ ([osmdata.openstreetmap.de](https://osmdata.openstreetmap.de/data/land-polygons.html))
92
+ clipped to every triangle crossed by either the Natural Earth or OSM
93
+ coastline, quantized to a cell-local 16-bit grid (~0.1 m) with delta-varint
94
+ rings. When loaded, covered answers switch from the Natural Earth base or
95
+ bulk land-fraction guess to an exact point-in-polygon test. This has
96
+ **99.95% agreement** with full polygon containment on 4,000 random points inside the
97
+ original coastal-cell sample (~23 µs per refined lookup, Python):
98
+
99
+ ```python
100
+ lc = LandCheck(refine_path="landcheck/data/coastal_osm_L10.tflr")
101
+ ```
102
+
103
+ ```js
104
+ await lc.loadRefinement("landcheck/data/coastal_osm_L10.tflr");
105
+ ```
106
+
107
+ Only covered coastline cells pay the polygon-test cost. OSM can override a
108
+ base `land` or `sea` answer when its coastline crosses a triangle that Natural
109
+ Earth classified differently.
110
+
111
+ Note on dataset semantics: the base layer keeps Natural Earth's view of
112
+ the world for `land`/`sea` kinds. NE and OSM systematically disagree
113
+ about Antarctica (OSM land polygons are clipped near the pole and draw
114
+ ice-shelf edges differently). With refinement enabled, OSM is authoritative
115
+ in every covered coastline cell.
116
+
117
+ Command-line one-off checks (uses refinement automatically when the
118
+ file is present):
119
+
120
+ ```console
121
+ $ python landcheck/python/landcheck.py 24.7536 59.4370
122
+ LAND kind=land confidence=1.000 land_fraction=1.0 cell=TFAVKGR refined=False
123
+ ```
124
+
125
+ ## Performance
126
+
127
+ | operation | speed |
128
+ |---|---|
129
+ | Python scalar `is_land` | ~13 µs/point (77k/s) |
130
+ | Python batch `is_land_batch` (numpy) | ~2.8 µs/point (360k/s) |
131
+ | JavaScript `isLand` (Node) | ~0.8 µs/point (1.2M/s) |
132
+ | dataset load | ~30 ms |
133
+
134
+ The Python library is dependency-free (stdlib only); `is_land_batch`
135
+ optionally uses numpy + the trifold SDK. The JS library is a single
136
+ ES module, works with browser + Node.
137
+
138
+ ## Files
139
+
140
+ ```
141
+ build.py TFLS builder: compacted L10 grid GeoJSON -> landsea_L10.tfls
142
+ refine_build.py TFLR builder: land polygons + TFLS -> coastal_osm_L10.tflr
143
+ python/ landcheck.py (public API) · _fastloc.py (point location)
144
+ js/landcheck.mjs the JS library (same data, same answers)
145
+ data/ landsea_L10.tfls (bundled) · coastal_osm_L10.tflr (optional)
146
+ tests/ pytest + node:test suites, shared fixture (points.json)
147
+ ```
148
+
149
+ Rebuild from a Trifold grid product:
150
+
151
+ ```bash
152
+ python landcheck/build.py # needs data/global_tri_L10_compacted.geojson
153
+ python landcheck/refine_build.py --land osm_simplified_land_polygons.geojson
154
+ python landcheck/tests/make_fixture.py # refresh cross-language fixture
155
+ pytest landcheck/tests/ && node --test landcheck/tests/test_landcheck.mjs
156
+ ```
157
+
158
+ ## Format notes
159
+
160
+ Custom data format is used to ensure compactness.
161
+
162
+ **TFLS** (land/sea runs): 16-byte header + zlib stream of
163
+ `varint(gap), varint(length<<1 | coastal)` per run, then 4-bit land
164
+ fractions for coastal cells. **TFLR** (refinement): 12-byte header +
165
+ zlib stream of `varint(Δindex), varint(code)` per covered cell, where
166
+ code 0/1 = all sea/land and code n≥2 introduces n−1 quantized
167
+ zigzag-delta rings combined by the even-odd rule. Both formats are
168
+ level-agnostic (the level lives in the header), so the same tooling can
169
+ serve an L8 (~30 KB) or L12 (~3 MB) variant.
170
+
171
+ ## Roadmap
172
+
173
+ * **Country detection**: the run-length layer maps
174
+ each cell to a country id instead of a land bit (runs split at borders;
175
+ border cells carry clipped boundary polygons like TFLR does for
176
+ coastlines). Probably can use same formats, same lookup path, same confidence model.
177
+ * Level-12 (~1.8 km trifolds) variant for higher-precision use.
178
+ * Published packages (`pip install trifold-landcheck`, npm equivalent).
@@ -0,0 +1,155 @@
1
+ # landcheck — offline land/sea lookup
2
+
3
+ This is created as Trifold application (also test and demonstration)
4
+
5
+ **Is this lat/long point on land or in the sea?** gets answered anywhere on
6
+ Earth in ~1–13 µs, fully offline, from a small **182 KB** bundled dataset —
7
+ with a confidence value for every answer. Works with Python and JavaScript.
8
+
9
+ **[Live in-browser demo](https://jaakla.github.io/trifold/landcheck.html)** —
10
+ classify sample or your own points (CSV / GeoJSON) on a map and watch the
11
+ measured lookup rate; the page embeds the real JS library and dataset.
12
+
13
+ ```python
14
+ import sys; sys.path.insert(0, "landcheck/python")
15
+ from landcheck import LandCheck
16
+
17
+ lc = LandCheck()
18
+ lc.is_land(24.7536, 59.4370) # True (lon, lat — Tallinn)
19
+ lc.check(-0.1276, 51.5072)
20
+ # LandResult(land=True, kind='land', confidence=1.0, land_fraction=1.0,
21
+ # cell='TFA95BM', refined=False)
22
+ ```
23
+
24
+ ```js
25
+ import { LandCheck } from "./landcheck/js/landcheck.mjs";
26
+ const lc = await LandCheck.fromFile(); // NodeJs takes bundled data directly
27
+ // OR:
28
+ const lc = await LandCheck.fromUrl("/data/landsea_L10.tfls"); // browser needs to load data file online
29
+ lc.isLand(24.7536, 59.437); // true
30
+ lc.check(-0.1276, 51.5072);
31
+ // { land: true, kind: 'land', confidence: 1, landFraction: 1,
32
+ // cell: 'TFA95BM', refined: false }
33
+ ```
34
+
35
+ ## How it works
36
+
37
+ The Trifold level-10 grid (~7 km triangles, 21M cells globally) is
38
+ classified against Natural Earth 1:50m land: 6.15M cells touch land,
39
+ of which 168,833 are *coastal* (mixed land/sea) and the rest are wholly
40
+ interior. Because Trifold addresses sort hierarchically, every cell at
41
+ any level maps to a contiguous range in the canonical level-10 index
42
+ space (`face·4¹⁰ + path`), so the entire classification collapses to
43
+ **153,884 run-length intervals** — 182 KB compressed, including a 4-bit
44
+ land-area fraction for every coastal cell.
45
+
46
+ A lookup is: locate the point's level-10 triangle (pure float math, no
47
+ dependencies), then binary-search the runs.
48
+
49
+ | answer kind | meaning | `land` | `confidence` |
50
+ |---|---|---|---|
51
+ | `land` | cell wholly inside land | `True` | 1.0 |
52
+ | `sea` | cell absent from dataset | `False` | 1.0 |
53
+ | `coast` | mixed cell | `land_fraction >= 0.5` | `max(f, 1−f)` |
54
+ | `coast` + `refined` | decided by OSM polygon test | exact | 0.99 |
55
+
56
+ **Measured accuracy** (30,000 uniform random points vs. exact polygon
57
+ containment in the source dataset): 99.82% agreement overall; `land` and
58
+ `sea` answers 100% correct; *all* residual error lives in `coast`
59
+ answers, which self-report their lower confidence (mean calibration
60
+ credit 0.997).
61
+
62
+ Caveats inherited from the source data: Natural Earth 1:50m treats **lakes as land and omits islets** below its resolution; `coast` cells flag exactly where such risk is concentrated.
63
+
64
+ ## Optional coastal refinement (OSM)
65
+
66
+ For applications that need near-exact coastlines, a second dataset
67
+ (`coastal_osm_L10.tflr`, **12.7 MB**) stores OSM simplified land polygons
68
+ ([osmdata.openstreetmap.de](https://osmdata.openstreetmap.de/data/land-polygons.html))
69
+ clipped to every triangle crossed by either the Natural Earth or OSM
70
+ coastline, quantized to a cell-local 16-bit grid (~0.1 m) with delta-varint
71
+ rings. When loaded, covered answers switch from the Natural Earth base or
72
+ bulk land-fraction guess to an exact point-in-polygon test. This has
73
+ **99.95% agreement** with full polygon containment on 4,000 random points inside the
74
+ original coastal-cell sample (~23 µs per refined lookup, Python):
75
+
76
+ ```python
77
+ lc = LandCheck(refine_path="landcheck/data/coastal_osm_L10.tflr")
78
+ ```
79
+
80
+ ```js
81
+ await lc.loadRefinement("landcheck/data/coastal_osm_L10.tflr");
82
+ ```
83
+
84
+ Only covered coastline cells pay the polygon-test cost. OSM can override a
85
+ base `land` or `sea` answer when its coastline crosses a triangle that Natural
86
+ Earth classified differently.
87
+
88
+ Note on dataset semantics: the base layer keeps Natural Earth's view of
89
+ the world for `land`/`sea` kinds. NE and OSM systematically disagree
90
+ about Antarctica (OSM land polygons are clipped near the pole and draw
91
+ ice-shelf edges differently). With refinement enabled, OSM is authoritative
92
+ in every covered coastline cell.
93
+
94
+ Command-line one-off checks (uses refinement automatically when the
95
+ file is present):
96
+
97
+ ```console
98
+ $ python landcheck/python/landcheck.py 24.7536 59.4370
99
+ LAND kind=land confidence=1.000 land_fraction=1.0 cell=TFAVKGR refined=False
100
+ ```
101
+
102
+ ## Performance
103
+
104
+ | operation | speed |
105
+ |---|---|
106
+ | Python scalar `is_land` | ~13 µs/point (77k/s) |
107
+ | Python batch `is_land_batch` (numpy) | ~2.8 µs/point (360k/s) |
108
+ | JavaScript `isLand` (Node) | ~0.8 µs/point (1.2M/s) |
109
+ | dataset load | ~30 ms |
110
+
111
+ The Python library is dependency-free (stdlib only); `is_land_batch`
112
+ optionally uses numpy + the trifold SDK. The JS library is a single
113
+ ES module, works with browser + Node.
114
+
115
+ ## Files
116
+
117
+ ```
118
+ build.py TFLS builder: compacted L10 grid GeoJSON -> landsea_L10.tfls
119
+ refine_build.py TFLR builder: land polygons + TFLS -> coastal_osm_L10.tflr
120
+ python/ landcheck.py (public API) · _fastloc.py (point location)
121
+ js/landcheck.mjs the JS library (same data, same answers)
122
+ data/ landsea_L10.tfls (bundled) · coastal_osm_L10.tflr (optional)
123
+ tests/ pytest + node:test suites, shared fixture (points.json)
124
+ ```
125
+
126
+ Rebuild from a Trifold grid product:
127
+
128
+ ```bash
129
+ python landcheck/build.py # needs data/global_tri_L10_compacted.geojson
130
+ python landcheck/refine_build.py --land osm_simplified_land_polygons.geojson
131
+ python landcheck/tests/make_fixture.py # refresh cross-language fixture
132
+ pytest landcheck/tests/ && node --test landcheck/tests/test_landcheck.mjs
133
+ ```
134
+
135
+ ## Format notes
136
+
137
+ Custom data format is used to ensure compactness.
138
+
139
+ **TFLS** (land/sea runs): 16-byte header + zlib stream of
140
+ `varint(gap), varint(length<<1 | coastal)` per run, then 4-bit land
141
+ fractions for coastal cells. **TFLR** (refinement): 12-byte header +
142
+ zlib stream of `varint(Δindex), varint(code)` per covered cell, where
143
+ code 0/1 = all sea/land and code n≥2 introduces n−1 quantized
144
+ zigzag-delta rings combined by the even-odd rule. Both formats are
145
+ level-agnostic (the level lives in the header), so the same tooling can
146
+ serve an L8 (~30 KB) or L12 (~3 MB) variant.
147
+
148
+ ## Roadmap
149
+
150
+ * **Country detection**: the run-length layer maps
151
+ each cell to a country id instead of a land bit (runs split at borders;
152
+ border cells carry clipped boundary polygons like TFLR does for
153
+ coastlines). Probably can use same formats, same lookup path, same confidence model.
154
+ * Level-12 (~1.8 km trifolds) variant for higher-precision use.
155
+ * Published packages (`pip install trifold-landcheck`, npm equivalent).
Binary file
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "landcheck"
7
+ version = "0.1.0"
8
+ description = "Offline land/sea point lookup: 182 KB global dataset, microsecond answers with confidence, built on the Trifold T3 triangular DGGS"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+ keywords = ["land", "sea", "ocean", "point-in-polygon", "geospatial", "offline", "dggs", "coastline"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Scientific/Engineering :: GIS",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ batch = ["numpy", "t3grid"]
28
+
29
+ [project.scripts]
30
+ landcheck = "landcheck:_main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://jaakla.github.io/trifold/landcheck.html"
34
+ Repository = "https://github.com/jaakla/trifold"
35
+
36
+ # Repo layout is python/ + data/; assemble the installable package here.
37
+ [tool.hatch.build.targets.wheel.force-include]
38
+ "python/landcheck.py" = "landcheck/__init__.py"
39
+ "python/_fastloc.py" = "landcheck/_fastloc.py"
40
+ "data/landsea_L10.tfls" = "landcheck/data/landsea_L10.tfls"
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["python/landcheck.py", "python/_fastloc.py", "data/landsea_L10.tfls", "README.md"]
@@ -0,0 +1,139 @@
1
+ """Fast scalar point location for landcheck.
2
+
3
+ A dependency-free, pure-float re-statement of ``trifold.core.locate``,
4
+ returning the canonical level-L cell index ``(face << 2L) | path_bits``
5
+ directly. The arithmetic (normalized-sum midpoints, plane-side tests
6
+ with the -1e-14 tolerance, first-match child order, max-margin fallback)
7
+ is bit-identical to the SDK implementation — the test suite cross-checks
8
+ the two on a large random sample. Roughly 20x faster per point than the
9
+ numpy-scalar SDK path; for bulk work prefer
10
+ ``trifold.api.locate_address_batch``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from math import sqrt, radians, cos, sin
15
+
16
+ _EPS = -1e-14
17
+ _LON_ROT = radians(7.3)
18
+
19
+ _FACE_INDEXES = (
20
+ (0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11),
21
+ (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6), (7, 1, 8),
22
+ (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9),
23
+ (4, 9, 5), (2, 4, 11), (6, 2, 10), (8, 6, 7), (9, 8, 1),
24
+ )
25
+
26
+
27
+ def _build_faces():
28
+ phi = (1.0 + sqrt(5.0)) / 2.0
29
+ raw = [
30
+ (-1.0, phi, 0.0), (1.0, phi, 0.0), (-1.0, -phi, 0.0), (1.0, -phi, 0.0),
31
+ (0.0, -1.0, phi), (0.0, 1.0, phi), (0.0, -1.0, -phi), (0.0, 1.0, -phi),
32
+ (phi, 0.0, -1.0), (phi, 0.0, 1.0), (-phi, 0.0, -1.0), (-phi, 0.0, 1.0),
33
+ ]
34
+ c, s = cos(_LON_ROT), sin(_LON_ROT)
35
+ verts = []
36
+ for x, y, z in raw:
37
+ n = sqrt(x * x + y * y + z * z)
38
+ x, y, z = x / n, y / n, z / n
39
+ verts.append((c * x - s * y, s * x + c * y, z))
40
+ faces = [(verts[i], verts[j], verts[k]) for i, j, k in _FACE_INDEXES]
41
+ cents = []
42
+ for v0, v1, v2 in faces:
43
+ cx, cy, cz = v0[0] + v1[0] + v2[0], v0[1] + v1[1] + v2[1], v0[2] + v1[2] + v2[2]
44
+ n = sqrt(cx * cx + cy * cy + cz * cz)
45
+ cents.append((cx / n, cy / n, cz / n))
46
+ return faces, cents
47
+
48
+
49
+ _FACES, _CENTROIDS = _build_faces()
50
+
51
+
52
+ def _mid(a, b):
53
+ x, y, z = a[0] + b[0], a[1] + b[1], a[2] + b[2]
54
+ n = sqrt(x * x + y * y + z * z)
55
+ return (x / n, y / n, z / n)
56
+
57
+
58
+ def _side(a, b, p):
59
+ """dot(cross(a, b), p)"""
60
+ return ((a[1] * b[2] - a[2] * b[1]) * p[0]
61
+ + (a[2] * b[0] - a[0] * b[2]) * p[1]
62
+ + (a[0] * b[1] - a[1] * b[0]) * p[2])
63
+
64
+
65
+ def locate_index(lon: float, lat: float, level: int) -> int:
66
+ """Canonical cell index ``(face << 2*level) | path_bits`` for a point."""
67
+ lam, phi = radians(lon), radians(lat)
68
+ cp = cos(phi)
69
+ p = (cp * cos(lam), cp * sin(lam), sin(phi))
70
+
71
+ tri = None
72
+ face = -1
73
+ for f, t in enumerate(_FACES):
74
+ v0, v1, v2 = t
75
+ if (_side(v0, v1, p) >= _EPS and _side(v1, v2, p) >= _EPS
76
+ and _side(v2, v0, p) >= _EPS):
77
+ face, tri = f, t
78
+ break
79
+ if tri is None: # numeric edge case: nearest face centroid
80
+ best = -2.0
81
+ for f, c in enumerate(_CENTROIDS):
82
+ d = c[0] * p[0] + c[1] * p[1] + c[2] * p[2]
83
+ if d > best:
84
+ best, face = d, f
85
+ tri = _FACES[face]
86
+
87
+ path = 0
88
+ v0, v1, v2 = tri
89
+ for _ in range(level):
90
+ m01, m12, m20 = _mid(v0, v1), _mid(v1, v2), _mid(v2, v0)
91
+ children = ((v0, m01, m20), (m01, v1, m12), (m20, m12, v2), (m01, m12, m20))
92
+ digit = -1
93
+ for d, (c0, c1, c2) in enumerate(children):
94
+ if (_side(c0, c1, p) >= _EPS and _side(c1, c2, p) >= _EPS
95
+ and _side(c2, c0, p) >= _EPS):
96
+ digit = d
97
+ break
98
+ if digit < 0: # tolerance fallback: max min-margin, first max
99
+ best = None
100
+ for d, (c0, c1, c2) in enumerate(children):
101
+ m = min(_side(c0, c1, p), _side(c1, c2, p), _side(c2, c0, p))
102
+ if best is None or m > best:
103
+ best, digit = m, d
104
+ v0, v1, v2 = children[digit]
105
+ path = (path << 2) | digit
106
+ return (face << (2 * level)) | path
107
+
108
+
109
+ def index_to_triangle(index: int, level: int):
110
+ """Unit-sphere vertices ``(v0, v1, v2)`` of a canonical cell index."""
111
+ face = index >> (2 * level)
112
+ path = index & ((1 << (2 * level)) - 1)
113
+ v0, v1, v2 = _FACES[face]
114
+ for shift in range(2 * (level - 1), -1, -2):
115
+ digit = (path >> shift) & 3
116
+ m01, m12, m20 = _mid(v0, v1), _mid(v1, v2), _mid(v2, v0)
117
+ if digit == 0:
118
+ v0, v1, v2 = v0, m01, m20
119
+ elif digit == 1:
120
+ v0, v1, v2 = m01, v1, m12
121
+ elif digit == 2:
122
+ v0, v1, v2 = m20, m12, v2
123
+ else:
124
+ v0, v1, v2 = m01, m12, m20
125
+ return v0, v1, v2
126
+
127
+
128
+ def index_to_lonlat_ring(index: int, level: int):
129
+ """Triangle ring in degrees, antimeridian-unwrapped to continuous
130
+ longitudes (may exceed 180), matching the grid GeoJSON convention."""
131
+ from math import atan2, asin, degrees
132
+
133
+ ring = []
134
+ for x, y, z in index_to_triangle(index, level):
135
+ ring.append((degrees(atan2(y, x)), degrees(asin(max(-1.0, min(1.0, z))))))
136
+ lons = [lon for lon, _ in ring]
137
+ if max(lons) - min(lons) > 180.0: # antimeridian crossing: unwrap east
138
+ ring = [(lon + 360.0 if lon < 0.0 else lon, lat) for lon, lat in ring]
139
+ return ring
@@ -0,0 +1,372 @@
1
+ """landcheck — offline land/sea lookup for lon/lat points (Trifold subproject).
2
+
3
+ A ~180 KB bundled dataset answers "is this point on land?" anywhere on
4
+ Earth in microseconds, fully offline. Built from the Trifold level-10
5
+ grid (~7 km triangles) classified against Natural Earth 1:50m land.
6
+
7
+ >>> from landcheck import LandCheck
8
+ >>> lc = LandCheck()
9
+ >>> lc.is_land(24.75, 59.44) # lon, lat — Tallinn
10
+ True
11
+ >>> r = lc.check(-0.1276, 51.5072) # London
12
+ >>> r.land, r.kind, round(r.confidence, 2)
13
+ (True, 'land', 1.0)
14
+
15
+ Answer semantics
16
+ ----------------
17
+ kind='land' cell is wholly inside land -> land, confidence 1.0
18
+ kind='sea' cell absent from the dataset -> sea, confidence 1.0
19
+ kind='coast' mixed cell; the bundled land fraction of the cell is used:
20
+ land = fraction >= 0.5, confidence = max(f, 1 - f).
21
+
22
+ With optional OSM refinement loaded, cells crossed by either source's
23
+ coastline are decided by a clipped OSM polygon before the Natural Earth base
24
+ classification is accepted.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import struct
29
+ import zlib
30
+ from array import array
31
+ from bisect import bisect_right
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ try:
36
+ from ._fastloc import (locate_index as _locate_index,
37
+ index_to_lonlat_ring as _index_to_ring)
38
+ except ImportError: # imported as a plain module, not a package
39
+ from _fastloc import (locate_index as _locate_index,
40
+ index_to_lonlat_ring as _index_to_ring)
41
+
42
+
43
+ def _ringbox(index: int, level: int) -> tuple[float, float, float, float]:
44
+ ring = _index_to_ring(index, level)
45
+ lons = [p[0] for p in ring]
46
+ lats = [p[1] for p in ring]
47
+ return min(lons), min(lats), max(lons), max(lats)
48
+
49
+ __all__ = ["LandCheck", "LandResult"]
50
+
51
+ _B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford, as trifold.address
52
+
53
+
54
+ def _index_to_compact(index: int, level: int) -> str:
55
+ """Compact Trifold address of a canonical cell index (e.g. 'TFAVKGR')."""
56
+ face = index >> (2 * level)
57
+ path = index & ((1 << (2 * level)) - 1)
58
+ bits = 2 * level
59
+ pad = (-bits) % 5
60
+ path <<= pad
61
+ chars = []
62
+ for shift in range(bits + pad - 5, -1, -5):
63
+ chars.append(_B32[(path >> shift) & 0x1F])
64
+ return "T" + _B32[face] + _B32[level] + "".join(chars)
65
+
66
+ _MAGIC = b"TFLS"
67
+
68
+ def _find_default_data() -> Path:
69
+ here = Path(__file__).resolve().parent
70
+ for cand in (here / "data" / "landsea_L10.tfls", # installed package
71
+ here.parent / "data" / "landsea_L10.tfls"): # repo checkout
72
+ if cand.is_file():
73
+ return cand
74
+ return cand
75
+
76
+ _DEFAULT_DATA = _find_default_data()
77
+
78
+ _KIND_LAND = "land"
79
+ _KIND_COAST = "coast"
80
+ _KIND_SEA = "sea"
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class LandResult:
85
+ """One lookup answer."""
86
+ land: bool #: best land/sea call
87
+ kind: str #: 'land' | 'coast' | 'sea'
88
+ confidence: float #: probability the ``land`` bool is right
89
+ land_fraction: float | None #: land share of the cell (None if not bundled)
90
+ cell: str | None #: compact Trifold address, None for open sea
91
+ refined: bool = False #: True if a coastal refinement polygon decided it
92
+
93
+
94
+ _REFINED_CONFIDENCE = 0.99 # OSM simplified polygons; quantization ~0.1 m
95
+
96
+
97
+ class LandCheck:
98
+ """Offline land/sea point lookup. Thread-safe after construction.
99
+
100
+ ``refine_path`` optionally loads a TFLR coastal-refinement dataset
101
+ (see refine_build.py). Covered cells are decided by a point-in-polygon
102
+ test against clipped OSM land polygons before the base classification.
103
+ """
104
+
105
+ def __init__(self, data_path: str | Path = _DEFAULT_DATA,
106
+ refine_path: str | Path | None = None):
107
+ raw = Path(data_path).read_bytes()
108
+ magic, version, level, flags, _, n_runs, n_coast = struct.unpack_from(
109
+ "<4sBBBBII", raw, 0)
110
+ if magic != _MAGIC:
111
+ raise ValueError(f"{data_path}: not a TFLS file")
112
+ if version != 1:
113
+ raise ValueError(f"{data_path}: unsupported TFLS version {version}")
114
+ self.level = level
115
+ body = zlib.decompress(raw[16:])
116
+
117
+ starts = array("I")
118
+ ends = array("I")
119
+ coastal = bytearray(n_runs)
120
+ coast_before = array("I") # coastal cells in runs before this one
121
+ pos = 0
122
+ cursor = 0
123
+ n_coast_seen = 0
124
+
125
+ def read_varint() -> int:
126
+ nonlocal pos
127
+ shift = 0
128
+ value = 0
129
+ while True:
130
+ b = body[pos]
131
+ pos += 1
132
+ value |= (b & 0x7F) << shift
133
+ if not b & 0x80:
134
+ return value
135
+ shift += 7
136
+
137
+ for i in range(n_runs):
138
+ cursor += read_varint()
139
+ packed = read_varint()
140
+ length = packed >> 1
141
+ starts.append(cursor)
142
+ ends.append(cursor + length)
143
+ coast_before.append(n_coast_seen)
144
+ if packed & 1:
145
+ coastal[i] = 1
146
+ n_coast_seen += length
147
+ cursor += length
148
+ if n_coast_seen != n_coast:
149
+ raise ValueError(f"{data_path}: coastal count mismatch")
150
+
151
+ self._starts = starts
152
+ self._ends = ends
153
+ self._coastal = bytes(coastal)
154
+ self._coast_before = coast_before
155
+ self._fractions = body[pos:] if flags & 1 else None
156
+ if self._fractions is not None and len(self._fractions) < (n_coast + 1) // 2:
157
+ raise ValueError(f"{data_path}: truncated fraction block")
158
+
159
+ self._refine = None
160
+ if refine_path is not None:
161
+ self.load_refinement(refine_path)
162
+
163
+ def load_refinement(self, path: str | Path) -> None:
164
+ """Load a TFLR coastal-refinement dataset (built by refine_build.py)."""
165
+ raw = Path(path).read_bytes()
166
+ magic, version, level, _, _, n_cells = struct.unpack_from("<4sBBBBI", raw, 0)
167
+ if magic != b"TFLR":
168
+ raise ValueError(f"{path}: not a TFLR file")
169
+ if version != 1:
170
+ raise ValueError(f"{path}: unsupported TFLR version {version}")
171
+ if level != self.level:
172
+ raise ValueError(f"{path}: level {level} != dataset level {self.level}")
173
+ body = zlib.decompress(raw[12:])
174
+ pos = 0
175
+
176
+ def read_varint() -> int:
177
+ nonlocal pos
178
+ shift = 0
179
+ value = 0
180
+ while True:
181
+ b = body[pos]
182
+ pos += 1
183
+ value |= (b & 0x7F) << shift
184
+ if not b & 0x80:
185
+ return value
186
+ shift += 7
187
+
188
+ cells = {}
189
+ index = 0
190
+ for _ in range(n_cells):
191
+ index += read_varint()
192
+ code = read_varint()
193
+ if code < 2:
194
+ cells[index] = code # 0 = all sea, 1 = all land
195
+ else:
196
+ rings = []
197
+ for _ in range(code - 1):
198
+ n_pts = read_varint()
199
+ pts = array("i")
200
+ x = y = 0
201
+ for _ in range(n_pts):
202
+ zx, zy = read_varint(), read_varint()
203
+ x += (zx >> 1) ^ -(zx & 1)
204
+ y += (zy >> 1) ^ -(zy & 1)
205
+ pts.append(x)
206
+ pts.append(y)
207
+ rings.append(pts)
208
+ cells[index] = rings
209
+ self._refine = cells
210
+
211
+ def _refined_land(self, index: int, lon: float, lat: float) -> bool | None:
212
+ """Exact land/sea from the refinement polygons, None if unavailable."""
213
+ if self._refine is None:
214
+ return None
215
+ entry = self._refine.get(index)
216
+ if entry is None:
217
+ return None
218
+ if isinstance(entry, int):
219
+ return bool(entry)
220
+ ring = _ringbox(index, self.level)
221
+ (minx, miny, maxx, maxy) = ring
222
+ if maxx > 180.0 and lon < 0.0:
223
+ lon += 360.0
224
+ qx = (lon - minx) * 65535.0 / (maxx - minx)
225
+ qy = (lat - miny) * 65535.0 / (maxy - miny)
226
+ inside = False # even-odd rule over all rings
227
+ for pts in entry:
228
+ n = len(pts) // 2
229
+ x1, y1 = pts[2 * (n - 1)], pts[2 * (n - 1) + 1]
230
+ for i in range(n):
231
+ x2, y2 = pts[2 * i], pts[2 * i + 1]
232
+ if (y1 > qy) != (y2 > qy):
233
+ if qx < x1 + (qy - y1) * (x2 - x1) / (y2 - y1):
234
+ inside = not inside
235
+ x1, y1 = x2, y2
236
+ return inside
237
+
238
+ # ------------------------------------------------------------- lookups
239
+ def check(self, lon: float, lat: float) -> LandResult:
240
+ """Full answer for one point (lon, lat in degrees, WGS84)."""
241
+ if not -180.0 <= lon <= 180.0:
242
+ raise ValueError("longitude must be in [-180, 180]")
243
+ if not -90.0 <= lat <= 90.0:
244
+ raise ValueError("latitude must be in [-90, 90]")
245
+ index = _locate_index(lon, lat, self.level)
246
+ run = bisect_right(self._starts, index) - 1
247
+ hit = run >= 0 and index < self._ends[run]
248
+ refined = self._refined_land(index, lon, lat)
249
+ if refined is not None:
250
+ fraction = (self._fraction_at(run, index)
251
+ if hit and self._coastal[run]
252
+ else (1.0 if hit else 0.0))
253
+ return LandResult(refined, _KIND_COAST, _REFINED_CONFIDENCE,
254
+ fraction, _index_to_compact(index, self.level),
255
+ refined=True)
256
+ if not hit:
257
+ return LandResult(False, _KIND_SEA, 1.0, 0.0, None)
258
+ cell = _index_to_compact(index, self.level)
259
+ if not self._coastal[run]:
260
+ return LandResult(True, _KIND_LAND, 1.0, 1.0, cell)
261
+ fraction = self._fraction_at(run, index)
262
+ if fraction is None:
263
+ return LandResult(True, _KIND_COAST, 0.5, None, cell)
264
+ return LandResult(fraction >= 0.5, _KIND_COAST,
265
+ max(fraction, 1.0 - fraction), fraction, cell)
266
+
267
+ def is_land(self, lon: float, lat: float) -> bool:
268
+ """Best land/sea bool for one point."""
269
+ index = _locate_index(lon, lat, self.level)
270
+ refined = self._refined_land(index, lon, lat)
271
+ if refined is not None:
272
+ return refined
273
+ run = bisect_right(self._starts, index) - 1
274
+ if run < 0 or index >= self._ends[run]:
275
+ return False
276
+ if not self._coastal[run]:
277
+ return True
278
+ fraction = self._fraction_at(run, index)
279
+ return True if fraction is None else fraction >= 0.5
280
+
281
+ def is_land_batch(self, lons, lats):
282
+ """Vectorised lookup for many points (requires numpy + trifold).
283
+
284
+ Returns a boolean numpy array; orders of magnitude faster than a
285
+ Python loop for large inputs.
286
+ """
287
+ import numpy as np
288
+ from trifold.api import locate_address_batch
289
+
290
+ addrs = locate_address_batch(lons, lats, self.level)
291
+ index = (addrs >> np.uint64(59 - 2 * self.level)).astype(np.int64)
292
+ starts = np.frombuffer(self._starts, dtype=np.uint32).astype(np.int64)
293
+ ends = np.frombuffer(self._ends, dtype=np.uint32).astype(np.int64)
294
+ run = np.searchsorted(starts, index, side="right") - 1
295
+ hit = (run >= 0) & (index < ends[np.maximum(run, 0)])
296
+ out = np.zeros(len(index), dtype=bool)
297
+ coastal = np.frombuffer(self._coastal, dtype=np.uint8).astype(bool)
298
+ hit_runs = run[hit]
299
+ is_coast = coastal[hit_runs]
300
+ land = ~is_coast
301
+ if self._fractions is not None and is_coast.any():
302
+ coast_before = np.frombuffer(self._coast_before,
303
+ dtype=np.uint32).astype(np.int64)
304
+ n = (coast_before[hit_runs] + index[hit] - starts[hit_runs])[is_coast]
305
+ nib = np.frombuffer(self._fractions, dtype=np.uint8)[n >> 1]
306
+ q = np.where(n & 1, nib >> 4, nib & 0x0F)
307
+ land = land.copy()
308
+ land[is_coast] = q >= 8 # fraction (q+0.5)/16 >= 0.5
309
+ else:
310
+ land = land | is_coast # no fractions: coast counts as land
311
+ out[hit] = land
312
+ if self._refine:
313
+ for pos, cell_index in enumerate(index):
314
+ idx = int(cell_index)
315
+ if idx in self._refine:
316
+ refined = self._refined_land(
317
+ idx, float(lons[pos]), float(lats[pos]))
318
+ if refined is not None:
319
+ out[pos] = refined
320
+ return out
321
+
322
+ # ------------------------------------------------------------- helpers
323
+ def _fraction_at(self, run: int, index: int) -> float | None:
324
+ if self._fractions is None:
325
+ return None
326
+ n = self._coast_before[run] + (index - self._starts[run])
327
+ byte = self._fractions[n >> 1]
328
+ q = (byte >> 4) if n & 1 else (byte & 0x0F)
329
+ return (q + 0.5) / 16.0
330
+
331
+ @property
332
+ def stats(self) -> dict:
333
+ """Dataset summary (for diagnostics)."""
334
+ n_coast = sum(e - s for s, e, c in
335
+ zip(self._starts, self._ends, self._coastal) if c)
336
+ n_land = sum(e - s for s, e, c in
337
+ zip(self._starts, self._ends, self._coastal) if not c)
338
+ return {
339
+ "level": self.level,
340
+ "runs": len(self._starts),
341
+ "interior_cells": n_land,
342
+ "coastal_cells": n_coast,
343
+ "has_fractions": self._fractions is not None,
344
+ "has_refinement": self._refine is not None,
345
+ }
346
+
347
+
348
+ def _main(argv=None) -> int:
349
+ import argparse
350
+
351
+ ap = argparse.ArgumentParser(
352
+ prog="landcheck", description="offline land/sea lookup for a point")
353
+ ap.add_argument("lon", type=float, help="longitude (-180..180)")
354
+ ap.add_argument("lat", type=float, help="latitude (-90..90)")
355
+ ap.add_argument("--data", default=_DEFAULT_DATA)
356
+ ap.add_argument("--refine", default=None,
357
+ help="optional TFLR coastal-refinement file")
358
+ args = ap.parse_args(argv)
359
+ refine = args.refine
360
+ if refine is None:
361
+ default_tflr = Path(args.data).parent / "coastal_osm_L10.tflr"
362
+ refine = default_tflr if default_tflr.exists() else None
363
+ result = LandCheck(args.data, refine_path=refine).check(args.lon, args.lat)
364
+ print(f"{'LAND' if result.land else 'SEA'} kind={result.kind} "
365
+ f"confidence={result.confidence:.3f} "
366
+ f"land_fraction={result.land_fraction} cell={result.cell} "
367
+ f"refined={result.refined}")
368
+ return 0
369
+
370
+
371
+ if __name__ == "__main__":
372
+ raise SystemExit(_main())