@mailwoman/soil 9.2.0

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 (97) hide show
  1. package/README.md +221 -0
  2. package/lib/index.ts +474 -0
  3. package/lib/schema.ts +493 -0
  4. package/lib/scripts/ingest-chunk.ts +47 -0
  5. package/lib/sdk/acquire.ts +124 -0
  6. package/lib/sdk/build-soil.ts +778 -0
  7. package/lib/sdk/cell-tiers.ts +310 -0
  8. package/lib/sdk/cells.ts +224 -0
  9. package/lib/sdk/client.ts +273 -0
  10. package/lib/sdk/download.ts +161 -0
  11. package/lib/sdk/index.ts +20 -0
  12. package/lib/sdk/ingest-chunk.ts +182 -0
  13. package/lib/sdk/ingest.ts +271 -0
  14. package/lib/sdk/measure-resolutions.ts +81 -0
  15. package/lib/sdk/reduce.ts +375 -0
  16. package/lib/sdk/survey-area.ts +467 -0
  17. package/lib/sdk/tabular.ts +296 -0
  18. package/lib/sdk/verify.ts +369 -0
  19. package/lib/test-kit.ts +255 -0
  20. package/lib/vocabulary.ts +241 -0
  21. package/out/index.d.ts +199 -0
  22. package/out/index.d.ts.map +1 -0
  23. package/out/index.js +255 -0
  24. package/out/index.js.map +1 -0
  25. package/out/schema.d.ts +369 -0
  26. package/out/schema.d.ts.map +1 -0
  27. package/out/schema.js +186 -0
  28. package/out/schema.js.map +1 -0
  29. package/out/scripts/ingest-chunk.d.ts +11 -0
  30. package/out/scripts/ingest-chunk.d.ts.map +1 -0
  31. package/out/scripts/ingest-chunk.js +42 -0
  32. package/out/scripts/ingest-chunk.js.map +1 -0
  33. package/out/sdk/acquire.d.ts +60 -0
  34. package/out/sdk/acquire.d.ts.map +1 -0
  35. package/out/sdk/acquire.js +74 -0
  36. package/out/sdk/acquire.js.map +1 -0
  37. package/out/sdk/build-soil.d.ts +197 -0
  38. package/out/sdk/build-soil.d.ts.map +1 -0
  39. package/out/sdk/build-soil.js +434 -0
  40. package/out/sdk/build-soil.js.map +1 -0
  41. package/out/sdk/cell-tiers.d.ts +47 -0
  42. package/out/sdk/cell-tiers.d.ts.map +1 -0
  43. package/out/sdk/cell-tiers.js +194 -0
  44. package/out/sdk/cell-tiers.js.map +1 -0
  45. package/out/sdk/cells.d.ts +118 -0
  46. package/out/sdk/cells.d.ts.map +1 -0
  47. package/out/sdk/cells.js +140 -0
  48. package/out/sdk/cells.js.map +1 -0
  49. package/out/sdk/client.d.ts +120 -0
  50. package/out/sdk/client.d.ts.map +1 -0
  51. package/out/sdk/client.js +214 -0
  52. package/out/sdk/client.js.map +1 -0
  53. package/out/sdk/download.d.ts +84 -0
  54. package/out/sdk/download.d.ts.map +1 -0
  55. package/out/sdk/download.js +117 -0
  56. package/out/sdk/download.js.map +1 -0
  57. package/out/sdk/index.d.ts +20 -0
  58. package/out/sdk/index.d.ts.map +1 -0
  59. package/out/sdk/index.js +20 -0
  60. package/out/sdk/index.js.map +1 -0
  61. package/out/sdk/ingest-chunk.d.ts +73 -0
  62. package/out/sdk/ingest-chunk.d.ts.map +1 -0
  63. package/out/sdk/ingest-chunk.js +98 -0
  64. package/out/sdk/ingest-chunk.js.map +1 -0
  65. package/out/sdk/ingest.d.ts +132 -0
  66. package/out/sdk/ingest.d.ts.map +1 -0
  67. package/out/sdk/ingest.js +163 -0
  68. package/out/sdk/ingest.js.map +1 -0
  69. package/out/sdk/measure-resolutions.d.ts +39 -0
  70. package/out/sdk/measure-resolutions.d.ts.map +1 -0
  71. package/out/sdk/measure-resolutions.js +52 -0
  72. package/out/sdk/measure-resolutions.js.map +1 -0
  73. package/out/sdk/reduce.d.ts +122 -0
  74. package/out/sdk/reduce.d.ts.map +1 -0
  75. package/out/sdk/reduce.js +266 -0
  76. package/out/sdk/reduce.js.map +1 -0
  77. package/out/sdk/survey-area.d.ts +105 -0
  78. package/out/sdk/survey-area.d.ts.map +1 -0
  79. package/out/sdk/survey-area.js +346 -0
  80. package/out/sdk/survey-area.js.map +1 -0
  81. package/out/sdk/tabular.d.ts +106 -0
  82. package/out/sdk/tabular.d.ts.map +1 -0
  83. package/out/sdk/tabular.js +194 -0
  84. package/out/sdk/tabular.js.map +1 -0
  85. package/out/sdk/verify.d.ts +135 -0
  86. package/out/sdk/verify.d.ts.map +1 -0
  87. package/out/sdk/verify.js +221 -0
  88. package/out/sdk/verify.js.map +1 -0
  89. package/out/test-kit.d.ts +80 -0
  90. package/out/test-kit.d.ts.map +1 -0
  91. package/out/test-kit.js +228 -0
  92. package/out/test-kit.js.map +1 -0
  93. package/out/vocabulary.d.ts +179 -0
  94. package/out/vocabulary.d.ts.map +1 -0
  95. package/out/vocabulary.js +215 -0
  96. package/out/vocabulary.js.map +1 -0
  97. package/package.json +276 -0
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # `@mailwoman/soil`
2
+
3
+ USDA NRCS SSURGO soil survey as a sealed spatial layer: acquisition, the `soil.db` build, and its reader.
4
+
5
+ The layer answers one question — **what does the soil survey assign to the map unit covering this
6
+ location** — and it answers it as a distribution rather than as a class. That shape is not a preference;
7
+ it is what three measurements force.
8
+
9
+ ## What it stores, and why it is a distribution
10
+
11
+ | measurement | figure |
12
+ | --------------------------------------------------------------------------- | -----------------------------: |
13
+ | national map units holding two or more components | 284,777 of 339,191 (**84.0%**) |
14
+ | map units where the largest component covers under half of it | 57,053 (**16.8%**) |
15
+ | `IA153` delineations smaller than one resolution-9 cell | 15,350 of 17,966 (**85.4%**) |
16
+ | NRCS's own dominant-condition share `muaggatt.niccdcdpct`, observed minimum | **2%** |
17
+
18
+ No affordable cell size removes the mixture, because the mixture is the survey's own finding: 128,499
19
+ map units (38.0%) are complexes, associations or undifferentiated groups, which is NRCS stating that the
20
+ soils are intermingled and cannot be separated at the mapping scale. NRCS itself ships its
21
+ dominant-condition class beside the share that class covers. `soil_capability_cell` reproduces that
22
+ pattern at cell grain.
23
+
24
+ **One artifact, two consumers.** A result-level observation reads `top_class` with `top_class_share`;
25
+ a bulk per-cell signal reads `class_shares` plus the four absence shares as one axis. One acquisition,
26
+ one aggregation, one set of provenance rows, and no way for the two to disagree about what the ground is.
27
+
28
+ ## Four absences, and the one positive negative
29
+
30
+ An absence is never represented by a small number. Five readings a consumer can tell apart:
31
+
32
+ | reading | where it lives |
33
+ | ---------------------------------------------------- | -------------------------------------------------- |
34
+ | the survey rated this land as precluding cultivation | class `"8"` in `class_shares` — a determination |
35
+ | the survey did not rate it | `unrated_share` |
36
+ | the rating does not apply to it (rock, water) | `notrateable_share` |
37
+ | the polygon exists, the soil mapping does not | `nodata_share` (`NOTCOM`, `NOTPUB`, access denied) |
38
+ | there is no survey here at all | **no `layer_coverage` row, and no summary row** |
39
+
40
+ Class 8 is a determination and is a class share like any other. Folding it in with the others produces a
41
+ well-formed wrong answer, and 67,547 national components carry it. The irrigated rating makes the point
42
+ again at larger scale: `irrcapcl` is NULL on 85.1% of national components because it is populated only
43
+ where irrigation is a considered use, so it is carried and never reduced.
44
+
45
+ `other_share` carries the truncated minority tail, so the five shares always sum to 1 and a reader can
46
+ see how much was folded away rather than inferring it from a gap. `mapped_share` says how much of the
47
+ cell any delineation covers at all — without it, a survey-area edge cell's unmapped remainder would
48
+ silently deflate every class share.
49
+
50
+ ## The resolution, measured
51
+
52
+ The index resolution is a measurement, not an argument. Measured on `IA153` — 17,966 delineations over
53
+ 1,532.5 km², median delineation 24,863 m²:
54
+
55
+ | res | touched cells | whole | partial | partial share | whole after compaction | (cell, delineation) pairs | mean delineations/cell | top class under half |
56
+ | --- | ------------: | -----: | ------: | ------------: | ---------------------: | ------------------------: | ---------------------: | -------------------: |
57
+ | 7 | 348 | 0 | 348 | **100.0%** | 0 | 24,073 | 69.18 | **44.4%** |
58
+ | 8 | 2,237 | 9 | 2,228 | **99.6%** | 9 | 36,569 | 16.35 | **39.8%** |
59
+ | 9 | 15,136 | 369 | 14,767 | **97.6%** | 315 | 80,956 | 5.35 | **30.7%** |
60
+ | 10 | 104,508 | 13,691 | 90,817 | **86.9%** | 11,537 | 268,408 | 2.57 | **18.2%** |
61
+
62
+ **The `partial` share inverts against the flood layer, exactly as the survey predicted, and the inversion
63
+ is total.** Flood polygons are large against their cells, so most cells fall wholly inside one zone and
64
+ `compactCells` collapses long uniform interiors. Soil delineations are the opposite, so **the containment
65
+ index answers almost no probe on its own at any candidate resolution**, and compaction yields close to
66
+ nothing: at resolution 9, 369 whole cells compact to 315 — a 14.6% reduction, against a flood layer whose
67
+ interiors collapse by orders of magnitude. At resolution 7 it collapses zero of zero.
68
+
69
+ That is why this layer carries the reduced `soil_capability_cell` **alongside** the index rather than
70
+ relying on the index the way the flood layer can. The unsimplified geometry is still the truth and is
71
+ still what the reduction weights by; it is not what answers a probe.
72
+
73
+ **The two numbers move in opposite directions, and only one of them discriminates.** The `partial` share
74
+ is 87–100% at every candidate, so it cannot choose a resolution here — which is itself the finding. The
75
+ mixture number can, and it is the one the choice rests on.
76
+
77
+ **Resolution 9 is the choice.** It is where `poi.db` keys its rows, so a reader already holding another
78
+ layer's cells finds these without a conversion; its mixture share (30.7%) is well inside the range the
79
+ authority's own aggregation lives in; and resolution 10 costs 6.9× the cells (104,508 against 15,136 for
80
+ one county) to move the mixture from 30.7% to 18.2%, and leaves 5.2% of its cells carrying no class at all against
81
+ 2.7% at resolution 9. Resolution 11 was excluded before measuring: it
82
+ would leave 2.1% of `IA153`'s delineations sub-cell at roughly 49× the resolution-9 cell count.
83
+
84
+ For comparison, NRCS's own map-unit-grain `niccdcdpct` reads below half on 3.3% of national map units.
85
+ Aggregating to a resolution-9 cell multiplies that roughly ninefold, which is the cost of the cell grain
86
+ stated as a number rather than as a worry.
87
+
88
+ ## Acquisition
89
+
90
+ - **Soil Data Access** (`sdmdataaccess.nrcs.usda.gov/Tabular/post.rest`) — the survey-area catalogue and
91
+ the point-intersection check, through `APIClient`. Anonymous, no key, measured at 0.374 s for a tabular
92
+ answer and 1.807 s for a point intersection.
93
+ - **Survey-area archives** (`websoilsurvey.sc.egov.usda.gov/DSD/Download/Cache/SSA`) — file transfers on
94
+ raw `fetch`, streamed to disk, saying so in place.
95
+
96
+ Three measured behaviours the code is written against:
97
+
98
+ 1. **Failures come back as XML, including on a timeout.** A bad column, a blocked query and a
99
+ server-side timeout all return an OGC `ServiceExceptionReport`, and the timeout arrives on an HTTP 200.
100
+ Every response is read as text and checked for the report before anything parses it as JSON.
101
+ 2. **The download host answers `HEAD` with 405 and ignores `Range`.** A request with `Range: bytes=0-0`
102
+ returned HTTP 200 and transferred the whole 27,598,377 bytes. Freshness comes from
103
+ `sacatalog.saverest`, which is also what the archive's filename embeds. A wrong date is an HTTP **400**,
104
+ not a 404.
105
+ 3. **The tabular export carries embedded newlines.** `sacatlog.txt` holds 594 newline bytes and exactly
106
+ ONE record, because `fgdcmetadata` is a 43,251-character XML document; `mstabcol.txt` — the column
107
+ dictionary itself — holds 913 newlines and 865 records. The reader is quote-aware end to end.
108
+
109
+ **The archive ships its own schema and its own vocabulary.** `mstab.txt` maps a logical table to the file
110
+ that holds it (`component` → `comp.txt`; neither is guessable), `mstabcol.txt` gives every column's
111
+ position, and `msdomdet.txt` carries each `Choice` column's declared members **with NRCS's own prose
112
+ definition** — capability classes 1 through 8, subclasses `c`/`e`/`s`/`w`, the 28 conditional farmland
113
+ classifications, the six component kinds. The layer reads its domain out of the file it ingested rather
114
+ than transcribing it, stores it in `soil_vocabulary`, and throws on a value outside it.
115
+
116
+ ## License, and where the grant comes from
117
+
118
+ data.gov's entry carries `usa.gov/publicdomain/label/1.0/`, which redirects to a page that declines a
119
+ blanket grant and tells the reader to check with the agency. The agency was checked at the strongest
120
+ available place — **the FGDC metadata NRCS ships inside every archive** — and its use constraints say:
121
+
122
+ > This is public information and may be interpreted by organizations, agencies, units of government, or
123
+ > others based on needs; however, they are responsible for the appropriate application.
124
+
125
+ The build asserts that sentence is present **per survey area**. An area whose use constraints no longer
126
+ carry it is a license change, and a build that absorbed one would ship an artifact under terms nobody
127
+ checked. The acknowledgement the same metadata asks for rides in `layer_manifest.attribution`:
128
+ _U.S. Department of Agriculture, Natural Resources Conservation Service._
129
+
130
+ ## Two dates, and they are not the same fact
131
+
132
+ `sacatalog.saverest` is the refresh. NRCS runs ONE coordinated Annual Soils Refresh each October 1, and
133
+ grouping the catalogue by year returns 2016: 1, 2025: 3,323, 2026: 56 — so a region's areas share a
134
+ vintage. **The field survey underneath is far older.** `IA153` carries a 2025-09-09 refresh over a
135
+ _Soil Survey of Polk County, Iowa_ published in **1960** at 1:15,840, and the dataset's own
136
+ time-period-of-content ends at the refresh. A consumer reading that as survey currency reads it wrong by
137
+ sixty-five years.
138
+
139
+ Both dates are stored per survey area, apart, with the title the older one came from so it is checkable.
140
+ Two scales are kept apart for the same reason: `legend.projectscale` (12,000 for `IA153`) is the scale the
141
+ map units were digitized at; the source citation's own `srcscale` (15,840) is the scale the ground was
142
+ walked at.
143
+
144
+ ## What a reading may claim
145
+
146
+ > the soil survey assigns this capability class to the map unit covering this location
147
+
148
+ and never
149
+
150
+ > this land can (or cannot) be farmed.
151
+
152
+ NRCS says the second reading is wrong, in the metadata it ships: the data "do not eliminate the need for
153
+ onsite sampling, testing, and detailed study of specific sites for intensive uses. Thus, these data and
154
+ their interpretations are intended for planning purposes only." Every reading carries the product's own
155
+ limits for that reason.
156
+
157
+ **The farmland vocabulary is conditional, and two of its categories do not travel.** 24 of its 28 declared
158
+ values carry an "if" — `Prime farmland if drained`, `Prime farmland if irrigated and reclaimed of excess
159
+ salts and sodium` — so the string is stored whole; a boolean `arable` column would be this layer's
160
+ invention. And 7 CFR 657.5 defines prime and unique farmland nationally while §657.5(c) and (d) hand
161
+ statewide and local importance to state and local agencies, so `Farmland of statewide importance` in Iowa
162
+ and in Georgia are not the same claim. `soil_map_unit.farmland_scope` carries that distinction into the
163
+ artifact.
164
+
165
+ ## Building
166
+
167
+ ```bash
168
+ # The smoke rung: one real survey area, end to end.
169
+ mailwoman gazetteer build soil --area IA153 --verify
170
+
171
+ # The pilot: every published Iowa survey area.
172
+ mailwoman gazetteer build soil --region IA --verify
173
+
174
+ # The resolution measurement. Reports a table, not an artifact.
175
+ mailwoman gazetteer build soil --area IA153 --measure-resolutions 7,8,9,10
176
+ ```
177
+
178
+ The build is bounded by construction: one child process per range of a survey area's own FIDs, because
179
+ h3's WASM heap cannot be reset from JavaScript and reports an exhausted allocator as a successful empty
180
+ answer. A per-part zero-cell guard refuses that answer; the process bound is what makes the build
181
+ reproducible. Both live in `@mailwoman/spatial`'s `h3/polygon-cells.ts`, shared with `@mailwoman/flood`,
182
+ because the traps are properties of h3-js rather than of either product.
183
+
184
+ ## Verification
185
+
186
+ `--verify` runs both halves. The positive half re-asks Soil Data Access which map unit covers a sample of
187
+ points drawn deterministically from the artifact, comparing **map unit against map unit** — comparing the
188
+ derived class instead would let a wrong delineation agree by accident whenever two neighbours share a
189
+ class. Disagreements carry the distance to the nearest **edge**, not to the nearest vertex: a point a
190
+ centimeter from a long edge can be meters from every vertex, and the flood layer's one near-miss read
191
+ 1.58 m to vertices and 0.009 m to edges.
192
+
193
+ The negative half samples points in every neighboring state, two of them close to the Iowa border, and
194
+ requires `unknown` — no coverage row — rather than a low-capability reading. The positive half alone would
195
+ pass on an artifact that answered class 8 for the whole planet.
196
+
197
+ ## The observation
198
+
199
+ Default OFF, and the switch is the presence of `$MAILWOMAN_DATA_ROOT/soil/soil.db` rather than a boolean.
200
+ The reading reaches a caller as one additive `QueryIntentMarker` with `code: "authority_designation"` and
201
+ `mechanism: "layer:soil_capability"` — the same code the flood layer's marker uses, under the same `layer`
202
+ family, with a rule of its own. The class never travels without the share it rests on. Ranking, abstention
203
+ and every existing result field are unchanged, and a test pins that a geocode without the layer is
204
+ byte-identical to one with it, minus the marker.
205
+
206
+ ## Not this layer's job
207
+
208
+ - **No raster in the database.** gSSURGO and gNATSGO are the gridded derivatives at 10 m per state and
209
+ 30 m for CONUS, in a projected CRS, distributed through a host that refuses anonymous programmatic
210
+ download. Should a builder reach for one, the raster rule applies: bin at build time to the same
211
+ per-cell class summary shape and store that, never the grid.
212
+ - **No Cropland Data Layer.** It is CC0 and measured, but it answers a different question — observed cover
213
+ in one season, not capability — its accuracy caveats are unread, and it is a raster ingest into a
214
+ repository with no raster tooling. Whoever does build it inherits a meaning-of-zero inversion that
215
+ arrives pre-built in the source's own encoding: the derived Crop Frequency Layer's value domain runs
216
+ `"1"` planted once in 18 years through `"18"` planted every year, then **`"255"` planted ZERO times**,
217
+ while **`"0"` is No Data**. A reader that takes 0 as "never planted" reads _we have no data here_ as
218
+ _nothing was ever grown here_ — exactly backwards. Nothing in this vocabulary uses a numeric sentinel
219
+ for either state, and nothing in it should start.
220
+ - **No suitability score.** The layer repeats what an authority states, in the authority's vocabulary, with
221
+ the authority's dates. The projection to a number belongs to the consumer, not to the layer.