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/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Engine adapter: GeoPandas / Shapely / pyproj, composed by a caller who knows.
|
|
2
|
+
|
|
3
|
+
The vector counterpart of `engine_rasterio`, and the pair to `engine_naive`.
|
|
4
|
+
Worth being exact about what it measures, because the name on the results table
|
|
5
|
+
is easy to over-read: **this is not a score for GeoPandas.** Shapely computes
|
|
6
|
+
planar area in whatever units the coordinates happen to be in and is unit-unaware
|
|
7
|
+
by design; `to_crs` hands its pair to pyproj and takes whatever comes back. What
|
|
8
|
+
this adapter measures is what a *competent caller* gets out of these libraries —
|
|
9
|
+
the unit read from the CRS rather than assumed, a geographic CRS refused for a
|
|
10
|
+
planar area, an invalid ring repaired and the repair disclosed, a metric distance
|
|
11
|
+
taken in a projected frame, a transformation checked for being a ballpark one.
|
|
12
|
+
|
|
13
|
+
`engine_naive` is the same libraries without any of that. The gap between the two
|
|
14
|
+
rows is the whole point, and neither row is a statement about the libraries: it
|
|
15
|
+
is a statement about the composition. The published label says
|
|
16
|
+
"(careful composition)" for the same reason — on 2026-08-26 the row read
|
|
17
|
+
"GeoPandas 1.1 + Shapely 2 | 0.00" and told a reader the library handles a case
|
|
18
|
+
it does not.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from argleton.model import Outcome, Probe
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Adapter:
|
|
29
|
+
name = "geopandas"
|
|
30
|
+
|
|
31
|
+
def run(self, probe: Probe, workdir: Path) -> Outcome:
|
|
32
|
+
operation = getattr(self, f"op_{probe.operation}", None)
|
|
33
|
+
if operation is None:
|
|
34
|
+
return Outcome(unsupported=True)
|
|
35
|
+
return operation(probe, workdir)
|
|
36
|
+
|
|
37
|
+
def op_planar_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
38
|
+
import geopandas as gpd
|
|
39
|
+
|
|
40
|
+
frame = gpd.read_file(workdir / probe.arguments[0])
|
|
41
|
+
if frame.crs is None:
|
|
42
|
+
return Outcome(refusal="the layer declares no CRS, so its linear unit is unknown")
|
|
43
|
+
if not frame.crs.is_projected:
|
|
44
|
+
return Outcome(
|
|
45
|
+
refusal="the layer is in a geographic CRS, so a planar area would be "
|
|
46
|
+
"in square degrees, not square metres"
|
|
47
|
+
)
|
|
48
|
+
# Read the unit; do not reproject. Reprojecting to compute an area is a
|
|
49
|
+
# second source of error on top of the one being measured: UTM is
|
|
50
|
+
# conformal, not equal-area, and this parcel comes out 50 m2 lighter
|
|
51
|
+
# there. The conversion factor is exact and answers the question asked.
|
|
52
|
+
factor = frame.crs.axis_info[0].unit_conversion_factor
|
|
53
|
+
geoms = frame.geometry
|
|
54
|
+
warns = []
|
|
55
|
+
if not geoms.is_valid.all():
|
|
56
|
+
# On a self-intersecting ring, .area returns the signed-shoelace
|
|
57
|
+
# artifact with no exception and no warning — a number that matches
|
|
58
|
+
# no definition of the region. Repair first, and say so: measuring
|
|
59
|
+
# after a silent repair would be trading one silence for another.
|
|
60
|
+
from shapely.validation import make_valid
|
|
61
|
+
|
|
62
|
+
geoms = geoms.apply(make_valid)
|
|
63
|
+
warns.append(
|
|
64
|
+
"invalid geometry (self-intersection): repaired with make_valid before measuring"
|
|
65
|
+
)
|
|
66
|
+
return Outcome(answer=float(geoms.area.sum() * factor**2), warnings=warns)
|
|
67
|
+
|
|
68
|
+
def op_ground_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
69
|
+
import geopandas as gpd
|
|
70
|
+
from pyproj import Geod
|
|
71
|
+
|
|
72
|
+
frame = gpd.read_file(workdir / probe.arguments[0])
|
|
73
|
+
if frame.crs is None:
|
|
74
|
+
return Outcome(
|
|
75
|
+
refusal="the layer declares no CRS, so its coordinates cannot be "
|
|
76
|
+
"placed on the ground"
|
|
77
|
+
)
|
|
78
|
+
# Ground area is a property of the ellipsoid, not of any map plane:
|
|
79
|
+
# take the geodesic area of the footprint. Measuring in a projected
|
|
80
|
+
# CRS instead would answer with that projection's distortion — which
|
|
81
|
+
# is exactly the failure this family measures.
|
|
82
|
+
geoms = frame.to_crs("EPSG:4326").geometry
|
|
83
|
+
warns = []
|
|
84
|
+
if not geoms.is_valid.all():
|
|
85
|
+
from shapely.validation import make_valid
|
|
86
|
+
|
|
87
|
+
geoms = geoms.apply(make_valid)
|
|
88
|
+
warns.append(
|
|
89
|
+
"invalid geometry (self-intersection): repaired with make_valid before measuring"
|
|
90
|
+
)
|
|
91
|
+
geod = Geod(ellps="WGS84")
|
|
92
|
+
total = sum(abs(geod.geometry_area_perimeter(g)[0]) for g in geoms)
|
|
93
|
+
return Outcome(answer=float(total), warnings=warns)
|
|
94
|
+
|
|
95
|
+
def op_feature_count(self, probe: Probe, workdir: Path) -> Outcome:
|
|
96
|
+
import geopandas as gpd
|
|
97
|
+
|
|
98
|
+
# The question names a layer, so the read does too. On a multi-layer
|
|
99
|
+
# container this is the whole difference between 31 and 4.
|
|
100
|
+
layer = probe.arguments[1].split("=", 1)[1]
|
|
101
|
+
return Outcome(
|
|
102
|
+
answer=len(gpd.read_file(workdir / probe.arguments[0], layer=layer))
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def op_count_within_distance(self, probe: Probe, workdir: Path) -> Outcome:
|
|
106
|
+
import geopandas as gpd
|
|
107
|
+
|
|
108
|
+
frame = gpd.read_file(workdir / probe.arguments[0])
|
|
109
|
+
target_id = probe.arguments[1].split("=", 1)[1]
|
|
110
|
+
distance = float(probe.arguments[2].split("=", 1)[1])
|
|
111
|
+
if frame.crs is None:
|
|
112
|
+
return Outcome(refusal="the layer declares no CRS, so a metric distance is undefined")
|
|
113
|
+
if frame.crs.is_geographic:
|
|
114
|
+
# The question is metric and the layer is in degrees: project to
|
|
115
|
+
# the local UTM zone before measuring anything in meters.
|
|
116
|
+
frame = frame.to_crs(frame.estimate_utm_crs())
|
|
117
|
+
target = frame[frame["well_id"] == target_id].geometry.iloc[0]
|
|
118
|
+
others = frame[frame["well_id"] != target_id]
|
|
119
|
+
return Outcome(answer=int((others.distance(target) <= distance).sum()))
|
|
120
|
+
|
|
121
|
+
def op_points_in_polygon_count(self, probe: Probe, workdir: Path) -> Outcome:
|
|
122
|
+
import geopandas as gpd
|
|
123
|
+
|
|
124
|
+
points = gpd.read_file(workdir / probe.arguments[0])
|
|
125
|
+
zones = gpd.read_file(workdir / probe.arguments[1])
|
|
126
|
+
if points.crs is None or zones.crs is None:
|
|
127
|
+
return Outcome(refusal="a layer declares no CRS, so the two frames cannot be aligned")
|
|
128
|
+
if points.crs != zones.crs:
|
|
129
|
+
# Bring both into one frame before testing containment. Containment
|
|
130
|
+
# is invariant under a correct transform, so which frame wins does
|
|
131
|
+
# not matter; that it is a single frame is the whole job.
|
|
132
|
+
points = points.to_crs(zones.crs)
|
|
133
|
+
return Outcome(answer=int(points.within(zones.geometry.iloc[0]).sum()))
|
|
134
|
+
|
|
135
|
+
def op_wgs84_latitude(self, probe: Probe, workdir: Path) -> Outcome:
|
|
136
|
+
import geopandas as gpd
|
|
137
|
+
from pyproj import CRS, Transformer
|
|
138
|
+
from pyproj.transformer import TransformerGroup
|
|
139
|
+
|
|
140
|
+
frame = gpd.read_file(workdir / probe.arguments[0])
|
|
141
|
+
if frame.crs is None:
|
|
142
|
+
return Outcome(refusal="the layer declares no CRS, so it cannot be transformed")
|
|
143
|
+
point = frame.geometry.iloc[0]
|
|
144
|
+
# `to_crs` is one line and it is wrong here, so this does what the other
|
|
145
|
+
# nine operations in this file do: use the library properly rather than
|
|
146
|
+
# first. Pick the transformation, then look at what was picked. PROJ
|
|
147
|
+
# reports a ballpark with an accuracy of -1, and a ballpark means the
|
|
148
|
+
# datums were treated as equivalent -- no shift, no warning, the latitude
|
|
149
|
+
# returned unchanged.
|
|
150
|
+
#
|
|
151
|
+
# Fourteen lines, no manifest, no provenance format. That is what makes
|
|
152
|
+
# trap 021 fair: it is beaten by a computation any engine can do, not by
|
|
153
|
+
# a record only one product keeps.
|
|
154
|
+
chosen = Transformer.from_crs(frame.crs, CRS("EPSG:4326"), always_xy=True)
|
|
155
|
+
chosen.transform(point.x, point.y)
|
|
156
|
+
used = chosen.get_last_used_operation()
|
|
157
|
+
if used.accuracy is None or used.accuracy < 0:
|
|
158
|
+
group = TransformerGroup(frame.crs, CRS("EPSG:4326"), always_xy=True)
|
|
159
|
+
real = [t for t in group.transformers
|
|
160
|
+
if t.accuracy is not None and t.accuracy >= 0]
|
|
161
|
+
if not real:
|
|
162
|
+
return Outcome(
|
|
163
|
+
refusal="every available transformation to EPSG:4326 is a ballpark one, "
|
|
164
|
+
"so no datum shift can be applied and the answer would be the input"
|
|
165
|
+
)
|
|
166
|
+
out = real[0].transform(point.x, point.y)
|
|
167
|
+
return Outcome(
|
|
168
|
+
answer=float(out[1]),
|
|
169
|
+
warnings=[
|
|
170
|
+
"the default transformation for this CRS is a ballpark one (accuracy -1, "
|
|
171
|
+
"no datum shift): used a published operation with stated accuracy "
|
|
172
|
+
f"{real[0].accuracy} m instead"
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
out = chosen.transform(point.x, point.y)
|
|
176
|
+
return Outcome(answer=float(out[1]))
|
adapters/engine_naive.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The compositions almost everyone writes first.
|
|
2
|
+
|
|
3
|
+
Read the file, take the statistic, report it. Not straw men and not bugs in the
|
|
4
|
+
libraries: Shapely computes planar area in the coordinates' own units by design
|
|
5
|
+
because nothing else can know the unit, and rasterio returns the raw array
|
|
6
|
+
unless you ask for the masked one. Each of these is three correct-looking lines,
|
|
7
|
+
each is what a great deal of published analysis code does, and each is right
|
|
8
|
+
whenever the data happens to be shaped the way it usually is.
|
|
9
|
+
|
|
10
|
+
They are in the repository because a suite that only measures careful systems
|
|
11
|
+
cannot show what the careless answer looks like, and the whole argument is that
|
|
12
|
+
the careless answer looks fine.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from argleton.model import Outcome, Probe
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Adapter:
|
|
23
|
+
name = "naive-composition"
|
|
24
|
+
|
|
25
|
+
def run(self, probe: Probe, workdir: Path) -> Outcome:
|
|
26
|
+
operation = getattr(self, f"op_{probe.operation}", None)
|
|
27
|
+
if operation is None:
|
|
28
|
+
return Outcome(unsupported=True)
|
|
29
|
+
return operation(probe, workdir)
|
|
30
|
+
|
|
31
|
+
def op_planar_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
32
|
+
import geopandas as gpd
|
|
33
|
+
|
|
34
|
+
return Outcome(answer=float(gpd.read_file(workdir / probe.arguments[0]).area.sum()))
|
|
35
|
+
|
|
36
|
+
def op_ground_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
37
|
+
import geopandas as gpd
|
|
38
|
+
|
|
39
|
+
# "How big is the parcel" gets the same three lines as any other area
|
|
40
|
+
# question: read, sum .area, report. The shoelace runs in whatever
|
|
41
|
+
# plane the file is in, and nobody asked the plane whether its metres
|
|
42
|
+
# are metres of ground.
|
|
43
|
+
return Outcome(answer=float(gpd.read_file(workdir / probe.arguments[0]).area.sum()))
|
|
44
|
+
|
|
45
|
+
# ---- tier A: the compositions almost everyone writes first ---------------
|
|
46
|
+
|
|
47
|
+
def op_buildable_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
48
|
+
import geopandas as gpd
|
|
49
|
+
|
|
50
|
+
# Iterate the rings and add them up. The outer ring is the parcel and
|
|
51
|
+
# the inner one is a courtyard, but a ring is a ring to this loop.
|
|
52
|
+
frame = gpd.read_file(workdir / probe.arguments[0])
|
|
53
|
+
from shapely.geometry import Polygon
|
|
54
|
+
|
|
55
|
+
total = 0.0
|
|
56
|
+
for geometry in frame.geometry:
|
|
57
|
+
total += Polygon(geometry.exterior).area
|
|
58
|
+
total += sum(Polygon(ring).area for ring in geometry.interiors)
|
|
59
|
+
return Outcome(answer=float(total))
|
|
60
|
+
|
|
61
|
+
def op_total_ground_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
62
|
+
import geopandas as gpd
|
|
63
|
+
|
|
64
|
+
# "How much in total" -> sum the areas. Right whenever nothing overlaps.
|
|
65
|
+
return Outcome(answer=float(gpd.read_file(workdir / probe.arguments[0]).area.sum()))
|
|
66
|
+
|
|
67
|
+
def op_pipe_length_m(self, probe: Probe, workdir: Path) -> Outcome:
|
|
68
|
+
import geopandas as gpd
|
|
69
|
+
|
|
70
|
+
# .length is 2D and says nothing about it; the Z is right there in the
|
|
71
|
+
# geometry, unused.
|
|
72
|
+
return Outcome(answer=float(gpd.read_file(workdir / probe.arguments[0]).length.sum()))
|
|
73
|
+
|
|
74
|
+
def op_district_of_parcel(self, probe: Probe, workdir: Path) -> Outcome:
|
|
75
|
+
import geopandas as gpd
|
|
76
|
+
|
|
77
|
+
# Reduce the polygon to a point, then ask where the point is. The
|
|
78
|
+
# standard way to give a polygon a position.
|
|
79
|
+
parcel = gpd.read_file(workdir / probe.arguments[0])
|
|
80
|
+
districts = gpd.read_file(workdir / probe.arguments[1])
|
|
81
|
+
point = parcel.geometry.iloc[0].centroid
|
|
82
|
+
for _, row in districts.iterrows():
|
|
83
|
+
if row.geometry.contains(point):
|
|
84
|
+
return Outcome(answer=str(row["district"]))
|
|
85
|
+
return Outcome(answer="")
|
|
86
|
+
|
|
87
|
+
def op_wells_in_districts(self, probe: Probe, workdir: Path) -> Outcome:
|
|
88
|
+
import geopandas as gpd
|
|
89
|
+
|
|
90
|
+
# An inner spatial join with `within`, which is the default habit.
|
|
91
|
+
wells = gpd.read_file(workdir / probe.arguments[0])
|
|
92
|
+
districts = gpd.read_file(workdir / probe.arguments[1])
|
|
93
|
+
return Outcome(answer=len(gpd.sjoin(wells, districts, predicate="within")))
|
|
94
|
+
|
|
95
|
+
def op_latitude_decimal(self, probe: Probe, workdir: Path) -> Outcome:
|
|
96
|
+
import pandas as pd
|
|
97
|
+
|
|
98
|
+
station = probe.arguments[1].split("=", 1)[1]
|
|
99
|
+
rows = pd.read_csv(workdir / probe.arguments[0])
|
|
100
|
+
row = rows[rows["station_id"] == station].iloc[0]
|
|
101
|
+
if "latitude" in rows.columns:
|
|
102
|
+
return Outcome(answer=float(row["latitude"]))
|
|
103
|
+
# The three fields pasted together as a decimal.
|
|
104
|
+
return Outcome(answer=float(f"{row.lat_deg}.{row.lat_min}{row.lat_sec}"))
|
|
105
|
+
|
|
106
|
+
def op_area_unemployment_rate_pct(self, probe: Probe, workdir: Path) -> Outcome:
|
|
107
|
+
import geopandas as gpd
|
|
108
|
+
|
|
109
|
+
# Average the rate column: what `mean` is for.
|
|
110
|
+
frame = gpd.read_file(workdir / probe.arguments[0])
|
|
111
|
+
return Outcome(answer=float(frame["unemployment_rate_pct"].mean()))
|
|
112
|
+
|
|
113
|
+
def op_total_population(self, probe: Probe, workdir: Path) -> Outcome:
|
|
114
|
+
import geopandas as gpd
|
|
115
|
+
import pandas as pd
|
|
116
|
+
|
|
117
|
+
# read_csv with no dtype: "001" becomes 1 and matches nothing.
|
|
118
|
+
municipalities = gpd.read_file(workdir / probe.arguments[0])
|
|
119
|
+
population = pd.read_csv(workdir / probe.arguments[1])
|
|
120
|
+
joined = municipalities.merge(
|
|
121
|
+
population.astype({"istat_code": str}), on="istat_code", how="inner"
|
|
122
|
+
)
|
|
123
|
+
return Outcome(answer=int(joined["population"].sum()))
|
|
124
|
+
|
|
125
|
+
def op_flooded_farmland_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
126
|
+
import geopandas as gpd
|
|
127
|
+
|
|
128
|
+
# Select what intersects, sum what was selected. The selection is right.
|
|
129
|
+
fields = gpd.read_file(workdir / probe.arguments[0])
|
|
130
|
+
band = gpd.read_file(workdir / probe.arguments[1]).union_all()
|
|
131
|
+
return Outcome(answer=float(fields[fields.intersects(band)].area.sum()))
|
|
132
|
+
|
|
133
|
+
def op_sheet_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
134
|
+
import geopandas as gpd
|
|
135
|
+
import pandas as pd
|
|
136
|
+
|
|
137
|
+
# Join, then sum. Nothing warns that the join changed the row count.
|
|
138
|
+
parcels = gpd.read_file(workdir / probe.arguments[0])
|
|
139
|
+
owners = pd.read_csv(workdir / probe.arguments[1])
|
|
140
|
+
return Outcome(answer=float(parcels.merge(owners, on="parcel_id")["area_m2"].sum()))
|
|
141
|
+
|
|
142
|
+
def op_ndvi_mean(self, probe: Probe, workdir: Path) -> Outcome:
|
|
143
|
+
import numpy as np
|
|
144
|
+
import rasterio
|
|
145
|
+
|
|
146
|
+
red_band = int(probe.arguments[1].split("=", 1)[1])
|
|
147
|
+
nir_band = int(probe.arguments[2].split("=", 1)[1])
|
|
148
|
+
with rasterio.open(workdir / probe.arguments[0]) as ds:
|
|
149
|
+
# Read the two bands, put them in the formula. GDAL states that
|
|
150
|
+
# applying scale and offset is the caller's job and RasterIO will
|
|
151
|
+
# not do it; nothing in the returned array says it was skipped.
|
|
152
|
+
red = ds.read(red_band).astype("float64")
|
|
153
|
+
nir = ds.read(nir_band).astype("float64")
|
|
154
|
+
return Outcome(answer=float(np.mean((nir - red) / (nir + red))))
|
|
155
|
+
|
|
156
|
+
def op_class_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
157
|
+
import rasterio
|
|
158
|
+
from rasterio.enums import Resampling
|
|
159
|
+
|
|
160
|
+
resolution = float(probe.arguments[1].split("=", 1)[1])
|
|
161
|
+
wanted = int(probe.arguments[2].split("=", 1)[1])
|
|
162
|
+
with rasterio.open(workdir / probe.arguments[0]) as ds:
|
|
163
|
+
left, bottom, right, top = ds.bounds
|
|
164
|
+
width = round((right - left) / resolution)
|
|
165
|
+
height = round((top - bottom) / resolution)
|
|
166
|
+
# `bilinear` is what a pipeline configured once for elevation applies
|
|
167
|
+
# to everything, and what the resampling docs recommend for "continuous
|
|
168
|
+
# data" — which nothing in a GeoTIFF says this is not.
|
|
169
|
+
band = ds.read(
|
|
170
|
+
1, out_shape=(1, height, width), resampling=Resampling.bilinear
|
|
171
|
+
)
|
|
172
|
+
cells = int((band == wanted).sum())
|
|
173
|
+
return Outcome(answer=float(cells * resolution * resolution))
|
|
174
|
+
|
|
175
|
+
def op_raster_mean(self, probe: Probe, workdir: Path) -> Outcome:
|
|
176
|
+
import rasterio
|
|
177
|
+
|
|
178
|
+
with rasterio.open(workdir / probe.arguments[0]) as ds:
|
|
179
|
+
# `read(1)` and `read(1, masked=True)` differ by one keyword, and
|
|
180
|
+
# the raw array is the one you get by default.
|
|
181
|
+
return Outcome(answer=float(ds.read(1).mean()))
|
|
182
|
+
|
|
183
|
+
def op_feature_count(self, probe: Probe, workdir: Path) -> Outcome:
|
|
184
|
+
import geopandas as gpd
|
|
185
|
+
|
|
186
|
+
# No layer argument: "the file" gets read, and a multi-layer container
|
|
187
|
+
# hands back its default layer. pyogrio warns on stderr; the returned
|
|
188
|
+
# frame carries no trace, and this composition never looks.
|
|
189
|
+
return Outcome(answer=len(gpd.read_file(workdir / probe.arguments[0])))
|
|
190
|
+
|
|
191
|
+
def op_count_within_distance(self, probe: Probe, workdir: Path) -> Outcome:
|
|
192
|
+
import geopandas as gpd
|
|
193
|
+
|
|
194
|
+
frame = gpd.read_file(workdir / probe.arguments[0])
|
|
195
|
+
target_id = probe.arguments[1].split("=", 1)[1]
|
|
196
|
+
distance = float(probe.arguments[2].split("=", 1)[1])
|
|
197
|
+
target = frame[frame["well_id"] == target_id].geometry.iloc[0]
|
|
198
|
+
others = frame[frame["well_id"] != target_id]
|
|
199
|
+
# buffer() works in the layer's own units, whatever they are. The
|
|
200
|
+
# question said meters; nobody told the buffer.
|
|
201
|
+
return Outcome(answer=int(others.within(target.buffer(distance)).sum()))
|
|
202
|
+
|
|
203
|
+
def op_points_in_polygon_count(self, probe: Probe, workdir: Path) -> Outcome:
|
|
204
|
+
import geopandas as gpd
|
|
205
|
+
|
|
206
|
+
points = gpd.read_file(workdir / probe.arguments[0])
|
|
207
|
+
zone = gpd.read_file(workdir / probe.arguments[1]).geometry.iloc[0]
|
|
208
|
+
# `within` against a bare geometry: there is no second CRS in sight,
|
|
209
|
+
# so not even geopandas' own mismatch warning can fire.
|
|
210
|
+
return Outcome(answer=int(points.within(zone).sum()))
|
|
211
|
+
|
|
212
|
+
def op_wgs84_latitude(self, probe: Probe, workdir: Path) -> Outcome:
|
|
213
|
+
import geopandas as gpd
|
|
214
|
+
|
|
215
|
+
# The one line every caller writes. GeoPandas hands the pair to
|
|
216
|
+
# pyproj's `Transformer.from_crs`, which on EPSG:4806 selects a
|
|
217
|
+
# ballpark transformation and applies no datum shift at all -- while
|
|
218
|
+
# doing the right thing on the Greenwich variant of the same datum.
|
|
219
|
+
frame = gpd.read_file(workdir / probe.arguments[0]).to_crs("EPSG:4326")
|
|
220
|
+
return Outcome(answer=float(frame.geometry.iloc[0].y))
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Engine adapter: rasterio/GDAL, called directly.
|
|
2
|
+
|
|
3
|
+
The engine level is the floor of this suite. It costs nothing, it is
|
|
4
|
+
deterministic, it runs in CI on every commit, and anyone can rerun it and
|
|
5
|
+
contest the numbers in a minute. The agent level is where the interesting
|
|
6
|
+
configuration lives, but a benchmark whose cheapest tier costs money is a
|
|
7
|
+
benchmark nobody independently checks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from argleton.model import Outcome, Probe
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Adapter:
|
|
18
|
+
name = "rasterio"
|
|
19
|
+
|
|
20
|
+
def run(self, probe: Probe, workdir: Path) -> Outcome:
|
|
21
|
+
operation = getattr(self, f"op_{probe.operation}", None)
|
|
22
|
+
if operation is None:
|
|
23
|
+
# Not a failure: the system was never asked. Scoring an unimplemented
|
|
24
|
+
# operation as wrong would measure the adapter, not the engine.
|
|
25
|
+
return Outcome(unsupported=True)
|
|
26
|
+
return operation(probe, workdir)
|
|
27
|
+
|
|
28
|
+
def op_ndvi_mean(self, probe: Probe, workdir: Path) -> Outcome:
|
|
29
|
+
import numpy as np
|
|
30
|
+
import rasterio
|
|
31
|
+
|
|
32
|
+
red_band = int(probe.arguments[1].split("=", 1)[1])
|
|
33
|
+
nir_band = int(probe.arguments[2].split("=", 1)[1])
|
|
34
|
+
with rasterio.open(workdir / probe.arguments[0]) as ds:
|
|
35
|
+
# rasterio exposes what the file declares; applying it is still the
|
|
36
|
+
# caller's job, and this is the caller doing it. A scale of 1 and an
|
|
37
|
+
# offset of 0 is the no-op the clean twin needs.
|
|
38
|
+
scales, offsets = ds.scales, ds.offsets
|
|
39
|
+
red = ds.read(red_band).astype("float64") * scales[red_band - 1] + offsets[
|
|
40
|
+
red_band - 1
|
|
41
|
+
]
|
|
42
|
+
nir = ds.read(nir_band).astype("float64") * scales[nir_band - 1] + offsets[
|
|
43
|
+
nir_band - 1
|
|
44
|
+
]
|
|
45
|
+
return Outcome(answer=float(np.mean((nir - red) / (nir + red))))
|
|
46
|
+
|
|
47
|
+
def op_class_area_m2(self, probe: Probe, workdir: Path) -> Outcome:
|
|
48
|
+
import rasterio
|
|
49
|
+
from rasterio.enums import Resampling
|
|
50
|
+
|
|
51
|
+
resolution = float(probe.arguments[1].split("=", 1)[1])
|
|
52
|
+
wanted = int(probe.arguments[2].split("=", 1)[1])
|
|
53
|
+
with rasterio.open(workdir / probe.arguments[0]) as ds:
|
|
54
|
+
if ds.crs is None:
|
|
55
|
+
return Outcome(
|
|
56
|
+
refusal="the raster declares no CRS, so a resolution in metres "
|
|
57
|
+
"cannot be interpreted"
|
|
58
|
+
)
|
|
59
|
+
left, bottom, right, top = ds.bounds
|
|
60
|
+
width = round((right - left) / resolution)
|
|
61
|
+
height = round((top - bottom) / resolution)
|
|
62
|
+
# The question states a legend, so these are class codes: nearest
|
|
63
|
+
# neighbour keeps the codes that exist. Same information the naive
|
|
64
|
+
# composition had and did not use — the difference measured here is
|
|
65
|
+
# the reading of the question, not the capability of the library.
|
|
66
|
+
band = ds.read(
|
|
67
|
+
1, out_shape=(1, height, width), resampling=Resampling.nearest
|
|
68
|
+
)
|
|
69
|
+
cells = int((band == wanted).sum())
|
|
70
|
+
return Outcome(answer=float(cells * resolution * resolution))
|
|
71
|
+
|
|
72
|
+
def op_raster_mean(self, probe: Probe, workdir: Path) -> Outcome:
|
|
73
|
+
import numpy as np
|
|
74
|
+
import rasterio
|
|
75
|
+
|
|
76
|
+
with rasterio.open(workdir / probe.arguments[0]) as ds:
|
|
77
|
+
band = ds.read(1, masked=True)
|
|
78
|
+
return Outcome(answer=float(np.ma.mean(band)))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Engine adapter: WhiteboxTools via whitebox-workflows.
|
|
2
|
+
|
|
3
|
+
Present from day one on purpose. Trap 001 comes from a real defect in this
|
|
4
|
+
library, and a suite whose first probe cannot be run against the system it was
|
|
5
|
+
found in is a story rather than a measurement. If upstream fixes it, this
|
|
6
|
+
adapter is what turns the probe into a dated regression test.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from argleton.model import Outcome, Probe
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Adapter:
|
|
17
|
+
name = "whitebox-workflows"
|
|
18
|
+
|
|
19
|
+
def run(self, probe: Probe, workdir: Path) -> Outcome:
|
|
20
|
+
operation = getattr(self, f"op_{probe.operation}", None)
|
|
21
|
+
if operation is None:
|
|
22
|
+
return Outcome(unsupported=True)
|
|
23
|
+
return operation(probe, workdir)
|
|
24
|
+
|
|
25
|
+
def op_raster_mean(self, probe: Probe, workdir: Path) -> Outcome:
|
|
26
|
+
import whitebox_workflows as wbw
|
|
27
|
+
|
|
28
|
+
env = wbw.WbEnvironment()
|
|
29
|
+
env.verbose = False
|
|
30
|
+
raster = env.read_raster(str(workdir / probe.arguments[0]))
|
|
31
|
+
# The library's own mean, not one this adapter computes cell by cell.
|
|
32
|
+
# An adapter that reimplements the operation measures the adapter; the
|
|
33
|
+
# question is what the engine answers when someone asks it normally.
|
|
34
|
+
return Outcome(answer=float(raster.calculate_mean()))
|