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.
- landcheck-0.1.0/.gitignore +15 -0
- landcheck-0.1.0/PKG-INFO +178 -0
- landcheck-0.1.0/README.md +155 -0
- landcheck-0.1.0/data/landsea_L10.tfls +0 -0
- landcheck-0.1.0/pyproject.toml +43 -0
- landcheck-0.1.0/python/_fastloc.py +139 -0
- landcheck-0.1.0/python/landcheck.py +372 -0
|
@@ -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
|
landcheck-0.1.0/PKG-INFO
ADDED
|
@@ -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())
|