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.
Files changed (124) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/engine_geopandas.py +176 -0
  3. adapters/engine_naive.py +220 -0
  4. adapters/engine_rasterio.py +78 -0
  5. adapters/engine_whitebox.py +34 -0
  6. adapters/gis_mcp.py +192 -0
  7. adapters/mapsmith.py +496 -0
  8. argleton/__init__.py +3 -0
  9. argleton/model.py +202 -0
  10. argleton/probes/clean/c001-raster-mean/build.py +36 -0
  11. argleton/probes/clean/c001-raster-mean/probe.toml +25 -0
  12. argleton/probes/clean/c002-projected-area/build.py +44 -0
  13. argleton/probes/clean/c002-projected-area/probe.toml +25 -0
  14. argleton/probes/clean/c003-raster-mean-nodata/build.py +35 -0
  15. argleton/probes/clean/c003-raster-mean-nodata/probe.toml +26 -0
  16. argleton/probes/clean/c004-points-in-polygon/build.py +60 -0
  17. argleton/probes/clean/c004-points-in-polygon/probe.toml +29 -0
  18. argleton/probes/clean/c005-polygon-area/build.py +38 -0
  19. argleton/probes/clean/c005-polygon-area/probe.toml +26 -0
  20. argleton/probes/clean/c006-named-layer/build.py +35 -0
  21. argleton/probes/clean/c006-named-layer/probe.toml +26 -0
  22. argleton/probes/clean/c007-distance-in-metres/build.py +42 -0
  23. argleton/probes/clean/c007-distance-in-metres/probe.toml +27 -0
  24. argleton/probes/clean/c008-equal-area-crs/build.py +39 -0
  25. argleton/probes/clean/c008-equal-area-crs/probe.toml +37 -0
  26. argleton/probes/clean/c009-native-resolution-classes/build.py +51 -0
  27. argleton/probes/clean/c009-native-resolution-classes/probe.toml +31 -0
  28. argleton/probes/clean/c010-physical-values/build.py +49 -0
  29. argleton/probes/clean/c010-physical-values/probe.toml +30 -0
  30. argleton/probes/clean/c011-solid-parcel/build.py +52 -0
  31. argleton/probes/clean/c011-solid-parcel/probe.toml +28 -0
  32. argleton/probes/clean/c012-disjoint-concessions/build.py +56 -0
  33. argleton/probes/clean/c012-disjoint-concessions/probe.toml +28 -0
  34. argleton/probes/clean/c013-flat-pipeline/build.py +42 -0
  35. argleton/probes/clean/c013-flat-pipeline/probe.toml +28 -0
  36. argleton/probes/clean/c014-convex-parcel/build.py +70 -0
  37. argleton/probes/clean/c014-convex-parcel/probe.toml +28 -0
  38. argleton/probes/clean/c015-wells-off-the-seam/build.py +73 -0
  39. argleton/probes/clean/c015-wells-off-the-seam/probe.toml +28 -0
  40. argleton/probes/clean/c016-decimal-degrees/build.py +29 -0
  41. argleton/probes/clean/c016-decimal-degrees/probe.toml +27 -0
  42. argleton/probes/clean/c017-equal-populations/build.py +60 -0
  43. argleton/probes/clean/c017-equal-populations/probe.toml +28 -0
  44. argleton/probes/clean/c018-plain-keys/build.py +59 -0
  45. argleton/probes/clean/c018-plain-keys/probe.toml +28 -0
  46. argleton/probes/clean/c019-fully-contained/build.py +64 -0
  47. argleton/probes/clean/c019-fully-contained/probe.toml +28 -0
  48. argleton/probes/clean/c020-one-owner-each/build.py +55 -0
  49. argleton/probes/clean/c020-one-owner-each/probe.toml +27 -0
  50. argleton/probes/clean/c021-greenwich-variant/build.py +41 -0
  51. argleton/probes/clean/c021-greenwich-variant/probe.toml +41 -0
  52. argleton/probes/schema/probe.schema.json +138 -0
  53. argleton/probes/schema/result.schema.json +103 -0
  54. argleton/probes/traps/001-tiff-predictor/README.md +75 -0
  55. argleton/probes/traps/001-tiff-predictor/build.py +58 -0
  56. argleton/probes/traps/001-tiff-predictor/probe.toml +57 -0
  57. argleton/probes/traps/002-feet-as-metres/README.md +79 -0
  58. argleton/probes/traps/002-feet-as-metres/build.py +44 -0
  59. argleton/probes/traps/002-feet-as-metres/probe.toml +70 -0
  60. argleton/probes/traps/003-nodata-in-statistics/README.md +61 -0
  61. argleton/probes/traps/003-nodata-in-statistics/build.py +51 -0
  62. argleton/probes/traps/003-nodata-in-statistics/probe.toml +62 -0
  63. argleton/probes/traps/004-mismatched-crs-join/README.md +65 -0
  64. argleton/probes/traps/004-mismatched-crs-join/build.py +65 -0
  65. argleton/probes/traps/004-mismatched-crs-join/probe.toml +75 -0
  66. argleton/probes/traps/005-bowtie-area/README.md +63 -0
  67. argleton/probes/traps/005-bowtie-area/build.py +41 -0
  68. argleton/probes/traps/005-bowtie-area/probe.toml +73 -0
  69. argleton/probes/traps/006-default-layer/README.md +65 -0
  70. argleton/probes/traps/006-default-layer/build.py +55 -0
  71. argleton/probes/traps/006-default-layer/probe.toml +63 -0
  72. argleton/probes/traps/007-buffer-in-degrees/README.md +57 -0
  73. argleton/probes/traps/007-buffer-in-degrees/build.py +42 -0
  74. argleton/probes/traps/007-buffer-in-degrees/probe.toml +64 -0
  75. argleton/probes/traps/008-web-mercator-area/README.md +55 -0
  76. argleton/probes/traps/008-web-mercator-area/build.py +39 -0
  77. argleton/probes/traps/008-web-mercator-area/probe.toml +68 -0
  78. argleton/probes/traps/009-resampled-classes/README.md +88 -0
  79. argleton/probes/traps/009-resampled-classes/build.py +46 -0
  80. argleton/probes/traps/009-resampled-classes/probe.toml +76 -0
  81. argleton/probes/traps/010-scale-offset/README.md +76 -0
  82. argleton/probes/traps/010-scale-offset/build.py +55 -0
  83. argleton/probes/traps/010-scale-offset/probe.toml +69 -0
  84. argleton/probes/traps/011-polygon-holes/README.md +47 -0
  85. argleton/probes/traps/011-polygon-holes/build.py +55 -0
  86. argleton/probes/traps/011-polygon-holes/probe.toml +59 -0
  87. argleton/probes/traps/012-double-counting/README.md +40 -0
  88. argleton/probes/traps/012-double-counting/build.py +58 -0
  89. argleton/probes/traps/012-double-counting/probe.toml +52 -0
  90. argleton/probes/traps/013-z-dimension/README.md +42 -0
  91. argleton/probes/traps/013-z-dimension/build.py +43 -0
  92. argleton/probes/traps/013-z-dimension/probe.toml +53 -0
  93. argleton/probes/traps/014-centroid-outside/README.md +42 -0
  94. argleton/probes/traps/014-centroid-outside/build.py +72 -0
  95. argleton/probes/traps/014-centroid-outside/probe.toml +59 -0
  96. argleton/probes/traps/015-boundary-semantics/README.md +41 -0
  97. argleton/probes/traps/015-boundary-semantics/build.py +74 -0
  98. argleton/probes/traps/015-boundary-semantics/probe.toml +57 -0
  99. argleton/probes/traps/016-coordinate-parsing/README.md +40 -0
  100. argleton/probes/traps/016-coordinate-parsing/build.py +36 -0
  101. argleton/probes/traps/016-coordinate-parsing/probe.toml +52 -0
  102. argleton/probes/traps/017-aggregation-weighting/README.md +42 -0
  103. argleton/probes/traps/017-aggregation-weighting/build.py +62 -0
  104. argleton/probes/traps/017-aggregation-weighting/probe.toml +53 -0
  105. argleton/probes/traps/018-join-key-typing/README.md +41 -0
  106. argleton/probes/traps/018-join-key-typing/build.py +61 -0
  107. argleton/probes/traps/018-join-key-typing/probe.toml +53 -0
  108. argleton/probes/traps/019-partial-overlap/README.md +43 -0
  109. argleton/probes/traps/019-partial-overlap/build.py +66 -0
  110. argleton/probes/traps/019-partial-overlap/probe.toml +53 -0
  111. argleton/probes/traps/020-join-cardinality/README.md +42 -0
  112. argleton/probes/traps/020-join-cardinality/build.py +60 -0
  113. argleton/probes/traps/020-join-cardinality/probe.toml +51 -0
  114. argleton/probes/traps/021-ballpark-datum/README.md +100 -0
  115. argleton/probes/traps/021-ballpark-datum/build.py +44 -0
  116. argleton/probes/traps/021-ballpark-datum/probe.toml +92 -0
  117. argleton/published.py +49 -0
  118. argleton/run.py +170 -0
  119. argleton/score.py +150 -0
  120. argleton-0.1.0.dist-info/METADATA +275 -0
  121. argleton-0.1.0.dist-info/RECORD +124 -0
  122. argleton-0.1.0.dist-info/WHEEL +4 -0
  123. argleton-0.1.0.dist-info/entry_points.txt +2 -0
  124. argleton-0.1.0.dist-info/licenses/LICENSE +202 -0
