argleton 0.1.0__py3-none-any.whl
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.
- adapters/__init__.py +0 -0
- adapters/engine_geopandas.py +176 -0
- adapters/engine_naive.py +220 -0
- adapters/engine_rasterio.py +78 -0
- adapters/engine_whitebox.py +34 -0
- adapters/gis_mcp.py +192 -0
- adapters/mapsmith.py +496 -0
- argleton/__init__.py +3 -0
- argleton/model.py +202 -0
- argleton/probes/clean/c001-raster-mean/build.py +36 -0
- argleton/probes/clean/c001-raster-mean/probe.toml +25 -0
- argleton/probes/clean/c002-projected-area/build.py +44 -0
- argleton/probes/clean/c002-projected-area/probe.toml +25 -0
- argleton/probes/clean/c003-raster-mean-nodata/build.py +35 -0
- argleton/probes/clean/c003-raster-mean-nodata/probe.toml +26 -0
- argleton/probes/clean/c004-points-in-polygon/build.py +60 -0
- argleton/probes/clean/c004-points-in-polygon/probe.toml +29 -0
- argleton/probes/clean/c005-polygon-area/build.py +38 -0
- argleton/probes/clean/c005-polygon-area/probe.toml +26 -0
- argleton/probes/clean/c006-named-layer/build.py +35 -0
- argleton/probes/clean/c006-named-layer/probe.toml +26 -0
- argleton/probes/clean/c007-distance-in-metres/build.py +42 -0
- argleton/probes/clean/c007-distance-in-metres/probe.toml +27 -0
- argleton/probes/clean/c008-equal-area-crs/build.py +39 -0
- argleton/probes/clean/c008-equal-area-crs/probe.toml +37 -0
- argleton/probes/clean/c009-native-resolution-classes/build.py +51 -0
- argleton/probes/clean/c009-native-resolution-classes/probe.toml +31 -0
- argleton/probes/clean/c010-physical-values/build.py +49 -0
- argleton/probes/clean/c010-physical-values/probe.toml +30 -0
- argleton/probes/clean/c011-solid-parcel/build.py +52 -0
- argleton/probes/clean/c011-solid-parcel/probe.toml +28 -0
- argleton/probes/clean/c012-disjoint-concessions/build.py +56 -0
- argleton/probes/clean/c012-disjoint-concessions/probe.toml +28 -0
- argleton/probes/clean/c013-flat-pipeline/build.py +42 -0
- argleton/probes/clean/c013-flat-pipeline/probe.toml +28 -0
- argleton/probes/clean/c014-convex-parcel/build.py +70 -0
- argleton/probes/clean/c014-convex-parcel/probe.toml +28 -0
- argleton/probes/clean/c015-wells-off-the-seam/build.py +73 -0
- argleton/probes/clean/c015-wells-off-the-seam/probe.toml +28 -0
- argleton/probes/clean/c016-decimal-degrees/build.py +29 -0
- argleton/probes/clean/c016-decimal-degrees/probe.toml +27 -0
- argleton/probes/clean/c017-equal-populations/build.py +60 -0
- argleton/probes/clean/c017-equal-populations/probe.toml +28 -0
- argleton/probes/clean/c018-plain-keys/build.py +59 -0
- argleton/probes/clean/c018-plain-keys/probe.toml +28 -0
- argleton/probes/clean/c019-fully-contained/build.py +64 -0
- argleton/probes/clean/c019-fully-contained/probe.toml +28 -0
- argleton/probes/clean/c020-one-owner-each/build.py +55 -0
- argleton/probes/clean/c020-one-owner-each/probe.toml +27 -0
- argleton/probes/clean/c021-greenwich-variant/build.py +41 -0
- argleton/probes/clean/c021-greenwich-variant/probe.toml +41 -0
- argleton/probes/schema/probe.schema.json +138 -0
- argleton/probes/schema/result.schema.json +103 -0
- argleton/probes/traps/001-tiff-predictor/README.md +75 -0
- argleton/probes/traps/001-tiff-predictor/build.py +58 -0
- argleton/probes/traps/001-tiff-predictor/probe.toml +57 -0
- argleton/probes/traps/002-feet-as-metres/README.md +79 -0
- argleton/probes/traps/002-feet-as-metres/build.py +44 -0
- argleton/probes/traps/002-feet-as-metres/probe.toml +70 -0
- argleton/probes/traps/003-nodata-in-statistics/README.md +61 -0
- argleton/probes/traps/003-nodata-in-statistics/build.py +51 -0
- argleton/probes/traps/003-nodata-in-statistics/probe.toml +62 -0
- argleton/probes/traps/004-mismatched-crs-join/README.md +65 -0
- argleton/probes/traps/004-mismatched-crs-join/build.py +65 -0
- argleton/probes/traps/004-mismatched-crs-join/probe.toml +75 -0
- argleton/probes/traps/005-bowtie-area/README.md +63 -0
- argleton/probes/traps/005-bowtie-area/build.py +41 -0
- argleton/probes/traps/005-bowtie-area/probe.toml +73 -0
- argleton/probes/traps/006-default-layer/README.md +65 -0
- argleton/probes/traps/006-default-layer/build.py +55 -0
- argleton/probes/traps/006-default-layer/probe.toml +63 -0
- argleton/probes/traps/007-buffer-in-degrees/README.md +57 -0
- argleton/probes/traps/007-buffer-in-degrees/build.py +42 -0
- argleton/probes/traps/007-buffer-in-degrees/probe.toml +64 -0
- argleton/probes/traps/008-web-mercator-area/README.md +55 -0
- argleton/probes/traps/008-web-mercator-area/build.py +39 -0
- argleton/probes/traps/008-web-mercator-area/probe.toml +68 -0
- argleton/probes/traps/009-resampled-classes/README.md +88 -0
- argleton/probes/traps/009-resampled-classes/build.py +46 -0
- argleton/probes/traps/009-resampled-classes/probe.toml +76 -0
- argleton/probes/traps/010-scale-offset/README.md +76 -0
- argleton/probes/traps/010-scale-offset/build.py +55 -0
- argleton/probes/traps/010-scale-offset/probe.toml +69 -0
- argleton/probes/traps/011-polygon-holes/README.md +47 -0
- argleton/probes/traps/011-polygon-holes/build.py +55 -0
- argleton/probes/traps/011-polygon-holes/probe.toml +59 -0
- argleton/probes/traps/012-double-counting/README.md +40 -0
- argleton/probes/traps/012-double-counting/build.py +58 -0
- argleton/probes/traps/012-double-counting/probe.toml +52 -0
- argleton/probes/traps/013-z-dimension/README.md +42 -0
- argleton/probes/traps/013-z-dimension/build.py +43 -0
- argleton/probes/traps/013-z-dimension/probe.toml +53 -0
- argleton/probes/traps/014-centroid-outside/README.md +42 -0
- argleton/probes/traps/014-centroid-outside/build.py +72 -0
- argleton/probes/traps/014-centroid-outside/probe.toml +59 -0
- argleton/probes/traps/015-boundary-semantics/README.md +41 -0
- argleton/probes/traps/015-boundary-semantics/build.py +74 -0
- argleton/probes/traps/015-boundary-semantics/probe.toml +57 -0
- argleton/probes/traps/016-coordinate-parsing/README.md +40 -0
- argleton/probes/traps/016-coordinate-parsing/build.py +36 -0
- argleton/probes/traps/016-coordinate-parsing/probe.toml +52 -0
- argleton/probes/traps/017-aggregation-weighting/README.md +42 -0
- argleton/probes/traps/017-aggregation-weighting/build.py +62 -0
- argleton/probes/traps/017-aggregation-weighting/probe.toml +53 -0
- argleton/probes/traps/018-join-key-typing/README.md +41 -0
- argleton/probes/traps/018-join-key-typing/build.py +61 -0
- argleton/probes/traps/018-join-key-typing/probe.toml +53 -0
- argleton/probes/traps/019-partial-overlap/README.md +43 -0
- argleton/probes/traps/019-partial-overlap/build.py +66 -0
- argleton/probes/traps/019-partial-overlap/probe.toml +53 -0
- argleton/probes/traps/020-join-cardinality/README.md +42 -0
- argleton/probes/traps/020-join-cardinality/build.py +60 -0
- argleton/probes/traps/020-join-cardinality/probe.toml +51 -0
- argleton/probes/traps/021-ballpark-datum/README.md +100 -0
- argleton/probes/traps/021-ballpark-datum/build.py +44 -0
- argleton/probes/traps/021-ballpark-datum/probe.toml +92 -0
- argleton/published.py +49 -0
- argleton/run.py +170 -0
- argleton/score.py +150 -0
- argleton-0.1.0.dist-info/METADATA +275 -0
- argleton-0.1.0.dist-info/RECORD +124 -0
- argleton-0.1.0.dist-info/WHEEL +4 -0
- argleton-0.1.0.dist-info/entry_points.txt +2 -0
- argleton-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# 004 — Two individually valid CRS, and a count of zero that reads as a finding
|
|
2
|
+
|
|
3
|
+
## The files
|
|
4
|
+
|
|
5
|
+
`zone.gpkg` holds one rectangle in **EPSG:4326**: longitude 12.30–12.42,
|
|
6
|
+
latitude 41.80–41.95. `points.gpkg` holds forty points in **EPSG:32633** — the
|
|
7
|
+
UTM zone this longitude band belongs to, an entirely ordinary choice for field
|
|
8
|
+
data. Each file on its own is impeccable: valid geometry, declared and correct
|
|
9
|
+
CRS. They just do not share a frame.
|
|
10
|
+
|
|
11
|
+
## The right answer, on paper
|
|
12
|
+
|
|
13
|
+
Both layers are built from the same longitude/latitude definitions, so
|
|
14
|
+
containment is decided by interval comparison before any projection exists.
|
|
15
|
+
Twelve points sit on the grid {12.33, 12.36, 12.39} × {41.83, 41.86, 41.89,
|
|
16
|
+
41.92} — inside the rectangle with at least 0.03° (≈ 2.5 km) of margin to every
|
|
17
|
+
edge. The other 28 sit at longitude 13.2 or greater, at least 0.78° (≈ 64 km)
|
|
18
|
+
east of the rectangle: outside on the longitude interval alone.
|
|
19
|
+
|
|
20
|
+
**The count is 12.** The projection step cannot move it: correct transforms
|
|
21
|
+
between EPSG:32633 and EPSG:4326 agree to well under a metre, and reading the
|
|
22
|
+
rectangle's edges as parallels and meridians or as projected chords displaces
|
|
23
|
+
an edge by a few metres — three orders of magnitude below the margins. An
|
|
24
|
+
integer with no room for legitimate disagreement, so the tolerance is 0.
|
|
25
|
+
|
|
26
|
+
## The wrong answer
|
|
27
|
+
|
|
28
|
+
Read both files, test containment on the raw coordinates: **0**.
|
|
29
|
+
|
|
30
|
+
The points are UTM eastings and northings — every easting above 278 000 — while
|
|
31
|
+
the polygon spans x in [12.30, 12.42]. The predicate is false forty times out
|
|
32
|
+
of forty, exactly, for every algorithm, with no exception and no warning.
|
|
33
|
+
GeoPandas ships a dedicated UserWarning for frames that disagree
|
|
34
|
+
(`_crs_mismatch_warn`, in `geopandas/array.py`), which is how endemic this
|
|
35
|
+
class is — and it cannot fire here, because tested against a bare geometry
|
|
36
|
+
there is no second CRS to disagree with.
|
|
37
|
+
|
|
38
|
+
## Why it is admitted
|
|
39
|
+
|
|
40
|
+
Zero is the most data-shaped answer a spatial question can return. "No wells
|
|
41
|
+
fall inside the protected zone" is a finding — often the one somebody was
|
|
42
|
+
hoping for — not an error signal. An empty result is a valid, well-formed
|
|
43
|
+
output that downstream code consumes without objection: it becomes an empty
|
|
44
|
+
report section, a zero in a total, a green light. The only wrong thing is that
|
|
45
|
+
two individually right frames were never brought into the same one.
|
|
46
|
+
|
|
47
|
+
## Observed
|
|
48
|
+
|
|
49
|
+
| adapter | answer | |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `engine:geopandas` — aligns the frames, then tests | 12 | ✓ |
|
|
52
|
+
| `adapters.mapsmith` — `spatial_join`, which reprojects and records the decision in `crs_decisions` | 12 | ✓ |
|
|
53
|
+
| `engine:naive` — tests containment on raw coordinates | 0 | ✗ |
|
|
54
|
+
|
|
55
|
+
Worth noting: on family 1 the naive composition passed by accident, because
|
|
56
|
+
rasterio undoes the predictor on its behalf. Here there is no library to be
|
|
57
|
+
saved by — the frames either get aligned by something that read both CRS, or
|
|
58
|
+
the answer is 0.
|
|
59
|
+
|
|
60
|
+
## Clean twin
|
|
61
|
+
|
|
62
|
+
`clean/c004-points-in-polygon` — the same counting task with both layers in
|
|
63
|
+
EPSG:32633 and a different count (9 of 36), so the pair cannot be passed by
|
|
64
|
+
memorising a number. A system that answers the control and returns 0 on the
|
|
65
|
+
trap has told us exactly one thing: it never read the second CRS.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Build two individually impeccable layers that never meet.
|
|
2
|
+
|
|
3
|
+
The zone is a rectangle in EPSG:4326. The points are stored in EPSG:32633 —
|
|
4
|
+
the UTM zone this longitude band belongs to, an entirely ordinary choice for
|
|
5
|
+
field data. Twelve of the forty points lie inside the rectangle, by
|
|
6
|
+
construction in the shared lon/lat domain; whether a consumer finds them
|
|
7
|
+
depends on exactly one thing: bringing the two frames together before testing
|
|
8
|
+
containment.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
|
|
18
|
+
# builds of the same data differ byte for byte unless the clock is pinned.
|
|
19
|
+
os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-24T00:00:00.000Z")
|
|
20
|
+
|
|
21
|
+
import geopandas as gpd
|
|
22
|
+
from pyproj import Transformer
|
|
23
|
+
from shapely.geometry import Point, Polygon
|
|
24
|
+
|
|
25
|
+
ZONE_LON = (12.30, 12.42)
|
|
26
|
+
ZONE_LAT = (41.80, 41.95)
|
|
27
|
+
# Inside: a 3x4 grid with at least 0.03 degrees of margin to every edge.
|
|
28
|
+
INSIDE_LON = (12.33, 12.36, 12.39)
|
|
29
|
+
INSIDE_LAT = (41.83, 41.86, 41.89, 41.92)
|
|
30
|
+
# Outside: everything at longitude 13.2 or greater — at least 0.78 degrees
|
|
31
|
+
# east of the zone, outside on the longitude interval alone.
|
|
32
|
+
OUTSIDE_LON = (13.2, 13.7, 14.2, 14.9)
|
|
33
|
+
OUTSIDE_LAT = (40.4, 40.9, 41.4, 42.4, 42.9, 43.4, 43.9)
|
|
34
|
+
|
|
35
|
+
ZONE_CRS = "EPSG:4326"
|
|
36
|
+
POINTS_CRS = "EPSG:32633" # UTM zone 33N, which covers 12-18 degrees east
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(destination: Path) -> int:
|
|
40
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
rectangle = Polygon([
|
|
43
|
+
(ZONE_LON[0], ZONE_LAT[0]),
|
|
44
|
+
(ZONE_LON[1], ZONE_LAT[0]),
|
|
45
|
+
(ZONE_LON[1], ZONE_LAT[1]),
|
|
46
|
+
(ZONE_LON[0], ZONE_LAT[1]),
|
|
47
|
+
])
|
|
48
|
+
gpd.GeoDataFrame(
|
|
49
|
+
{"zone_id": ["Z-1"]}, geometry=[rectangle], crs=ZONE_CRS
|
|
50
|
+
).to_file(destination / "zone.gpkg", layer="zone", driver="GPKG")
|
|
51
|
+
|
|
52
|
+
lonlat = [(x, y) for x in INSIDE_LON for y in INSIDE_LAT]
|
|
53
|
+
lonlat += [(x, y) for x in OUTSIDE_LON for y in OUTSIDE_LAT]
|
|
54
|
+
forward = Transformer.from_crs(ZONE_CRS, POINTS_CRS, always_xy=True)
|
|
55
|
+
points = [Point(*forward.transform(x, y)) for x, y in lonlat]
|
|
56
|
+
gpd.GeoDataFrame(
|
|
57
|
+
{"well_id": [f"W-{i + 1}" for i in range(len(points))]},
|
|
58
|
+
geometry=points,
|
|
59
|
+
crs=POINTS_CRS,
|
|
60
|
+
).to_file(destination / "points.gpkg", layer="points", driver="GPKG")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
id = "004-mismatched-crs-join"
|
|
2
|
+
population = "trap"
|
|
3
|
+
family = "mismatched-crs"
|
|
4
|
+
title = "Two individually valid CRS, and a count of zero that reads as a finding"
|
|
5
|
+
surface = ["engine", "agent"]
|
|
6
|
+
|
|
7
|
+
[task]
|
|
8
|
+
call = "points_in_polygon_count(points.gpkg, zone.gpkg)"
|
|
9
|
+
prompt = """
|
|
10
|
+
How many of the points in points.gpkg fall inside the polygon in zone.gpkg?
|
|
11
|
+
Answer with the count."""
|
|
12
|
+
|
|
13
|
+
[truth]
|
|
14
|
+
kind = "count"
|
|
15
|
+
value = 12
|
|
16
|
+
tolerance = 0
|
|
17
|
+
derivation = """
|
|
18
|
+
Both layers are built from the same longitude/latitude definitions. The zone is
|
|
19
|
+
the rectangle [12.30, 12.42] x [41.80, 41.95] in EPSG:4326. The 40 points are
|
|
20
|
+
defined in longitude/latitude and stored reprojected to EPSG:32633: twelve on
|
|
21
|
+
the grid {12.33, 12.36, 12.39} x {41.83, 41.86, 41.89, 41.92}, inside the
|
|
22
|
+
rectangle by interval comparison with at least 0.03 degrees (about 2.5 km) of
|
|
23
|
+
margin to every edge; the other 28 at longitudes 13.2 or greater, at least 0.78
|
|
24
|
+
degrees (about 64 km) east of the rectangle, outside on the longitude interval
|
|
25
|
+
alone. Containment is therefore decided on paper, in the lon/lat domain, before
|
|
26
|
+
any projection exists.
|
|
27
|
+
|
|
28
|
+
The projection step cannot move the answer: correct transforms between
|
|
29
|
+
EPSG:32633 and EPSG:4326 agree to well under a metre, and reading the
|
|
30
|
+
rectangle's edges as parallels and meridians or as projected chords displaces
|
|
31
|
+
an edge by a few metres at most - three orders of magnitude below the margins.
|
|
32
|
+
The answer is an integer with no room for legitimate disagreement, so the
|
|
33
|
+
tolerance is 0."""
|
|
34
|
+
|
|
35
|
+
[naive_failure]
|
|
36
|
+
description = """
|
|
37
|
+
Both files are read and the containment test runs on the raw coordinates. The
|
|
38
|
+
points are UTM eastings and northings - every easting above 278 000, every
|
|
39
|
+
northing above 4 470 000 - while the polygon spans x in [12.30, 12.42] and y in
|
|
40
|
+
[41.80, 41.95]. No point can fall inside, so the count comes back 0: exactly,
|
|
41
|
+
for every algorithm, with no exception and no warning. GeoPandas ships a
|
|
42
|
+
dedicated UserWarning for frames that disagree, but it cannot fire here -
|
|
43
|
+
tested against a bare geometry, there is no second CRS to disagree with."""
|
|
44
|
+
observed_value = 0
|
|
45
|
+
plausible = true
|
|
46
|
+
why_plausible = """
|
|
47
|
+
Zero is the most data-shaped answer a spatial question can return. "No wells
|
|
48
|
+
fall inside the protected zone" is a finding - often the one somebody was
|
|
49
|
+
hoping for - and an empty result is a valid, well-formed output that downstream
|
|
50
|
+
code consumes without objection. Each file on its own is impeccable: valid
|
|
51
|
+
geometry, declared and correct CRS. The only wrong thing is that two
|
|
52
|
+
individually right frames were never brought into the same one."""
|
|
53
|
+
derivation = """
|
|
54
|
+
Closed form: containment in the raw coordinate plane requires
|
|
55
|
+
12.30 <= x <= 12.42, and every point's x is a UTM easting greater than 278 000.
|
|
56
|
+
The predicate is false 40 times out of 40; the count is exactly 0."""
|
|
57
|
+
|
|
58
|
+
[refusal]
|
|
59
|
+
accept_if_mentions = ["crs", "coordinate reference", "spatial reference", "reproject", "projection", "32633", "4326"]
|
|
60
|
+
|
|
61
|
+
[provenance]
|
|
62
|
+
source = """
|
|
63
|
+
Endemic enough that GeoPandas carries a guard for exactly this class:
|
|
64
|
+
_crs_mismatch_warn in geopandas/array.py warns "CRS mismatch between the CRS of
|
|
65
|
+
left geometries and the CRS of right geometries" whenever two geometry arrays
|
|
66
|
+
disagree (verified in geopandas 1.1.4). The guard exists upstream because the
|
|
67
|
+
empty join from mismatched frames is the canonical first question of spatial
|
|
68
|
+
debugging - and the composition measured here slips past it, because a bare
|
|
69
|
+
geometry has no CRS to compare."""
|
|
70
|
+
found_by = "mapsmith"
|
|
71
|
+
date = "2026-08-24"
|
|
72
|
+
|
|
73
|
+
[provenance.engine_versions]
|
|
74
|
+
geopandas = "1.1.4 - warns on array-vs-array mismatch, silent against a bare shapely geometry"
|
|
75
|
+
shapely = "planar containment on raw coordinates, CRS-unaware by design"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# 005 — A self-intersecting parcel whose area is computed anyway
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`parcel.gpkg` holds one polygon in **EPSG:32632** whose ring crosses itself: a
|
|
6
|
+
bowtie with two unequal lobes. Nothing refuses to store it — GeoPackage writes
|
|
7
|
+
it, every reader reads it back unchanged, and it renders as two filled
|
|
8
|
+
triangles that look like an ordinary odd-shaped parcel. Invalid rings are
|
|
9
|
+
endemic in real cadastral and digitised data, which is why every serious
|
|
10
|
+
engine ships a repair function.
|
|
11
|
+
|
|
12
|
+
## The right answer, on paper
|
|
13
|
+
|
|
14
|
+
The ring is A=(0,0), B=(0,100), C=(120,20), D=(120,80) in local metres. Edges
|
|
15
|
+
B→C and D→A cross at X=(75,50), splitting the figure into two triangles:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
(A, B, X): 100 · 75 / 2 = 3750 m²
|
|
19
|
+
(C, D, X): 60 · 45 / 2 = 1350 m² total: 5100 m²
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**5100 is the only defensible answer.** Both fill rules (even-odd and nonzero
|
|
23
|
+
winding) fill both lobes — which is also what any renderer draws — and
|
|
24
|
+
repairing the ring with `make_valid` yields exactly the two triangles as a
|
|
25
|
+
MultiPolygon of area 5100. A refusal that names the self-intersection is also
|
|
26
|
+
accepted: repairing and refusing-with-reason are both careful behaviours.
|
|
27
|
+
|
|
28
|
+
## The wrong answer
|
|
29
|
+
|
|
30
|
+
Read the file, take `.area`, report it: **2400**.
|
|
31
|
+
|
|
32
|
+
That is the shoelace sum over the ring, in which the two lobes carry opposite
|
|
33
|
+
orientation and partially cancel: |3750 − 1350| = 2400 — a number that matches
|
|
34
|
+
no definition of a region at all. And it arrives more quietly than the
|
|
35
|
+
library's own manual predicts: Shapely's documentation says of self-crossing
|
|
36
|
+
rings that "exceptions will be raised when they are operated on" — for the
|
|
37
|
+
area, nothing is raised. No exception, no warning, a plausible number.
|
|
38
|
+
|
|
39
|
+
## Why it is admitted
|
|
40
|
+
|
|
41
|
+
2400 m² and 5100 m² are both entirely ordinary parcels. The file is well
|
|
42
|
+
formed, the CRS is declared and metric, the geometry renders. The only wrong
|
|
43
|
+
thing is that a ring crosses itself and nothing in the composition ever asked
|
|
44
|
+
`is_valid` — the repair function exists in every engine, and a repair function
|
|
45
|
+
that is never called protects nobody.
|
|
46
|
+
|
|
47
|
+
## Observed
|
|
48
|
+
|
|
49
|
+
| adapter | answer | |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `engine:geopandas` — checks validity, repairs with `make_valid`, **and says so** | 5100.0, `correct_with_warning` | ✓ |
|
|
52
|
+
| `engine:naive` — sums `.area` | 2400.0 | ✗ |
|
|
53
|
+
|
|
54
|
+
The careful adapter's verdict is worth a note: it is scored
|
|
55
|
+
`correct_with_warning`, not plain `correct`, because it *communicated* the
|
|
56
|
+
repair. Measuring after a silent repair would be trading one silence for
|
|
57
|
+
another.
|
|
58
|
+
|
|
59
|
+
## Clean twin
|
|
60
|
+
|
|
61
|
+
`clean/c005-polygon-area` — a valid L-shaped parcel, same CRS, same question,
|
|
62
|
+
area 7600 m² by decomposition on paper. The trap and its control differ in
|
|
63
|
+
exactly one thing: whether the ring crosses itself.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Build one self-intersecting parcel — a bowtie with unequal lobes.
|
|
2
|
+
|
|
3
|
+
The ring is A -> B -> C -> D -> A with two edges crossing once. Nothing refuses
|
|
4
|
+
to store it: GeoPackage writes it, every reader reads it back byte-identical,
|
|
5
|
+
and the polygon renders as two filled triangles that look like an ordinary odd
|
|
6
|
+
parcel. The coordinates are ordinary UTM 32N metres.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
|
|
16
|
+
# builds of the same data differ byte for byte unless the clock is pinned.
|
|
17
|
+
os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-24T00:00:00.000Z")
|
|
18
|
+
|
|
19
|
+
import geopandas as gpd
|
|
20
|
+
from shapely.geometry import Polygon
|
|
21
|
+
|
|
22
|
+
CRS = "EPSG:32632"
|
|
23
|
+
E0, N0 = 400_000.0, 5_000_000.0 # ordinary coordinates for the zone
|
|
24
|
+
|
|
25
|
+
# Local shape: A=(0,0), B=(0,100), C=(120,20), D=(120,80). Edges B->C and D->A
|
|
26
|
+
# cross at X=(75,50); the lobes are the triangles (A,B,X) = 3750 m2 and
|
|
27
|
+
# (C,D,X) = 1350 m2. Offsets do not change areas.
|
|
28
|
+
RING = [(0.0, 0.0), (0.0, 100.0), (120.0, 20.0), (120.0, 80.0)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(destination: Path) -> int:
|
|
32
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
bowtie = Polygon([(E0 + x, N0 + y) for x, y in RING])
|
|
34
|
+
gpd.GeoDataFrame(
|
|
35
|
+
{"parcel_id": ["A-1"]}, geometry=[bowtie], crs=CRS
|
|
36
|
+
).to_file(destination / "parcel.gpkg", layer="parcel", driver="GPKG")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
id = "005-bowtie-area"
|
|
2
|
+
population = "trap"
|
|
3
|
+
family = "invalid-geometry"
|
|
4
|
+
title = "A self-intersecting parcel whose area is computed anyway"
|
|
5
|
+
surface = ["engine", "agent"]
|
|
6
|
+
|
|
7
|
+
[task]
|
|
8
|
+
call = "planar_area_m2(parcel.gpkg)"
|
|
9
|
+
prompt = """
|
|
10
|
+
What is the area of the parcel in parcel.gpkg, in square metres, measured in the
|
|
11
|
+
plane of the layer's own coordinate reference system?"""
|
|
12
|
+
|
|
13
|
+
[truth]
|
|
14
|
+
kind = "scalar"
|
|
15
|
+
value = 5100.0
|
|
16
|
+
tolerance = 0.01
|
|
17
|
+
derivation = """
|
|
18
|
+
The ring is A=(0,0), B=(0,100), C=(120,20), D=(120,80) in local metres
|
|
19
|
+
(EPSG:32632, offset 400000/5000000 - offsets do not change areas). Edges B->C
|
|
20
|
+
(y = 100 - 2x/3) and D->A (y = 2x/3) cross at X=(75,50). The two lobes are the
|
|
21
|
+
triangles (A,B,X), area 100*75/2 = 3750, and (C,D,X), area 60*45/2 = 1350. The
|
|
22
|
+
region the ring encloses is therefore 3750 + 1350 = 5100 square metres.
|
|
23
|
+
|
|
24
|
+
WHY 5100 IS THE ONLY CORRECT ANSWER. Every point-set reading of "the area of
|
|
25
|
+
this parcel" agrees: both the even-odd and the nonzero winding rule fill both
|
|
26
|
+
lobes, which is also what any renderer draws, and repairing the ring
|
|
27
|
+
(make_valid) yields exactly the two triangles as a MultiPolygon of area 5100.
|
|
28
|
+
The other number in play, 2400, corresponds to no definition of a region at
|
|
29
|
+
all - it is the signed shoelace sum, in which the two lobes carry opposite
|
|
30
|
+
orientation and partially cancel (see naive_failure). A refusal naming the
|
|
31
|
+
self-intersection is also accepted: repairing and refusing-with-reason are
|
|
32
|
+
both careful behaviours; reporting the artifact is the only wrong one."""
|
|
33
|
+
|
|
34
|
+
[naive_failure]
|
|
35
|
+
description = """
|
|
36
|
+
Read the file, take `.area`, report it. On a self-intersecting ring Shapely
|
|
37
|
+
returns the shoelace sum - lobes with opposite orientation partially cancel -
|
|
38
|
+
with no exception and no warning. That is quieter than the library's own
|
|
39
|
+
manual, which says of self-crossing rings that "exceptions will be raised when
|
|
40
|
+
they are operated on": for the area, nothing is raised. The answer comes back
|
|
41
|
+
2400."""
|
|
42
|
+
observed_value = 2400.0
|
|
43
|
+
plausible = true
|
|
44
|
+
why_plausible = """
|
|
45
|
+
2400 m2 is an entirely ordinary parcel, and so is the true 5100 m2 - neither
|
|
46
|
+
looks out of place in a report or a total. Nothing objects along the way: the
|
|
47
|
+
file writes and reads cleanly, the geometry renders as two filled triangles,
|
|
48
|
+
and the area call returns a number rather than raising. Invalid rings are
|
|
49
|
+
endemic in real cadastral and digitised data, which is why every serious
|
|
50
|
+
engine ships a repair function - and a repair function that exists but is not
|
|
51
|
+
called protects nobody."""
|
|
52
|
+
derivation = """
|
|
53
|
+
Closed form: the shoelace sum over A,B,C,D is 0 - 12000 + 7200 + 0 = -4800,
|
|
54
|
+
so the reported area is |-4800|/2 = 2400 - exactly |3750 - 1350|, the larger
|
|
55
|
+
lobe minus the smaller one."""
|
|
56
|
+
|
|
57
|
+
[refusal]
|
|
58
|
+
accept_if_mentions = ["invalid", "self-intersect", "self intersection", "bowtie", "make_valid", "not valid"]
|
|
59
|
+
|
|
60
|
+
[provenance]
|
|
61
|
+
source = """
|
|
62
|
+
Shapely manual (fetched 2026-08-24): "Operations on invalid features may
|
|
63
|
+
fail." and, of self-crossing rings, "Shapely will not prevent the creation of
|
|
64
|
+
such rings, but exceptions will be raised when they are operated on" - yet
|
|
65
|
+
`.area` on this ring raises nothing and returns the artifact (verified on
|
|
66
|
+
shapely 2.1.2). GEOS, PostGIS (ST_MakeValid) and Shapely all ship a repair
|
|
67
|
+
function because invalid rings are endemic in real data; this probe measures
|
|
68
|
+
the composition that never calls it."""
|
|
69
|
+
found_by = "mapsmith"
|
|
70
|
+
date = "2026-08-24"
|
|
71
|
+
|
|
72
|
+
[provenance.engine_versions]
|
|
73
|
+
shapely = "2.1.2 - .area returns the signed-shoelace artifact on a self-intersecting ring, no exception, no warning"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# 006 — A two-layer container that answers a question nobody asked
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`project.gpkg` holds two layers, both valid, both honestly named: `zones`
|
|
6
|
+
(4 polygons, written first — which makes it the container's **default**) and
|
|
7
|
+
`wells` (31 points, written second). GeoPackage is explicitly a multi-layer
|
|
8
|
+
format; a single project container holding zones and wells together is its
|
|
9
|
+
normal, documented use. Nothing about this file is unusual.
|
|
10
|
+
|
|
11
|
+
## The right answer, on paper
|
|
12
|
+
|
|
13
|
+
The question names the layer: *how many features are in the `wells` layer?*
|
|
14
|
+
By construction, `wells` holds W-1 through W-31. **The count is 31**, an
|
|
15
|
+
integer with no room for disagreement; the tolerance is 0.
|
|
16
|
+
|
|
17
|
+
## The wrong answer
|
|
18
|
+
|
|
19
|
+
Read the file, count the rows: **4**.
|
|
20
|
+
|
|
21
|
+
`read_file(path)` with no layer argument returns the container's default
|
|
22
|
+
layer — `zones`. This one is not fully mute, and the honest description says
|
|
23
|
+
so: pyogrio emits a UserWarning naming the default (*"More than one layer
|
|
24
|
+
found in 'project.gpkg': 'zones' (default), 'wells'. Specify layer parameter
|
|
25
|
+
to avoid this warning."*). But the warning goes to stderr, the returned frame
|
|
26
|
+
is perfectly well formed, and nothing in the **result** carries any trace of
|
|
27
|
+
it. A pipeline that logs-and-forgets — the overwhelming default, and the only
|
|
28
|
+
option in most agent stacks, where stderr never reaches the model — reports 4.
|
|
29
|
+
|
|
30
|
+
## Why it is admitted
|
|
31
|
+
|
|
32
|
+
"4 wells" is an entirely ordinary count, and a count carries no fingerprint of
|
|
33
|
+
the layer it came from. The person who asked about wells receives a number
|
|
34
|
+
computed on zones, and no property of the number says so. The one signal that
|
|
35
|
+
exists is attached to a stream, not to a result — and a signal nothing
|
|
36
|
+
downstream can see is not a defence, it is a description of the defect.
|
|
37
|
+
|
|
38
|
+
## Observed
|
|
39
|
+
|
|
40
|
+
| adapter | answer | |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `engine:geopandas` — passes the layer the question names | 31 | ✓ |
|
|
43
|
+
| `engine:naive` — reads "the file" | 4 | ✗ |
|
|
44
|
+
| `adapters.mapsmith` — `describe_dataset`, which takes a path and nothing else | 4 | ✗ **silent error** |
|
|
45
|
+
|
|
46
|
+
The third row is the reason this probe exists, and it is about the suite's own
|
|
47
|
+
author: MapSmith's reader resolved a multi-layer container to its default
|
|
48
|
+
layer **silently** — its inspection result said `feature_count: 4` with no
|
|
49
|
+
warning field at all, quieter than the bare pyogrio call it wraps. Filed as
|
|
50
|
+
[MapSmith issue #29](https://github.com/mapsmith-ai/MapSmith/issues/29) before
|
|
51
|
+
this trap was published, and that number is in the published
|
|
52
|
+
[2026-08-25 run](../../results/2026-08-25-six-families/).
|
|
53
|
+
|
|
54
|
+
**After the fix** (MapSmith `ebea7d0`, the same day, in that order on
|
|
55
|
+
purpose): operations refuse a container with no chosen layer, naming the
|
|
56
|
+
layers; `describe_dataset` describes every layer so the caller can choose; and
|
|
57
|
+
this adapter's composition now answers **31, correct**. The row above is the
|
|
58
|
+
regression test with a date on it — both dates.
|
|
59
|
+
|
|
60
|
+
## Clean twin
|
|
61
|
+
|
|
62
|
+
`clean/c006-named-layer` — one layer, honestly named, 17 points, same
|
|
63
|
+
question. With a single layer there is nothing to choose wrongly: a system
|
|
64
|
+
that answers the control and returns 4 on the trap has told us exactly one
|
|
65
|
+
thing — it never chose a layer at all.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Build one GeoPackage holding two layers, neither of them wrong.
|
|
2
|
+
|
|
3
|
+
``zones`` is written first (4 polygons) and therefore becomes the container's
|
|
4
|
+
default layer; ``wells`` (31 points) is written second. Every reader that is
|
|
5
|
+
handed the file without a layer name gets zones — the question the caller
|
|
6
|
+
asked names wells. Both layers are valid, ordinary and honest; the only trap
|
|
7
|
+
is that the container can answer a question nobody asked.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
|
|
17
|
+
# builds of the same data differ byte for byte unless the clock is pinned.
|
|
18
|
+
os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-25T00:00:00.000Z")
|
|
19
|
+
|
|
20
|
+
import geopandas as gpd
|
|
21
|
+
from shapely.geometry import Point, Polygon
|
|
22
|
+
|
|
23
|
+
CRS = "EPSG:32632"
|
|
24
|
+
N_ZONES = 4
|
|
25
|
+
N_WELLS = 31
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(destination: Path) -> int:
|
|
29
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
container = destination / "project.gpkg"
|
|
31
|
+
|
|
32
|
+
zones = gpd.GeoDataFrame(
|
|
33
|
+
{"zone_id": [f"Z-{i}" for i in range(1, N_ZONES + 1)]},
|
|
34
|
+
geometry=[
|
|
35
|
+
Polygon([
|
|
36
|
+
(i * 100.0, 0.0), (i * 100.0 + 80.0, 0.0),
|
|
37
|
+
(i * 100.0 + 80.0, 80.0), (i * 100.0, 80.0),
|
|
38
|
+
])
|
|
39
|
+
for i in range(N_ZONES)
|
|
40
|
+
],
|
|
41
|
+
crs=CRS,
|
|
42
|
+
)
|
|
43
|
+
zones.to_file(container, layer="zones", driver="GPKG")
|
|
44
|
+
|
|
45
|
+
wells = gpd.GeoDataFrame(
|
|
46
|
+
{"well_id": [f"W-{i}" for i in range(1, N_WELLS + 1)]},
|
|
47
|
+
geometry=[Point(10.0 + i * 7.0, 200.0 + (i % 5) * 11.0) for i in range(N_WELLS)],
|
|
48
|
+
crs=CRS,
|
|
49
|
+
)
|
|
50
|
+
wells.to_file(container, layer="wells", driver="GPKG")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
id = "006-default-layer"
|
|
2
|
+
population = "trap"
|
|
3
|
+
family = "ambiguous-layer"
|
|
4
|
+
title = "A two-layer container that answers a question nobody asked"
|
|
5
|
+
surface = ["engine", "agent"]
|
|
6
|
+
|
|
7
|
+
[task]
|
|
8
|
+
call = "feature_count(project.gpkg, layer=wells)"
|
|
9
|
+
prompt = """
|
|
10
|
+
How many features are in the `wells` layer of project.gpkg? Answer with the
|
|
11
|
+
count."""
|
|
12
|
+
|
|
13
|
+
[truth]
|
|
14
|
+
kind = "count"
|
|
15
|
+
value = 31
|
|
16
|
+
tolerance = 0
|
|
17
|
+
derivation = """
|
|
18
|
+
By construction: project.gpkg holds exactly two layers, `zones` written first
|
|
19
|
+
(4 polygons) and `wells` written second (31 points, W-1 through W-31). The
|
|
20
|
+
question names the layer, so the answer is the number of features in `wells`:
|
|
21
|
+
31. An integer with no room for disagreement; the tolerance is 0."""
|
|
22
|
+
|
|
23
|
+
[naive_failure]
|
|
24
|
+
description = """
|
|
25
|
+
Read the file, count the rows: `read_file(path)` with no layer argument
|
|
26
|
+
returns the container's DEFAULT layer, which is `zones` — the count comes back
|
|
27
|
+
4. This is not fully mute: pyogrio emits a UserWarning naming the default
|
|
28
|
+
("More than one layer found... 'zones' (default)"). But the warning goes to
|
|
29
|
+
stderr, the returned frame is well formed, and nothing in the RESULT carries
|
|
30
|
+
any trace of it — a pipeline that logs-and-forgets, which is the overwhelming
|
|
31
|
+
default, reports 4. Measured on a system that wraps the same reader: its
|
|
32
|
+
inspection result said `feature_count: 4` with no warning field at all."""
|
|
33
|
+
observed_value = 4
|
|
34
|
+
plausible = true
|
|
35
|
+
why_plausible = """
|
|
36
|
+
"4 wells" is an entirely ordinary count, and nothing about the container
|
|
37
|
+
pushes back: both layers are valid and honestly named, the CRS is declared,
|
|
38
|
+
the file opens everywhere. The person who asked about wells receives a number
|
|
39
|
+
computed on zones, and no property of the number says so — a count carries no
|
|
40
|
+
fingerprint of the layer it came from. The one signal that exists is a
|
|
41
|
+
UserWarning on a stream nobody reads in production, attached to no result."""
|
|
42
|
+
derivation = """
|
|
43
|
+
Closed form: the default layer is the first one written into the container —
|
|
44
|
+
`zones`, with exactly 4 features. A bare read counts those: 4."""
|
|
45
|
+
|
|
46
|
+
[refusal]
|
|
47
|
+
accept_if_mentions = ["layer", "multi-layer", "multilayer", "ambiguous", "more than one", "default"]
|
|
48
|
+
|
|
49
|
+
[provenance]
|
|
50
|
+
source = """
|
|
51
|
+
The guard exists upstream because the mistake is endemic: pyogrio warns "More
|
|
52
|
+
than one layer found in '...': '...' (default)... Specify layer parameter to
|
|
53
|
+
avoid this warning" on every bare read of a multi-layer source (verified,
|
|
54
|
+
pyogrio 0.11/geopandas 1.1.4). GeoPackage is explicitly a multi-layer format,
|
|
55
|
+
and single-file project containers are its normal use. Found while designing
|
|
56
|
+
this probe: the reader of a provenance-first geoprocessing server swallowed
|
|
57
|
+
the same warning and reported the default layer's count with no trace — filed
|
|
58
|
+
there as issue #29 before this trap was published."""
|
|
59
|
+
found_by = "mapsmith"
|
|
60
|
+
date = "2026-08-25"
|
|
61
|
+
|
|
62
|
+
[provenance.engine_versions]
|
|
63
|
+
geopandas = "1.1.4/pyogrio - bare read returns the first layer and warns on stderr; the result object carries nothing"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 007 — A distance of 500, applied in the layer's units instead of the question's
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`wells.gpkg` holds 25 wells in **EPSG:4326**. W-1 sits at (12.40, 41.90); three
|
|
6
|
+
wells lie within 400 metres of it, the other 21 lie kilometres away. Everything
|
|
7
|
+
about the file is honest — valid points, declared CRS — and so is the number
|
|
8
|
+
500 in the question. What is implicit is the unit it gets applied in.
|
|
9
|
+
|
|
10
|
+
## The right answer, on paper
|
|
11
|
+
|
|
12
|
+
In the local metric at 41.9°N (1° of latitude = 111.1 km, 1° of longitude =
|
|
13
|
+
82.9 km), the three near wells sit at ~300 m, ~398 m and ~359 m from W-1; the
|
|
14
|
+
nearest excluded well is at 6,932 m. **The count is 3.** The margins carry the
|
|
15
|
+
derivation: at these distances a flat-earth approximation, a geodesic and a
|
|
16
|
+
UTM-planar distance agree to within centimetres, while the included/excluded
|
|
17
|
+
gap is three orders of magnitude wider than any method disagreement.
|
|
18
|
+
Tolerance 0.
|
|
19
|
+
|
|
20
|
+
## The wrong answer
|
|
21
|
+
|
|
22
|
+
Buffer W-1 by 500, count the wells inside: **24 — every single one**.
|
|
23
|
+
|
|
24
|
+
Shapely, GeoPandas and PostGIS all buffer in the layer's own units by design,
|
|
25
|
+
and none of them can know the caller meant metres. A buffer of 500 *degrees*
|
|
26
|
+
spans longitudes −487.6 to 512.4: the whole coordinate space of the file, and
|
|
27
|
+
then the planet, repeatedly. No exception, no warning — the buffer is a
|
|
28
|
+
perfectly valid polygon, just an absurd one that nothing downstream ever looks
|
|
29
|
+
at. Only the count survives, and the count looks fine.
|
|
30
|
+
|
|
31
|
+
## Why it is admitted
|
|
32
|
+
|
|
33
|
+
This family's classic demonstration — "the buffered *area* is planet-sized" —
|
|
34
|
+
is loud, and a loud failure belongs in an ordinary test suite. The trap plants
|
|
35
|
+
the quiet variant: a **count**. "24 wells within half a kilometre" is an
|
|
36
|
+
ordinary number for a dense urban wellfield, and a count carries no trace of
|
|
37
|
+
the geometry that produced it. The figure 500 is exactly what the question
|
|
38
|
+
said; the only implicit thing was the unit.
|
|
39
|
+
|
|
40
|
+
## Observed
|
|
41
|
+
|
|
42
|
+
| adapter | answer | |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `engine:geopandas` — projects to the local UTM zone, then measures | 3 | ✓ |
|
|
45
|
+
| `adapters.mapsmith` — `buffer_layer`, whose contract is metres (auto-UTM on geographic layers, decision recorded), then a within-join | 3 | ✓ |
|
|
46
|
+
| `engine:naive` — buffers 500 in the layer's units | 24 | ✗ |
|
|
47
|
+
|
|
48
|
+
The MapSmith pass is earned the same way family 4's was: not by a library that
|
|
49
|
+
happens to save the caller, but by a tool contract that names the unit
|
|
50
|
+
(*metres, always*) and records the reprojection decision in the manifest.
|
|
51
|
+
|
|
52
|
+
## Clean twin
|
|
53
|
+
|
|
54
|
+
`clean/c007-distance-in-metres` — the same question on a layer already in
|
|
55
|
+
EPSG:32633: five wells within 460 m (offsets exact by Pythagoras), the rest at
|
|
56
|
+
2.5 km or more. The trap and its control differ in exactly one thing: whether
|
|
57
|
+
the layer's unit is the one the question uses.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Build a wells layer in EPSG:4326 where "within 500 metres" is the question.
|
|
2
|
+
|
|
3
|
+
W-1 sits at (12.40, 41.90). Three wells lie within 400 metres of it; the other
|
|
4
|
+
21 lie kilometres away. The layer is honest — valid points, declared CRS — and
|
|
5
|
+
the number 500 is honest too. What is implicit is the unit it gets applied in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
|
|
15
|
+
# builds of the same data differ byte for byte unless the clock is pinned.
|
|
16
|
+
os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-25T00:00:00.000Z")
|
|
17
|
+
|
|
18
|
+
import geopandas as gpd
|
|
19
|
+
from shapely.geometry import Point
|
|
20
|
+
|
|
21
|
+
CRS = "EPSG:4326"
|
|
22
|
+
LON, LAT = 12.40, 41.90 # W-1
|
|
23
|
+
|
|
24
|
+
# ~300 m north, ~398 m east, ~359 m south-west of W-1 (local metric at 41.9N).
|
|
25
|
+
INSIDE = [(LON, LAT + 0.0027), (LON + 0.0048, LAT), (LON - 0.0036, LAT - 0.0018)]
|
|
26
|
+
# Everything else at least ~4 km away (>= 0.05 degrees in both axes).
|
|
27
|
+
OUTSIDE = [(LON + 0.05 + 0.01 * i, LAT + 0.05 + 0.007 * (i % 7)) for i in range(21)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(destination: Path) -> int:
|
|
31
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
points = [Point(LON, LAT)] + [Point(*c) for c in INSIDE] + [Point(*c) for c in OUTSIDE]
|
|
33
|
+
gpd.GeoDataFrame(
|
|
34
|
+
{"well_id": [f"W-{i + 1}" for i in range(len(points))]},
|
|
35
|
+
geometry=points,
|
|
36
|
+
crs=CRS,
|
|
37
|
+
).to_file(destination / "wells.gpkg", layer="wells", driver="GPKG")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|