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
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