@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/lib/schema.ts ADDED
@@ -0,0 +1,493 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema for `soil.db` — the polygon truth table, the containment index above it, and the ONE
7
+ * reduction both consumers read, plus the layer-contract tables from `@mailwoman/core/layers`.
8
+ *
9
+ * THREE CELL-FACING TABLES ARE ONE PIPELINE, NOT THREE SOURCES. {@link SoilMapUnitAreaTable} holds what
10
+ * the authority drew, unsimplified. {@link SoilMapUnitCellTable} says which cells each delineation
11
+ * reaches and whether it fills them. {@link SoilCapabilityCellTable} is that index reduced ONCE, at build
12
+ * time, into a per-cell distribution. A `partial` cell's contribution to the reduction is weighted by the
13
+ * area it actually covers, which is why the truth table keeps unsimplified rings: simplify them and the
14
+ * weights change silently.
15
+ *
16
+ * THE REDUCTION STORES A DISTRIBUTION, NEVER A WINNER, AND THAT IS FORCED BY MEASUREMENT RATHER THAN
17
+ * PREFERENCE. 84.0% of the 339,191 national map units hold two or more components; in 16.8% the largest
18
+ * component covers under half the map unit; and 85.4% of `IA153`'s 17,966 delineations are smaller than
19
+ * one resolution-9 cell, so no affordable cell size removes the mixture. NRCS itself ships
20
+ * `muaggatt.niccdcd` — its own dominant-condition capability class — beside `niccdcdpct`, the share that
21
+ * class actually covers, with an observed minimum of 2%. This table reproduces that pattern at cell grain
22
+ * rather than inventing one.
23
+ *
24
+ * AN ABSENCE IS NEVER A SMALL NUMBER. Four separate shares name why the rest of a cell carries no class,
25
+ * and class 8 is NOT among them: class 8 is a determination — the survey looked and rated the land as
26
+ * precluding commercial plant production — so it is a class share like any other. Folding a `NOTCOM`
27
+ * polygon, a water body and an unrated series into "not arable" would produce a well-formed wrong answer,
28
+ * which is what the four shares exist to make impossible.
29
+ *
30
+ * `WITHOUT ROWID` ON THE CELL TABLES AND NEVER ON THE GEOMETRY TABLE. Small fixed-width rows probed by
31
+ * their exact primary key belong in the B-tree; a row carrying a geometry blob does not — clustering it
32
+ * into the B-tree makes every index page a geometry page.
33
+ */
34
+
35
+ import type { LayerContractDatabase } from "@mailwoman/core/layers"
36
+ import { sql, type Kysely } from "kysely"
37
+
38
+ /**
39
+ * Whether an H3 cell lies wholly inside one map-unit delineation, or is crossed by its boundary.
40
+ */
41
+ export const SoilCellContainment = {
42
+ /**
43
+ * Every point in the cell is inside the delineation. Answered from the index alone, with no geometry read.
44
+ */
45
+ Whole: "whole",
46
+ /**
47
+ * The delineation's boundary crosses the cell. The index has narrowed the candidates; the point test decides.
48
+ */
49
+ Partial: "partial",
50
+ } as const
51
+
52
+ export type SoilCellContainment = (typeof SoilCellContainment)[keyof typeof SoilCellContainment]
53
+
54
+ /**
55
+ * One map-unit delineation, verbatim. A plain rowid table: it holds a geometry blob, which is the one shape `WITHOUT
56
+ * ROWID` hurts.
57
+ */
58
+ export interface SoilMapUnitAreaTable {
59
+ /**
60
+ * `<areasymbol>:<ordinal>` — the survey area plus this delineation's position in the authority's own shapefile order.
61
+ * SSURGO publishes no per-delineation key of its own (`mukey` names the MAP UNIT, and one map unit has many
62
+ * delineations), so the ordinal is what makes a row nameable at all. Text, so a source that starts publishing a
63
+ * non-numeric id needs no schema change.
64
+ */
65
+ area_id: string
66
+ /**
67
+ * The map unit this delineation belongs to — NRCS's own key, and the join to every attribute.
68
+ */
69
+ mukey: string
70
+ areasymbol: string
71
+ min_lat: number
72
+ min_lon: number
73
+ max_lat: number
74
+ max_lon: number
75
+ /**
76
+ * The authority's ring coordinates, unsimplified — see `@mailwoman/spatial`'s ring blob for the layout.
77
+ */
78
+ rings: Uint8Array
79
+ }
80
+
81
+ /**
82
+ * Per (cell, delineation): does the delineation cover the whole cell, or only part of it?
83
+ *
84
+ * Keyed on the delineation rather than on the map unit, because the reduction weights by the area a DELINEATION covers
85
+ * and two delineations of one map unit reaching the same cell cover different ground.
86
+ */
87
+ export interface SoilMapUnitCellTable {
88
+ /**
89
+ * 48-bit short H3 cell. Mixed-resolution: `whole` rows are compacted parent-ward, `partial` rows stay at the index
90
+ * resolution.
91
+ */
92
+ h3_cell: number
93
+ /**
94
+ * The resolution this row's cell was captured at. A short cell does not name its own resolution, and a table that
95
+ * mixes them cannot be probed without it.
96
+ */
97
+ resolution: number
98
+ area_id: string
99
+ /**
100
+ * One of {@link SoilCellContainment}.
101
+ */
102
+ containment: string
103
+ }
104
+
105
+ /**
106
+ * One SSURGO map unit — the attribute row every delineation joins to.
107
+ */
108
+ export interface SoilMapUnitTable {
109
+ mukey: string
110
+ areasymbol: string
111
+ /**
112
+ * The map unit symbol. `NOTCOM` and `NOTPUB` are MEANINGFUL values here, not codes to skip: they name a polygon the
113
+ * authority drew with no soil mapping behind it.
114
+ */
115
+ musym: string
116
+ muname: string
117
+ /**
118
+ * `Consociation` | `Complex` | `Association` | `Undifferentiated group`, from the authority's declared domain. A
119
+ * complex is NRCS's statement that two or more soils are intermingled and cannot be separated at the mapping scale —
120
+ * the mixture is the survey's finding rather than this layer's loss.
121
+ */
122
+ mukind: string | null
123
+ mustatus: string | null
124
+ /**
125
+ * The full conditional string, verbatim. NULL is not "not prime farmland": `Not prime farmland` is itself a declared
126
+ * value, and NULL means the map unit carries no farmland classification at all.
127
+ */
128
+ farmlndcl: string | null
129
+ /**
130
+ * Which of {@link FarmlandScope} `farmlndcl` falls under — federal criteria travel between states, delegated ones do
131
+ * not. Derived once at build time so a consumer never has to re-read 7 CFR 657.5 to know whether two rows are
132
+ * comparable.
133
+ */
134
+ farmland_scope: string
135
+ /**
136
+ * NRCS's OWN dominant-condition capability class for the map unit, carried rather than recomputed.
137
+ */
138
+ niccdcd: string | null
139
+ /**
140
+ * And the share that class actually covers. The pair is the pattern this layer's cell reduction reproduces at cell
141
+ * grain, so carrying both makes the two comparable.
142
+ */
143
+ niccdcdpct: number | null
144
+ /**
145
+ * Whether this map unit is a polygon with NO soil mapping behind it — `NOTCOM`, `NOTPUB`, access denied, or a map
146
+ * unit carrying no components at all. Such a map unit contributes to `nodata_share` and NEVER to a class share.
147
+ */
148
+ no_mapping: number
149
+ }
150
+
151
+ /**
152
+ * A map unit's components, because a map unit is a mixture.
153
+ */
154
+ export interface SoilComponentTable {
155
+ cokey: string
156
+ mukey: string
157
+ /**
158
+ * The component's representative percentage of its map unit — the weight the reduction aggregates by.
159
+ */
160
+ comppct_r: number
161
+ compname: string | null
162
+ /**
163
+ * `Miscellaneous area` is what separates NOT-RATEABLE from UNRATED: a rock outcrop or a water body is a component the
164
+ * capability rating does not apply to, while an unrated series is one the survey did not rate.
165
+ */
166
+ compkind: string | null
167
+ /**
168
+ * Nonirrigated Land Capability Class, `"1"`–`"8"`. NULL means NOT RATED, never class 8.
169
+ */
170
+ nirrcapcl: string | null
171
+ /**
172
+ * Subclass `c` | `e` | `s` | `w`.
173
+ */
174
+ nirrcapscl: string | null
175
+ /**
176
+ * The irrigated rating. NULL on 85.1% of national components, because it is populated only where irrigation is a
177
+ * considered use — so its absence is a statement about the rating's applicability rather than about the land, and it
178
+ * is carried but never reduced.
179
+ */
180
+ irrcapcl: string | null
181
+ irrcapscl: string | null
182
+ /**
183
+ * The NCCPI v3.0 overall index in [0, 1], under its own rule name. Never blended with the capability class.
184
+ */
185
+ nccpi_v3: number | null
186
+ }
187
+
188
+ /**
189
+ * THE SHARED ARTIFACT BOTH CONSUMERS READ: one row per cell, the index reduced once.
190
+ *
191
+ * The result-level observation takes {@link SoilCapabilityCellTable.top_class} with the share it rests on; #1683's
192
+ * affordance vector takes `class_shares` plus the four absence shares as its axis. One artifact, one aggregation, one
193
+ * set of provenance rows, and no possibility of the two consumers disagreeing about what the ground is.
194
+ */
195
+ export interface SoilCapabilityCellTable {
196
+ /**
197
+ * 48-bit short H3 cell at the declared index resolution. SINGLE-resolution, unlike {@link SoilMapUnitCellTable}: this
198
+ * is the table a consumer joins on, and a mixed-resolution join key is not one.
199
+ */
200
+ h3_cell: number
201
+ /**
202
+ * JSON: the authority's class codes mapped to their area-weighted share, sorted by descending share. Shares above the
203
+ * declared truncation floor only; the remainder is in `other_share`.
204
+ */
205
+ class_shares: string
206
+ /**
207
+ * Mapped soil components carrying a NULL rating — the survey did not rate them.
208
+ */
209
+ unrated_share: number
210
+ /**
211
+ * Miscellaneous areas (rock outcrop, water) the rating does not apply to.
212
+ */
213
+ notrateable_share: number
214
+ /**
215
+ * `NOTCOM`, `NOTPUB` and access-denied map units: a polygon the authority drew, with no soil mapping behind it.
216
+ */
217
+ nodata_share: number
218
+ /**
219
+ * The truncated minority tail. Stored explicitly so the five shares always sum to 1 and a reader can see how much was
220
+ * folded away rather than inferring it from a gap.
221
+ */
222
+ other_share: number
223
+ /**
224
+ * The fraction of the CELL covered by any map-unit delineation at all.
225
+ *
226
+ * The five shares above are normalized over this, so they sum to 1 exactly. A cell at the edge of a survey area is
227
+ * partly outside every delineation, and without this column that unmapped remainder would silently deflate every
228
+ * class share — an absence represented as a small number, which is the one thing this schema exists to prevent. A
229
+ * cell wholly inside the mapped area reads 1.
230
+ */
231
+ mapped_share: number
232
+ /**
233
+ * The largest class share, and the share it rests on — the result-level consumer's reading, and NRCS's own
234
+ * `niccdcd`/`niccdcdpct` pattern at cell grain. NULL when the cell carries no class at all, which is a real answer: a
235
+ * cell that is 100% `unrated_share` is complete and holds no capability reading whatsoever.
236
+ */
237
+ top_class: string | null
238
+ top_class_share: number | null
239
+ /**
240
+ * Which weighting produced the shares — {@link SOIL_SHARE_WEIGHTING}. Stored per row rather than only in the
241
+ * manifest, because a later build at a different weighting must not read as the same claim.
242
+ */
243
+ weighting: string
244
+ /**
245
+ * How many delineations reached this cell. The denominator behind every share above, and the number that separates a
246
+ * confident single-delineation cell from a crowded one.
247
+ */
248
+ delineations: number
249
+ }
250
+
251
+ /**
252
+ * The authority's mapped footprint, one row per published survey area — derived from the survey-area OUTLINE and each
253
+ * area's own metadata, NEVER from the rated polygons.
254
+ *
255
+ * Deriving it from the rated polygons is the error §3.2 of the survey describes: `NOTCOM` and access-denied map units
256
+ * are INSIDE the footprint and carry no rating, so a footprint taken from the rated set would report them as unmapped
257
+ * when the authority has in fact declared exactly what they are.
258
+ */
259
+ export interface SoilSurveyAreaTable {
260
+ areasymbol: string
261
+ areaname: string
262
+ /**
263
+ * The version-established date from `sacatalog.saverest` — the refresh, and the manifest's vintage.
264
+ */
265
+ saverest: string
266
+ saversion: number | null
267
+ /**
268
+ * The OLDEST source citation date in the area's own FGDC lineage — the field survey the republished polygons rest on.
269
+ *
270
+ * This is a different fact from `saverest` and keeping them apart is the point: `IA153` carries a 2025-09-09 refresh
271
+ * over a field survey published in 1960, and the dataset's own time-period-of-content ends at the refresh, so a
272
+ * consumer reading that as survey currency reads it wrong.
273
+ */
274
+ survey_source_date: string | null
275
+ /**
276
+ * The title of the source `survey_source_date` came from, so the date is checkable rather than assertible.
277
+ */
278
+ survey_source_title: string | null
279
+ /**
280
+ * The scale of that original source — 15840 for `IA153`'s 1960 survey.
281
+ */
282
+ source_scale: number | null
283
+ /**
284
+ * The scale the map units were DIGITIZED at, from `legend.projectscale` — 12000 for `IA153`. A different number from
285
+ * `source_scale` and a different fact: one is how finely the ground was walked, the other how finely it was drawn.
286
+ */
287
+ mapping_scale: number | null
288
+ /**
289
+ * The area the authority publishes for the survey area, in acres. The independent witness the ring-area check
290
+ * compares against.
291
+ */
292
+ area_acres: number | null
293
+ min_lat: number
294
+ min_lon: number
295
+ max_lat: number
296
+ max_lon: number
297
+ /**
298
+ * How many `layer_coverage` rows this survey area produced, and at what resolution.
299
+ */
300
+ coverage_cells: number
301
+ coverage_resolution: number
302
+ }
303
+
304
+ /**
305
+ * The authority's declared domain for one `Choice` column, read out of the `msdomdet.txt` the archive ships.
306
+ *
307
+ * Stored so a reader can refuse a code the layer was never built to hold, and so the authority's own prose definition
308
+ * of "capability class 3" travels with the artifact instead of living in a handbook the reader has to go find.
309
+ */
310
+ export interface SoilVocabularyTable {
311
+ /**
312
+ * The domain name as NRCS spells it — `capability_class`, `capability_subclass`, `farmland_classification`,
313
+ * `component_kind`, `mapunit_kind`.
314
+ */
315
+ domain: string
316
+ /**
317
+ * The value as it appears in the data.
318
+ */
319
+ code: string
320
+ /**
321
+ * The authority's own definition of it.
322
+ */
323
+ definition: string
324
+ /**
325
+ * Ordering within the domain, as the authority declares it.
326
+ */
327
+ sequence: number
328
+ }
329
+
330
+ /**
331
+ * Pass to `new DatabaseClient<SoilDatabase>(...)`.
332
+ */
333
+ export interface SoilDatabase extends LayerContractDatabase {
334
+ soil_map_unit_area: SoilMapUnitAreaTable
335
+ soil_map_unit_cell: SoilMapUnitCellTable
336
+ soil_map_unit: SoilMapUnitTable
337
+ soil_component: SoilComponentTable
338
+ soil_capability_cell: SoilCapabilityCellTable
339
+ soil_survey_area: SoilSurveyAreaTable
340
+ soil_vocabulary: SoilVocabularyTable
341
+ }
342
+
343
+ /**
344
+ * The slice of a Kysely handle the DDL touches. Kysely is invariant in its schema parameter, so naming only the members
345
+ * these functions call lets a caller pass its own wider handle.
346
+ */
347
+ export type SoilSchemaHandle = Pick<Kysely<SoilDatabase>, "schema">
348
+
349
+ /**
350
+ * Create `soil_map_unit_area`. A PLAIN rowid table on purpose — the `rings` blob is exactly the payload `WITHOUT ROWID`
351
+ * penalizes.
352
+ */
353
+ export async function createSoilMapUnitAreaTable(db: SoilSchemaHandle): Promise<void> {
354
+ await db.schema
355
+ .createTable("soil_map_unit_area")
356
+ .addColumn("area_id", "text", (c) => c.primaryKey())
357
+ .addColumn("mukey", "text", (c) => c.notNull())
358
+ .addColumn("areasymbol", "text", (c) => c.notNull())
359
+ .addColumn("min_lat", "real", (c) => c.notNull())
360
+ .addColumn("min_lon", "real", (c) => c.notNull())
361
+ .addColumn("max_lat", "real", (c) => c.notNull())
362
+ .addColumn("max_lon", "real", (c) => c.notNull())
363
+ .addColumn("rings", "blob", (c) => c.notNull())
364
+ .execute()
365
+ }
366
+
367
+ /**
368
+ * Create `soil_map_unit_cell` — the containment index. Small fixed-width rows probed by their exact primary key.
369
+ */
370
+ export async function createSoilMapUnitCellTable(db: SoilSchemaHandle): Promise<void> {
371
+ await db.schema
372
+ .createTable("soil_map_unit_cell")
373
+ .addColumn("h3_cell", "integer", (c) => c.notNull())
374
+ .addColumn("resolution", "integer", (c) => c.notNull())
375
+ .addColumn("area_id", "text", (c) => c.notNull())
376
+ .addColumn("containment", "text", (c) => c.notNull())
377
+ .addPrimaryKeyConstraint("soil_map_unit_cell_pk", ["h3_cell", "area_id"])
378
+ // `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
379
+ .modifyEnd(sql`without rowid`)
380
+ .execute()
381
+ }
382
+
383
+ /**
384
+ * Create `soil_map_unit`.
385
+ */
386
+ export async function createSoilMapUnitTable(db: SoilSchemaHandle): Promise<void> {
387
+ await db.schema
388
+ .createTable("soil_map_unit")
389
+ .addColumn("mukey", "text", (c) => c.primaryKey())
390
+ .addColumn("areasymbol", "text", (c) => c.notNull())
391
+ .addColumn("musym", "text", (c) => c.notNull())
392
+ .addColumn("muname", "text", (c) => c.notNull())
393
+ .addColumn("mukind", "text")
394
+ .addColumn("mustatus", "text")
395
+ .addColumn("farmlndcl", "text")
396
+ .addColumn("farmland_scope", "text", (c) => c.notNull())
397
+ .addColumn("niccdcd", "text")
398
+ .addColumn("niccdcdpct", "integer")
399
+ .addColumn("no_mapping", "integer", (c) => c.notNull())
400
+ .execute()
401
+ }
402
+
403
+ /**
404
+ * Create `soil_component`.
405
+ */
406
+ export async function createSoilComponentTable(db: SoilSchemaHandle): Promise<void> {
407
+ await db.schema
408
+ .createTable("soil_component")
409
+ .addColumn("cokey", "text", (c) => c.primaryKey())
410
+ .addColumn("mukey", "text", (c) => c.notNull())
411
+ .addColumn("comppct_r", "integer", (c) => c.notNull())
412
+ .addColumn("compname", "text")
413
+ .addColumn("compkind", "text")
414
+ .addColumn("nirrcapcl", "text")
415
+ .addColumn("nirrcapscl", "text")
416
+ .addColumn("irrcapcl", "text")
417
+ .addColumn("irrcapscl", "text")
418
+ .addColumn("nccpi_v3", "real")
419
+ .execute()
420
+ }
421
+
422
+ /**
423
+ * Create `soil_capability_cell` — the reduction both consumers read.
424
+ */
425
+ export async function createSoilCapabilityCellTable(db: SoilSchemaHandle): Promise<void> {
426
+ await db.schema
427
+ .createTable("soil_capability_cell")
428
+ .addColumn("h3_cell", "integer", (c) => c.primaryKey())
429
+ .addColumn("class_shares", "text", (c) => c.notNull())
430
+ .addColumn("unrated_share", "real", (c) => c.notNull())
431
+ .addColumn("notrateable_share", "real", (c) => c.notNull())
432
+ .addColumn("nodata_share", "real", (c) => c.notNull())
433
+ .addColumn("other_share", "real", (c) => c.notNull())
434
+ .addColumn("mapped_share", "real", (c) => c.notNull())
435
+ .addColumn("top_class", "text")
436
+ .addColumn("top_class_share", "real")
437
+ .addColumn("weighting", "text", (c) => c.notNull())
438
+ .addColumn("delineations", "integer", (c) => c.notNull())
439
+ .modifyEnd(sql`without rowid`)
440
+ .execute()
441
+ }
442
+
443
+ /**
444
+ * Create `soil_survey_area`.
445
+ */
446
+ export async function createSoilSurveyAreaTable(db: SoilSchemaHandle): Promise<void> {
447
+ await db.schema
448
+ .createTable("soil_survey_area")
449
+ .addColumn("areasymbol", "text", (c) => c.primaryKey())
450
+ .addColumn("areaname", "text", (c) => c.notNull())
451
+ .addColumn("saverest", "text", (c) => c.notNull())
452
+ .addColumn("saversion", "integer")
453
+ .addColumn("survey_source_date", "text")
454
+ .addColumn("survey_source_title", "text")
455
+ .addColumn("source_scale", "integer")
456
+ .addColumn("mapping_scale", "integer")
457
+ .addColumn("area_acres", "integer")
458
+ .addColumn("min_lat", "real", (c) => c.notNull())
459
+ .addColumn("min_lon", "real", (c) => c.notNull())
460
+ .addColumn("max_lat", "real", (c) => c.notNull())
461
+ .addColumn("max_lon", "real", (c) => c.notNull())
462
+ .addColumn("coverage_cells", "integer", (c) => c.notNull())
463
+ .addColumn("coverage_resolution", "integer", (c) => c.notNull())
464
+ .execute()
465
+ }
466
+
467
+ /**
468
+ * Create `soil_vocabulary`.
469
+ */
470
+ export async function createSoilVocabularyTable(db: SoilSchemaHandle): Promise<void> {
471
+ await db.schema
472
+ .createTable("soil_vocabulary")
473
+ .addColumn("domain", "text", (c) => c.notNull())
474
+ .addColumn("code", "text", (c) => c.notNull())
475
+ .addColumn("definition", "text", (c) => c.notNull())
476
+ .addColumn("sequence", "integer", (c) => c.notNull())
477
+ .addPrimaryKeyConstraint("soil_vocabulary_pk", ["domain", "code"])
478
+ .modifyEnd(sql`without rowid`)
479
+ .execute()
480
+ }
481
+
482
+ /**
483
+ * Every domain table this layer owns, in dependency order.
484
+ */
485
+ export async function createSoilTables(db: SoilSchemaHandle): Promise<void> {
486
+ await createSoilMapUnitAreaTable(db)
487
+ await createSoilMapUnitCellTable(db)
488
+ await createSoilMapUnitTable(db)
489
+ await createSoilComponentTable(db)
490
+ await createSoilCapabilityCellTable(db)
491
+ await createSoilSurveyAreaTable(db)
492
+ await createSoilVocabularyTable(db)
493
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * One chunk of the soil ingest, as its own process — spawned by `buildSoilDatabase`, never run by hand.
7
+ * The process boundary and the stdout contract live with `runIngestChunkScript`; what stays here is only
8
+ * this product's flags and its feature-source constructor.
9
+ */
10
+
11
+ import { requiredArgument } from "@mailwoman/core/scripting/arguments"
12
+ import { INGEST_CHUNK_FLAGS, runIngestChunkScript } from "@mailwoman/core/scripting/ingest-chunk-script"
13
+ import type { DatabaseClient } from "@mailwoman/sqlite/client"
14
+
15
+ import type { SoilDatabase } from "#schema"
16
+ import { createShapefileFeatureSource } from "#sdk/ingest"
17
+ import { ingestSoilChunk } from "#sdk/ingest-chunk"
18
+
19
+ await runIngestChunkScript({
20
+ context: "soil ingest-chunk",
21
+ options: {
22
+ ...INGEST_CHUNK_FLAGS,
23
+ shapefile: { type: "string" },
24
+ "area-symbol": { type: "string" },
25
+ "fid-from": { type: "string" },
26
+ "fid-to": { type: "string" },
27
+ "no-mapping-mukeys": { type: "string" },
28
+ },
29
+ run: async (database: DatabaseClient<SoilDatabase>, values, chunk) =>
30
+ ingestSoilChunk(database, {
31
+ source: await createShapefileFeatureSource({
32
+ shapefilePath: requiredArgument("soil ingest-chunk", "shapefile", values.shapefile),
33
+ areaSymbol: requiredArgument("soil ingest-chunk", "area-symbol", values["area-symbol"]),
34
+ fidFrom: Number(requiredArgument("soil ingest-chunk", "fid-from", values["fid-from"])),
35
+ fidTo: Number(requiredArgument("soil ingest-chunk", "fid-to", values["fid-to"])),
36
+ // A range's own count is not knowable up front — `ogrinfo` reports the layer's total and nothing narrower — so
37
+ // the chunk asserts nothing about its size and the PARENT checks the per-area sum against the shapefile's.
38
+ declaredFeatureCount: 0,
39
+ }),
40
+ indexResolution: chunk.indexResolution,
41
+ coverageResolution: chunk.coverageResolution,
42
+ // An empty string is an empty set, not "every map unit": a build where nothing lacks soil mapping passes one, and
43
+ // `"".split(",")` yields one empty element that has to be dropped rather than joined against as a mukey.
44
+ noMappingMukeys: new Set((values["no-mapping-mukeys"] ?? "").split(",").filter((mukey) => mukey.length > 0)),
45
+ onProgress: chunk.onProgress,
46
+ }),
47
+ })
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Acquisition, end to end: which survey areas a region holds, their archives, and the inputs the builder
7
+ * takes.
8
+ *
9
+ * THE FRESHNESS QUESTION IS ANSWERED BY THE TABULAR SERVICE, NOT BY THE FILE HOST. `sacatalog.saverest` is
10
+ * the version-established date, and it is also what the archive's filename embeds — so one catalogue call
11
+ * both decides what to download and names the file. The download host cannot answer it: it refuses `HEAD`
12
+ * with 405 and ignores `Range`, so a length probe there is a full transfer.
13
+ *
14
+ * THE VINTAGE IS THE REFRESH THE BUILD INGESTED, AND IT IS ONE DATE FOR THE WHOLE ARTIFACT. NRCS performs
15
+ * ONE coordinated Annual Soils Refresh, each October 1; grouping `sacatalog` by year of `saverest` returns
16
+ * 2016: 1, 2025: 3,323, 2026: 56. So a region's areas share a refresh and the manifest can carry one
17
+ * `source_vintage` — the LATEST of the areas built, because that is the date after which nothing in the
18
+ * artifact changed. Every area's own date is kept per row in `soil_survey_area`, and so is the far older
19
+ * field-survey date, which is the number a currency claim actually turns on.
20
+ */
21
+
22
+ import type { SurveyAreaInput } from "#sdk/build-soil"
23
+ import type { SoilDataAccessClient, SurveyAreaCatalogEntry } from "#sdk/client"
24
+ import { downloadSurveyArea, type SurveyAreaArchive } from "#sdk/download"
25
+ import { mapUnitShapefile, readSoilSourceIdentity, surveyAreaShapefile } from "#sdk/ingest"
26
+ import { readSurveyAreaAttributes, readSurveyAreaOutline } from "#sdk/survey-area"
27
+
28
+ export interface AcquireRegionOptions {
29
+ client: SoilDataAccessClient
30
+ /**
31
+ * The survey-area symbol prefix — a state code (`IA`) for a whole state, or a full symbol (`IA153`) for the
32
+ * single-area rung.
33
+ */
34
+ prefix: string
35
+ /**
36
+ * Where vintages are kept.
37
+ */
38
+ cacheRoot: string
39
+ /**
40
+ * Build only these symbols out of the ones the catalogue returns. Absent means all of them.
41
+ */
42
+ only?: ReadonlyArray<string>
43
+ onProgress?: (message: string) => void
44
+ }
45
+
46
+ /**
47
+ * What one region's acquisition produced.
48
+ */
49
+ export interface AcquiredRegion {
50
+ catalog: SurveyAreaCatalogEntry[]
51
+ archives: SurveyAreaArchive[]
52
+ areas: SurveyAreaInput[]
53
+ /**
54
+ * The refresh the artifact carries — the latest version date among the areas built.
55
+ */
56
+ sourceVintage: string
57
+ }
58
+
59
+ /**
60
+ * Acquire every survey area a prefix names, and turn them into builder inputs.
61
+ *
62
+ * @throws {Error} When the catalogue holds nothing for the prefix, when `only` names a symbol the catalogue does not
63
+ * carry, or when any area's archive, metadata or shapefile refuses.
64
+ */
65
+ export async function acquireRegion(options: AcquireRegionOptions): Promise<AcquiredRegion> {
66
+ const catalog = await options.client.readSurveyAreaCatalog(options.prefix)
67
+ const wanted = options.only ? new Set(options.only.map((symbol) => symbol.toUpperCase())) : undefined
68
+
69
+ const selected = wanted ? catalog.filter((entry) => wanted.has(entry.areasymbol.toUpperCase())) : catalog
70
+
71
+ if (wanted) {
72
+ const found = new Set(selected.map((entry) => entry.areasymbol.toUpperCase()))
73
+ const missing = [...wanted].filter((symbol) => !found.has(symbol))
74
+
75
+ if (missing.length) {
76
+ throw new Error(
77
+ `soil acquire: the catalogue holds no survey area named ${missing.join(", ")} — building the rest would quietly answer a smaller question than the one asked`
78
+ )
79
+ }
80
+ }
81
+
82
+ options.onProgress?.(`${selected.length} survey area(s) from the catalogue`)
83
+
84
+ const archives: SurveyAreaArchive[] = []
85
+ const areas: SurveyAreaInput[] = []
86
+
87
+ for (const entry of selected) {
88
+ const archive = await downloadSurveyArea({
89
+ areaSymbol: entry.areasymbol,
90
+ versionDate: entry.saverest,
91
+ cacheRoot: options.cacheRoot,
92
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
93
+ })
94
+
95
+ archives.push(archive)
96
+
97
+ const attributes = await readSurveyAreaAttributes(archive.tabularDirectory, entry.areasymbol)
98
+ const shapefilePath = mapUnitShapefile(archive.spatialDirectory, entry.areasymbol)
99
+ const identity = await readSoilSourceIdentity({ shapefilePath })
100
+ const outline = await readSurveyAreaOutline(surveyAreaShapefile(archive.spatialDirectory, entry.areasymbol))
101
+
102
+ options.onProgress?.(
103
+ `${entry.areasymbol}: ${identity.featureCount.toLocaleString()} delineations · ${attributes.mapUnits.length} map units · ` +
104
+ `${attributes.components.length} components · refresh ${attributes.saverest} · field survey ${attributes.surveySourceDate ?? "unstated"}`
105
+ )
106
+
107
+ areas.push({
108
+ attributes,
109
+ shapefilePath,
110
+ outline,
111
+ declaredFeatureCount: identity.featureCount,
112
+ })
113
+ }
114
+
115
+ // The LATEST refresh among the areas built, because that is the date after which nothing in this artifact changed.
116
+ // Taking the earliest would claim a currency the newest area does not have; taking today's date would claim one no
117
+ // area has.
118
+ const sourceVintage = selected
119
+ .map((entry) => entry.saverest)
120
+ .toSorted()
121
+ .at(-1)!
122
+
123
+ return { catalog, archives, areas, sourceVintage }
124
+ }