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/__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]))
@@ -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()))