adapters/mapsmith.py ADDED
@@ -0,0 +1,496 @@
1
+ """Adapter: MapSmith, the system these probes were written next to.
2
+
3
+ Here on purpose and first, because a suite whose authors do not measure
4
+ themselves is not a suite. What it finds about MapSmith goes in `results/`
5
+ whatever it says.
6
+
7
+ Two things worth stating before the numbers.
8
+
9
+ **MapSmith exposes no `raster_mean`.** It exposes `zonal_statistics`, so that is
10
+ what this adapter calls, with a zone covering the raster's own extent — which is
11
+ how you ask MapSmith the question. Composing a system's real tools to answer the
12
+ probe is the adapter's whole job; inventing an operation it does not have would
13
+ measure a system that does not exist.
14
+
15
+ **The area operations arrived because of this suite.** Until 2026-08-25 the two
16
+ area families (`linear-units`, `invalid-geometry`) and then
17
+ `projection-distortion` came back `unsupported` three times: MapSmith had no
18
+ area operation at all, and composing one out of raw SQL would have measured
19
+ DuckDB instead. Three `unsupported` verdicts on the most elementary question in
20
+ GIS is a finding about the catalog, and `measure_area` is the answer to it.
21
+
22
+ **There is no switch to turn MapSmith's verification off**, and adding one to a
23
+ product whose argument is that it verifies would be a footgun someone eventually
24
+ ships with. So this measures MapSmith as it is released. On the families
25
+ implemented so far that turns out not to matter, and the reason is worth reading
26
+ in `results/`.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from pathlib import Path
32
+
33
+ from argleton.model import Outcome, Probe
34
+
35
+
36
+ class Adapter:
37
+ name = "mapsmith"
38
+
39
+ def run(self, probe: Probe, workdir: Path) -> Outcome:
40
+ operation = getattr(self, f"op_{probe.operation}", None)
41
+ if operation is None:
42
+ return Outcome(unsupported=True)
43
+ return operation(probe, workdir)
44
+
45
+ # ---- tier A ---------------------------------------------------------------
46
+
47
+ def op_buildable_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
48
+ # measure_area on a polygon with a hole: the courtyard is part of the
49
+ # geometry's definition, so nothing here has to know it exists.
50
+ return self._area(probe, workdir, method="planar")
51
+
52
+ def op_total_ground_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
53
+ from mapsmith.engines import vector
54
+
55
+ # Two steps, both catalogue operations: dissolve with no key merges the
56
+ # overlapping licences into one piece of ground, then measure it. The
57
+ # shared strip stops being two things before anything is added up.
58
+ merged = workdir / "_argleton_dissolved.parquet"
59
+ try:
60
+ vector.dissolve(str(workdir / probe.arguments[0]), str(merged))
61
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
62
+ return self._refusal_or_error(exc)
63
+ return self._measure(merged, workdir, "planar")
64
+
65
+ def op_flooded_farmland_m2(self, probe: Probe, workdir: Path) -> Outcome:
66
+ from mapsmith.engines import vector
67
+
68
+ # overlay(intersection) cuts the fields to the band, so what gets
69
+ # measured is the flooded part rather than the parcels that touch it.
70
+ clipped = workdir / "_argleton_flooded.parquet"
71
+ try:
72
+ vector.overlay(
73
+ str(workdir / probe.arguments[0]),
74
+ str(workdir / probe.arguments[1]),
75
+ str(clipped),
76
+ how="intersection",
77
+ )
78
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
79
+ return self._refusal_or_error(exc)
80
+ return self._measure(clipped, workdir, "planar")
81
+
82
+ def op_district_of_parcel(self, probe: Probe, workdir: Path) -> Outcome:
83
+ import geopandas as gpd
84
+ from mapsmith.engines import vector
85
+
86
+ # spatial_join with `within`: the parcel is joined as a polygon, not
87
+ # reduced to a point first, so a concave parcel keeps its shape.
88
+ joined = workdir / "_argleton_district.parquet"
89
+ try:
90
+ vector.spatial_join(
91
+ str(workdir / probe.arguments[0]),
92
+ str(workdir / probe.arguments[1]),
93
+ str(joined),
94
+ predicate="within",
95
+ )
96
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
97
+ return self._refusal_or_error(exc)
98
+ frame = gpd.read_parquet(joined)
99
+ if frame.empty:
100
+ return Outcome(answer="")
101
+ return Outcome(answer=str(frame["district"].iloc[0]), warnings=self._warnings(joined))
102
+
103
+ def op_wells_in_districts(self, probe: Probe, workdir: Path) -> Outcome:
104
+ import geopandas as gpd
105
+ from mapsmith.engines import vector
106
+
107
+ # `intersects` rather than `within`, because the question is about a
108
+ # partition and a well on the shared edge is in the study area whichever
109
+ # district claims it. A seam well then matches both districts, so the
110
+ # count is over distinct wells — reading the join, not rewriting it.
111
+ joined = workdir / "_argleton_wells.parquet"
112
+ try:
113
+ vector.spatial_join(
114
+ str(workdir / probe.arguments[0]),
115
+ str(workdir / probe.arguments[1]),
116
+ str(joined),
117
+ predicate="intersects",
118
+ )
119
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
120
+ return self._refusal_or_error(exc)
121
+ frame = gpd.read_parquet(joined)
122
+ return Outcome(
123
+ answer=int(frame["well_id"].nunique()), warnings=self._warnings(joined)
124
+ )
125
+
126
+ def op_pipe_length_m(self, probe: Probe, workdir: Path) -> Outcome:
127
+ from mapsmith.engines import vector
128
+
129
+ # measure_length(method="3d"), which exists because this probe returned
130
+ # unsupported the first time it ran. The method is stated rather than
131
+ # defaulted: a pipe follows the ground, so the question is about the
132
+ # length through space and the composition has to say so.
133
+ output = workdir / "_argleton_length.parquet"
134
+ try:
135
+ result = vector.measure_length(
136
+ str(workdir / probe.arguments[0]), str(output), method="3d"
137
+ )
138
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
139
+ return self._refusal_or_error(exc)
140
+ return Outcome(
141
+ answer=float(result["total_length_m"]), warnings=self._warnings(output)
142
+ )
143
+
144
+ def op_latitude_decimal(self, probe: Probe, workdir: Path) -> Outcome:
145
+ import geopandas as gpd
146
+ import pandas as pd
147
+ from mapsmith.engines import vector
148
+
149
+ # parse_coordinates: the caller names the columns, because the file
150
+ # cannot say whether 41.5324 is a decimal degree or a mangled DMS.
151
+ station = probe.arguments[1].split("=", 1)[1]
152
+ table = workdir / probe.arguments[0]
153
+ columns = set(pd.read_csv(table, nrows=1).columns)
154
+ if {"lat_deg", "lat_min", "lat_sec"} <= columns:
155
+ latitude = "lat_deg,lat_min,lat_sec,lat_hem"
156
+ longitude = "lon_deg,lon_min,lon_sec,lon_hem"
157
+ else:
158
+ latitude, longitude = "latitude", "longitude"
159
+ output = workdir / "_argleton_points.parquet"
160
+ try:
161
+ vector.parse_coordinates(
162
+ str(table),
163
+ str(output),
164
+ latitude_columns=latitude,
165
+ longitude_columns=longitude,
166
+ )
167
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
168
+ return self._refusal_or_error(exc)
169
+ points = gpd.read_parquet(output)
170
+ row = points[points["station_id"] == station].iloc[0]
171
+ return Outcome(answer=float(row.geometry.y), warnings=self._warnings(output))
172
+
173
+ def op_area_unemployment_rate_pct(self, probe: Probe, workdir: Path) -> Outcome:
174
+ from mapsmith.engines import vector
175
+
176
+ # aggregate_weighted: a rate over an area is the ratio of totals, and
177
+ # the weight column has to be named — which is the whole operation.
178
+ output = workdir / "_argleton_rate.parquet"
179
+ try:
180
+ result = vector.aggregate_weighted(
181
+ str(workdir / probe.arguments[0]),
182
+ str(output),
183
+ value_column="unemployment_rate_pct",
184
+ weight_column="labour_force",
185
+ )
186
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
187
+ return self._refusal_or_error(exc)
188
+ return Outcome(
189
+ answer=float(result["weighted_value"]), warnings=self._warnings(output)
190
+ )
191
+
192
+ def op_total_population(self, probe: Probe, workdir: Path) -> Outcome:
193
+ import geopandas as gpd
194
+ from mapsmith.engines import vector
195
+
196
+ # join_table reads keys as text on both sides, so a code like "001"
197
+ # still matches after the CSV has been through a reader.
198
+ output = workdir / "_argleton_joined.parquet"
199
+ try:
200
+ vector.join_table(
201
+ str(workdir / probe.arguments[0]),
202
+ str(workdir / probe.arguments[1]),
203
+ str(output),
204
+ on="istat_code",
205
+ )
206
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
207
+ return self._refusal_or_error(exc)
208
+ joined = gpd.read_parquet(output)
209
+ return Outcome(
210
+ answer=int(joined["population"].sum()), warnings=self._warnings(output)
211
+ )
212
+
213
+ def op_sheet_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
214
+ import geopandas as gpd
215
+ from mapsmith.engines import vector
216
+
217
+ # The join is where this goes wrong, so the join is where MapSmith
218
+ # says something: join_table reports that the table multiplied the
219
+ # features, and the composition answers over the parcels rather than
220
+ # over the joined rows.
221
+ output = workdir / "_argleton_owners.parquet"
222
+ try:
223
+ result = vector.join_table(
224
+ str(workdir / probe.arguments[0]),
225
+ str(workdir / probe.arguments[1]),
226
+ str(output),
227
+ on="parcel_id",
228
+ )
229
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
230
+ return self._refusal_or_error(exc)
231
+ joined = gpd.read_parquet(output)
232
+ # One row per parcel, whatever the deed says: the duplicate keys are
233
+ # named in the result, so this is reading MapSmith's answer rather than
234
+ # working around it.
235
+ area = float(joined.drop_duplicates(subset="parcel_id")["area_m2"].sum())
236
+ warnings = self._warnings(output)
237
+ if result.get("duplicate_keys"):
238
+ warnings.append(
239
+ f"the owners table has {result['duplicate_keys']} duplicate key(s): "
240
+ f"the join produced {result['feature_count']} rows from "
241
+ f"{result['input_feature_count']} parcels"
242
+ )
243
+ return Outcome(answer=area, warnings=warnings)
244
+
245
+ def _measure(self, dataset: Path, workdir: Path, method: str) -> Outcome:
246
+ from mapsmith.engines import vector
247
+
248
+ output = workdir / f"_argleton_measured_{method}.parquet"
249
+ try:
250
+ result = vector.measure_area(str(dataset), str(output), method=method)
251
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
252
+ return self._refusal_or_error(exc)
253
+ return Outcome(
254
+ answer=float(result["total_area_m2"]), warnings=self._warnings(output)
255
+ )
256
+
257
+ @staticmethod
258
+ def _refusal_or_error(exc: Exception) -> Outcome:
259
+ text = str(exc)
260
+ if "has no CRS" in text or "Refusing" in text or "square degrees" in text:
261
+ return Outcome(refusal=text)
262
+ return Outcome(error=f"{type(exc).__name__}: {text}")
263
+
264
+ def op_ndvi_mean(self, probe: Probe, workdir: Path) -> Outcome:
265
+ import numpy as np
266
+ import rasterio
267
+ from mapsmith.engines import raster
268
+
269
+ red_band = int(probe.arguments[1].split("=", 1)[1])
270
+ nir_band = int(probe.arguments[2].split("=", 1)[1])
271
+ source = workdir / probe.arguments[0]
272
+ output = workdir / "_argleton_ndvi.tif"
273
+ try:
274
+ result = raster.band_math(
275
+ str(source),
276
+ str(output),
277
+ expression="(b2 - b1) / (b2 + b1)"
278
+ if (red_band, nir_band) == (1, 2)
279
+ else f"(b{nir_band} - b{red_band}) / (b{nir_band} + b{red_band})",
280
+ )
281
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
282
+ text = str(exc)
283
+ if "declares no CRS" in text or "Refusing" in text:
284
+ return Outcome(refusal=text)
285
+ return Outcome(error=f"{type(exc).__name__}: {text}")
286
+
287
+ with rasterio.open(output) as ds:
288
+ band = ds.read(1, masked=True)
289
+ warns = [w["detail"] for w in result.get("warnings", [])]
290
+ return Outcome(answer=float(np.ma.mean(band)), warnings=warns)
291
+
292
+ def op_class_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
293
+ import numpy as np
294
+ import rasterio
295
+ from mapsmith.engines import raster
296
+
297
+ resolution = float(probe.arguments[1].split("=", 1)[1])
298
+ wanted = int(probe.arguments[2].split("=", 1)[1])
299
+ source = workdir / probe.arguments[0]
300
+ resampled = workdir / "_argleton_resampled.tif"
301
+ try:
302
+ # resample_raster has NO default method: the composition has to
303
+ # state one, and the question states a legend, so these are class
304
+ # codes. That forced choice is the whole defence — and if this
305
+ # composition had said "bilinear", the result would have come back
306
+ # with `invented_values` naming the class that appeared out of
307
+ # nothing, which is the second defence.
308
+ result = raster.resample(
309
+ str(source), str(resampled), resolution, "nearest"
310
+ )
311
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
312
+ text = str(exc)
313
+ if "declares no CRS" in text or "Refusing" in text:
314
+ return Outcome(refusal=text)
315
+ return Outcome(error=f"{type(exc).__name__}: {text}")
316
+
317
+ with rasterio.open(resampled) as ds:
318
+ band = ds.read(1)
319
+ cell = abs(float(ds.res[0])) * abs(float(ds.res[1]))
320
+ area = float(int(np.sum(band == wanted)) * cell)
321
+ warns = [w["detail"] for w in result.get("warnings", [])]
322
+ return Outcome(answer=area, warnings=warns)
323
+
324
+ def op_raster_mean(self, probe: Probe, workdir: Path) -> Outcome:
325
+ import geopandas as gpd
326
+ import rasterio
327
+ from mapsmith.engines import raster
328
+ from shapely.geometry import box
329
+
330
+ source = workdir / probe.arguments[0]
331
+ with rasterio.open(source) as ds:
332
+ bounds, crs = ds.bounds, ds.crs
333
+ if crs is None:
334
+ return Outcome(refusal="the raster declares no CRS, so a zone cannot be placed on it")
335
+
336
+ # A zone that is exactly the raster's extent. exactextract weights
337
+ # partial pixels rather than counting or dropping them, so a zone on the
338
+ # grid boundary is the whole grid and nothing is double-counted.
339
+ zone = workdir / "_argleton_extent.gpkg"
340
+ gpd.GeoDataFrame(
341
+ {"zone": [1]}, geometry=[box(*bounds)], crs=crs
342
+ ).to_file(zone, layer="zone", driver="GPKG")
343
+
344
+ output = workdir / "_argleton_zonal.parquet"
345
+ try:
346
+ raster.zonal_statistics(str(source), str(zone), str(output), stats=["mean"])
347
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
348
+ text = str(exc)
349
+ # MapSmith refuses rather than guesses in several places, and a
350
+ # refusal that names its reason is a different result from a crash.
351
+ if "has no CRS" in text or "Refusing" in text:
352
+ return Outcome(refusal=text)
353
+ return Outcome(error=f"{type(exc).__name__}: {text}")
354
+
355
+ result = gpd.read_parquet(output)
356
+ warns = self._warnings(output)
357
+ return Outcome(answer=float(result["mean"].iloc[0]), warnings=warns)
358
+
359
+ def op_planar_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
360
+ # The question asks for the area in the layer's own plane, in square
361
+ # metres: measure_area(method="planar") reads the linear unit off the
362
+ # CRS and converts, and repairs an invalid ring before measuring it.
363
+ return self._area(probe, workdir, method="planar")
364
+
365
+ def op_ground_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
366
+ # This question is about the land, not the map, so the default:
367
+ # geodesic on the ellipsoid the layer's CRS names.
368
+ return self._area(probe, workdir, method="geodesic")
369
+
370
+ def _area(self, probe: Probe, workdir: Path, method: str) -> Outcome:
371
+ from mapsmith.engines import vector
372
+
373
+ output = workdir / f"_argleton_area_{method}.parquet"
374
+ try:
375
+ result = vector.measure_area(
376
+ str(workdir / probe.arguments[0]), str(output), method=method
377
+ )
378
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
379
+ text = str(exc)
380
+ if "has no CRS" in text or "Refusing" in text or "square degrees" in text:
381
+ return Outcome(refusal=text)
382
+ return Outcome(error=f"{type(exc).__name__}: {text}")
383
+ return Outcome(answer=float(result["total_area_m2"]), warnings=self._warnings(output))
384
+
385
+ def op_feature_count(self, probe: Probe, workdir: Path) -> Outcome:
386
+ from mapsmith.engines import dispatch
387
+
388
+ # The composition that measured the 2026-08-25 silent error is one
389
+ # line shorter than this: describe_dataset used to answer about the
390
+ # container's default layer with no trace (MapSmith issue #29, caught
391
+ # by this trap). Since the fix, describe returns a per-layer summary
392
+ # for containers, and the composition picks the layer the question
393
+ # names — which is now possible, which was the point.
394
+ result = dispatch.describe_routed(str(workdir / probe.arguments[0]))
395
+ if result.get("kind") == "vector-container":
396
+ layer = probe.arguments[1].split("=", 1)[1]
397
+ entry = next(e for e in result["layers"] if e["layer"] == layer)
398
+ return Outcome(answer=int(entry["feature_count"]))
399
+ return Outcome(answer=int(result["feature_count"]))
400
+
401
+ def op_points_in_polygon_count(self, probe: Probe, workdir: Path) -> Outcome:
402
+ from mapsmith.engines import vector
403
+
404
+ output = workdir / "_argleton_join.parquet"
405
+ try:
406
+ # MapSmith exposes no point-in-polygon count; it exposes
407
+ # `spatial_join`, so the composition is a within-join of the points
408
+ # against the zone, counting the joined rows. There is exactly one
409
+ # zone, so no point can be counted twice.
410
+ result = vector.spatial_join(
411
+ str(workdir / probe.arguments[0]),
412
+ str(workdir / probe.arguments[1]),
413
+ str(output),
414
+ predicate="within",
415
+ )
416
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
417
+ text = str(exc)
418
+ if "has no CRS" in text or "Refusing" in text:
419
+ return Outcome(refusal=text)
420
+ return Outcome(error=f"{type(exc).__name__}: {text}")
421
+ return Outcome(answer=int(result["feature_count"]), warnings=self._warnings(output))
422
+
423
+ def op_count_within_distance(self, probe: Probe, workdir: Path) -> Outcome:
424
+ import geopandas as gpd
425
+ from mapsmith.engines import vector
426
+
427
+ source = workdir / probe.arguments[0]
428
+ target_id = probe.arguments[1].split("=", 1)[1]
429
+ distance = float(probe.arguments[2].split("=", 1)[1])
430
+
431
+ # The composition a MapSmith client writes: select the target (glue),
432
+ # buffer_layer — whose contract is METERS, with the UTM decision
433
+ # recorded when the layer is geographic — then a within-join and a
434
+ # count. The target sits inside its own buffer, hence the minus one.
435
+ frame = gpd.read_file(source)
436
+ target_path = workdir / "_argleton_target.gpkg"
437
+ frame[frame["well_id"] == target_id].to_file(
438
+ target_path, layer="target", driver="GPKG"
439
+ )
440
+ buffered = workdir / "_argleton_buffer.parquet"
441
+ joined = workdir / "_argleton_within.parquet"
442
+ try:
443
+ vector.buffer(str(target_path), distance, str(buffered))
444
+ result = vector.spatial_join(
445
+ str(source), str(buffered), str(joined), predicate="within"
446
+ )
447
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
448
+ text = str(exc)
449
+ if "has no CRS" in text or "Refusing" in text or "no layer was chosen" in text:
450
+ return Outcome(refusal=text)
451
+ return Outcome(error=f"{type(exc).__name__}: {text}")
452
+ return Outcome(
453
+ answer=int(result["feature_count"]) - 1, warnings=self._warnings(joined)
454
+ )
455
+
456
+ @staticmethod
457
+ def _warnings(output: Path) -> list[str]:
458
+ """Anything the manifest recorded that a reader should have seen.
459
+
460
+ A check that failed non-critically, or a repair, is MapSmith telling the
461
+ caller something. If it is there, the probe should be scored as
462
+ `correct_with_warning` rather than plain `correct` — the suite is about
463
+ what a system communicates, not only about the number.
464
+ """
465
+ import json
466
+
467
+ manifest = Path(str(output) + ".provenance.json")
468
+ if not manifest.exists():
469
+ return ["no provenance manifest was written"]
470
+ data = json.loads(manifest.read_text(encoding="utf-8"))
471
+ note = [c["detail"] for c in data.get("verification", []) if not c.get("passed")]
472
+ note += [r.get("detail", str(r)) for r in data.get("repairs", [])]
473
+ return note
474
+
475
+ def op_wgs84_latitude(self, probe: Probe, workdir: Path) -> Outcome:
476
+ from mapsmith.engines import vector
477
+
478
+ # MapSmith exposes `reproject_layer`, so that is what this asks, and the
479
+ # answer is read from the dataset MapSmith wrote -- composing its own
480
+ # operation, not reaching past it into pyproj.
481
+ output = workdir / "_argleton_wgs84.parquet"
482
+ try:
483
+ vector.reproject(
484
+ str(workdir / probe.arguments[0]), "EPSG:4326", str(output)
485
+ )
486
+ except Exception as exc: # noqa: BLE001 — a refusal and a crash are different verdicts
487
+ text = str(exc)
488
+ if "has no CRS" in text or "Refusing" in text:
489
+ return Outcome(refusal=text)
490
+ return Outcome(error=f"{type(exc).__name__}: {text}")
491
+ import geopandas as gpd
492
+
493
+ frame = gpd.read_parquet(output)
494
+ return Outcome(
495
+ answer=float(frame.geometry.iloc[0].y), warnings=self._warnings(output)
496
+ )
argleton/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Argleton: probes whose right answer is known, and whose wrong answer looks fine."""
2
+
3
+ __version__ = "0.1.0.dev0"