@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
@@ -0,0 +1,778 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build `soil.db` — the sealed polygon layer, from the survey areas NRCS publishes.
7
+ *
8
+ * THE ACCUMULATION IS IN SQL, NOT IN A MAP. Classification is per delineation and shared with the
9
+ * resolution measurement, but where the touches GO differs on purpose: the measuring instrument holds
10
+ * them in memory because it is comparing candidate resolutions in one pass, and the builder streams them
11
+ * into a temporary table because memory has to stay flat in row count. A polygon layer's touches
12
+ * outnumber its features, and Iowa is 99 survey areas.
13
+ *
14
+ * ABSENCE IS NO ROW, IN EVERY TABLE. Land outside a published survey area gets no `layer_coverage` row and
15
+ * no summary row — never a zero, never an empty histogram. Inside a published area the coverage row says
16
+ * `designated` at completeness 1.0, because NRCS declares its own mapping complete for those areas at its
17
+ * own scale; a coverage cell reached ONLY by `NOTCOM` and access-denied polygons gets no row either,
18
+ * because the polygon exists and the soil mapping behind it does not.
19
+ *
20
+ * A COVERAGE ROW LICENSES ONLY THAT THE AUTHORITY MAPPED HERE. The reading is the class distribution, and
21
+ * a cell that is 100% `unrated_share` is `designated`-complete and carries no capability reading
22
+ * whatsoever. That pairing is not a corner case: 17.1% of national components carry no capability rating,
23
+ * and for the irrigated rating the figure is 85.1%.
24
+ *
25
+ * THE AREA CROSS-CHECK USES THE AUTHORITY'S OWN PUBLISHED FIGURE. `legend.areaacres` is what NRCS states
26
+ * the survey area covers — 378,800 acres for `IA153`, which is 1,532.9 km² against the 1,532.5 km² an
27
+ * independent projected measurement of the same shapefile reports. Comparing the spherical ring sum
28
+ * against it catches a hole read as an exterior ring, whose absence is silent: such a polygon is
29
+ * well-formed and simply answers "inside" for ground the authority did not map.
30
+ */
31
+
32
+ import { readFileSize } from "@mailwoman/core/fs/readers"
33
+ import {
34
+ areaAgreementFrom,
35
+ createLayerCoverageTable,
36
+ createLayerManifestTable,
37
+ designatedCoverageCells,
38
+ polygonLayerManifest,
39
+ writeLayerCoverage,
40
+ writeLayerManifest,
41
+ type AreaAgreementReading,
42
+ type CoverageCell,
43
+ } from "@mailwoman/core/layers"
44
+ import { resolveModulePath } from "@mailwoman/core/module/resolvers"
45
+ import { ingestChunkArguments, mergeCountsInto, runChunkProcess } from "@mailwoman/core/utils"
46
+ import {
47
+ arealPolygons,
48
+ geometryContains,
49
+ interiorCoverageCells,
50
+ shortCellToInt,
51
+ type MultiPolygonRings,
52
+ type ParsedGeometry,
53
+ } from "@mailwoman/spatial"
54
+ import type { DatabaseClient } from "@mailwoman/sqlite/client"
55
+ import { buildSealedArtifact } from "@mailwoman/sqlite/sealed-build"
56
+ import { cellToLatLng } from "h3-js"
57
+
58
+ import { createSoilTables, type SoilDatabase, type SoilSurveyAreaTable } from "#schema"
59
+ import { reduceCells, resolveCells } from "#sdk/cell-tiers"
60
+ import type { SoilFeatureSource } from "#sdk/ingest"
61
+ import type { SoilChunkResult } from "#sdk/ingest-chunk"
62
+ import { ingestSoilChunk } from "#sdk/ingest-chunk"
63
+ import { WEIGHT_LATTICE_DEPTH } from "#sdk/reduce"
64
+ import type { SurveyAreaAttributes } from "#sdk/survey-area"
65
+ import {
66
+ SOIL_SHARE_WEIGHTING,
67
+ SOIL_SHARE_WEIGHTING_DESCRIPTION,
68
+ soilLayerName,
69
+ SSURGO_ATTRIBUTION,
70
+ SSURGO_LICENSE,
71
+ SSURGO_SOURCE,
72
+ } from "#vocabulary"
73
+
74
+ /**
75
+ * Schema version of the domain tables. Bumped when a column changes meaning, never for an added column a reader can
76
+ * ignore.
77
+ */
78
+ export const SOIL_SCHEMA_VERSION = 1
79
+
80
+ /**
81
+ * Delineation ids per chunk process.
82
+ *
83
+ * Sized against the ceiling the flood layer measured rather than guessed: single-process runs over that product died
84
+ * after roughly 510,000 and 798,000 features as h3's WASM heap fragmented. 100,000 leaves five times that margin, and
85
+ * the cost of a smaller number is one interpreter start per chunk. Iowa's largest survey area holds well under it, so
86
+ * on this build the bound costs one process per area — which is what makes an area's failure nameable.
87
+ */
88
+ export const DEFAULT_CHUNK_SIZE = 100_000
89
+
90
+ /**
91
+ * Square metres in an acre — the unit `legend.areaacres` publishes in.
92
+ */
93
+ const M2_PER_ACRE = 4046.8564224
94
+
95
+ /**
96
+ * The relative gap between the ring-area total and the authority's own published acreage that fails the build.
97
+ *
98
+ * Two percent. The comparison is a spherical ring area against a figure NRCS itself warns "may differ from that
99
+ * measured using GIS software due to different measuring techniques and rounding practices, or due to the fact that the
100
+ * value has been adjusted so that the sum total of all map units in the legend equals that listed for soil survey area"
101
+ * — so an exact test would be brittle. Two percent sits far above the 0.03% `IA153` measures and far below the error a
102
+ * hole-blind read produces, which the zoning survey measured at 4.1% over a whole national layer.
103
+ */
104
+ const AREA_TOLERANCE = 0.02
105
+
106
+ /**
107
+ * One survey area, ready to build: where its geometry is, and what its tabular export said.
108
+ */
109
+ export interface SurveyAreaInput {
110
+ attributes: SurveyAreaAttributes
111
+ /**
112
+ * The map-unit polygon shapefile.
113
+ */
114
+ shapefilePath?: string
115
+ /**
116
+ * The survey area's own outline, already read.
117
+ */
118
+ outline: ParsedGeometry
119
+ /**
120
+ * An in-process feature source. Correct for a fixture and for anything small; the batched path builds one of these
121
+ * per chunk, so the two share one implementation.
122
+ */
123
+ source?: SoilFeatureSource
124
+ /**
125
+ * The delineation count the shapefile declares, when the caller has already read it.
126
+ */
127
+ declaredFeatureCount?: number
128
+ }
129
+
130
+ export interface BuildSoilOptions {
131
+ /**
132
+ * The survey areas to build, in the order they should be ingested.
133
+ */
134
+ areas: ReadonlyArray<SurveyAreaInput>
135
+ /**
136
+ * The region the layer name carries — the pilot's is `ia`.
137
+ */
138
+ region: string
139
+ /**
140
+ * Where the sealed artifact lands. The build writes beside it and swaps.
141
+ */
142
+ out: string
143
+ /**
144
+ * The refresh the build ingested — `layer_manifest.version` and `source_vintage`.
145
+ */
146
+ sourceVintage: string
147
+ buildCmd: string
148
+ buildSHA: string
149
+ /**
150
+ * ISO-8601, supplied by the caller. Never generated here: the contract says so, and a library-generated timestamp
151
+ * makes two builds of the same inputs differ.
152
+ */
153
+ createdAt: string
154
+ /**
155
+ * The resolution the cell index and the reduction are built at — chosen from the measurement.
156
+ */
157
+ indexResolution: number
158
+ /**
159
+ * The resolution `layer_coverage` rows are keyed at. Must be coarser than the index resolution.
160
+ */
161
+ coverageResolution: number
162
+ /**
163
+ * Delineation ids per chunk process. See {@link DEFAULT_CHUNK_SIZE}.
164
+ */
165
+ chunkSize?: number
166
+ /**
167
+ * Run the ingest IN THIS PROCESS rather than spawning chunk children. Only for fixtures, which carry no shapefile for
168
+ * a child to open.
169
+ */
170
+ inProcess?: boolean
171
+ onProgress?: (message: string) => void
172
+ }
173
+
174
+ export interface BuildSoilResult {
175
+ out: string
176
+ region: string
177
+ surveyAreas: number
178
+ delineations: number
179
+ mapUnits: number
180
+ components: number
181
+ indexResolution: number
182
+ coverageResolution: number
183
+ wholeCellRows: number
184
+ partialCellRows: number
185
+ /**
186
+ * `partialCellRows / (wholeCellRows + partialCellRows)` over the STORED index rows. The whole side is compacted, so
187
+ * this is not the same number the resolution was chosen on and is reported separately.
188
+ */
189
+ storedPartialShare: number
190
+ capabilityCells: number
191
+ /**
192
+ * Cells the lattice was used for, rather than the whole-cell fast path.
193
+ */
194
+ sampledCells: number
195
+ /**
196
+ * Cells whose top class covers less than half the cell — the §4.7 number, taken off the shipping artifact rather than
197
+ * out of a separate harness.
198
+ */
199
+ topClassUnderHalfCells: number
200
+ topClassUnderHalfShare: number
201
+ /**
202
+ * Cells with NO class at all: the survey mapped them and rated nothing there.
203
+ */
204
+ classlessCells: number
205
+ /**
206
+ * Cells the index touched that no lattice point landed inside. Dropped rather than stored as an all-zero
207
+ * distribution, and counted because a large number would mean the lattice is too coarse for this geometry.
208
+ */
209
+ unsampledCells: number
210
+ meanDelineationsPerCell: number
211
+ coarsenedFeatures: number
212
+ storedResolutions: number[]
213
+ coverageCells: number
214
+ /**
215
+ * Coverage cells inside a survey-area outline that no delineation with soil mapping reached. Reported rather than
216
+ * smoothed over: SSURGO is wall-to-wall inside a published area, so a large number means the outline and the
217
+ * delineations disagree.
218
+ */
219
+ coverageCellsWithoutMapping: number
220
+ /**
221
+ * The area readings in square kilometres against the authority's own published figure, with the witness stated. The
222
+ * `known` count of areas that publish an acreage is what a receipt reads the absence against.
223
+ */
224
+ area: AreaAgreementReading
225
+ sizeBytes: number
226
+ }
227
+
228
+ /**
229
+ * Build the layer.
230
+ *
231
+ * @throws {Error} On a delineation the classifier refuses, a streamed count that disagrees with the shapefile's own
232
+ * declaration, an area total that disagrees with the authority's published acreage, or a set of outlines that yields
233
+ * no interior coverage cell at all.
234
+ */
235
+ export async function buildSoilDatabase(options: BuildSoilOptions): Promise<BuildSoilResult> {
236
+ if (options.coverageResolution >= options.indexResolution) {
237
+ throw new Error(
238
+ `soil build: the coverage resolution (${options.coverageResolution}) must be coarser than the index resolution (${options.indexResolution}) — a row's coverage cell is the PARENT of its index cell`
239
+ )
240
+ }
241
+
242
+ if (!options.areas.length) {
243
+ throw new Error(
244
+ "soil build: no survey areas were supplied — an empty build writes an artifact that answers unknown everywhere while reporting success"
245
+ )
246
+ }
247
+
248
+ const built = await buildSealedArtifact<SoilDatabase, StreamResult, Omit<BuildSoilResult, "sizeBytes">>({
249
+ out: options.out,
250
+ createTables: async (kdb) => {
251
+ await createSoilTables(kdb)
252
+ await createLayerManifestTable(kdb)
253
+ await createLayerCoverageTable(kdb)
254
+
255
+ // The touch table exists only for this build and is dropped before the artifact is sealed. No primary key while
256
+ // loading: the resolution queries below read it through indexes created once the load is done, and a clustered
257
+ // key would sort every insert against an ingest order nothing controls.
258
+ kdb.exec(
259
+ "CREATE TABLE build_cell_touch (h3_cell INTEGER NOT NULL, resolution INTEGER NOT NULL, area_id TEXT NOT NULL, is_full INTEGER NOT NULL)"
260
+ )
261
+ },
262
+ ingest: async (kdb) => {
263
+ // Attributes FIRST because the ingest needs one thing out of them — which map units have no soil mapping behind
264
+ // them — and because a delineation whose map unit is missing must fail while the artifact is still empty rather
265
+ // than after millions of geometry rows are written.
266
+ writeAttributes(kdb, options.areas)
267
+
268
+ if (!options.inProcess) return undefined
269
+
270
+ return aggregateChunks(await ingestInProcess(kdb, options))
271
+ },
272
+ ...(options.inProcess
273
+ ? {}
274
+ : { batched: async (tmpPath: string) => aggregateChunks(await runBatchedIngest(tmpPath, options)) }),
275
+ finish: async (kdb, ingested) => {
276
+ assertDelineationCounts(options.areas, ingested)
277
+ const area = assertAreaAgreement(options.areas, ingested)
278
+
279
+ options.onProgress?.(`${ingested.delineations.toLocaleString()} delineations written · resolving cells`)
280
+
281
+ const cells = resolveCells(kdb, options.indexResolution)
282
+
283
+ options.onProgress?.(
284
+ `${cells.wholeRows.toLocaleString()} whole (compacted) · ${cells.partialRows.toLocaleString()} partial · reducing`
285
+ )
286
+
287
+ const reduced = reduceCells(kdb, options.indexResolution, options.onProgress)
288
+
289
+ const coverage = buildCoverageCells(options, ingested)
290
+
291
+ await writeLayerCoverage(kdb, coverage.cells)
292
+
293
+ writeSurveyAreaRows(kdb, options, coverage.cellsByArea)
294
+ writeVocabularyRows(kdb, options.areas)
295
+
296
+ // THE SPINE KEY NAMES THE TABLE A CONSUMER JOINS ON, table-qualified, per the layer contract. For this layer
297
+ // that is the REDUCTION rather than the containment index: `soil_capability_cell` holds one row per cell at one
298
+ // resolution, which `soil_map_unit_cell` does not — it is keyed `(cell, delineation)` and is mixed-resolution by
299
+ // construction, so it is a tier the reader walks rather than a key a consumer joins.
300
+ await writeLayerManifest(
301
+ kdb,
302
+ polygonLayerManifest(options, {
303
+ name: soilLayerName(options.region),
304
+ schemaVersion: SOIL_SCHEMA_VERSION,
305
+ license: SSURGO_LICENSE,
306
+ attribution: SSURGO_ATTRIBUTION,
307
+ source: SSURGO_SOURCE,
308
+ cellColumn: "soil_capability_cell.h3_cell",
309
+ })
310
+ )
311
+
312
+ kdb.exec("DROP TABLE build_cell_touch")
313
+
314
+ const totalCellRows = cells.wholeRows + cells.partialRows
315
+
316
+ return {
317
+ out: options.out,
318
+ region: options.region,
319
+ surveyAreas: options.areas.length,
320
+ delineations: ingested.delineations,
321
+ mapUnits: options.areas.reduce((sum, input) => sum + input.attributes.mapUnits.length, 0),
322
+ components: options.areas.reduce((sum, input) => sum + input.attributes.components.length, 0),
323
+ indexResolution: options.indexResolution,
324
+ coverageResolution: options.coverageResolution,
325
+ wholeCellRows: cells.wholeRows,
326
+ partialCellRows: cells.partialRows,
327
+ storedPartialShare: totalCellRows ? cells.partialRows / totalCellRows : 0,
328
+ capabilityCells: reduced.cells,
329
+ sampledCells: reduced.sampled,
330
+ topClassUnderHalfCells: reduced.topClassUnderHalf,
331
+ topClassUnderHalfShare: reduced.cells ? reduced.topClassUnderHalf / reduced.cells : 0,
332
+ classlessCells: reduced.classless,
333
+ unsampledCells: reduced.unsampled,
334
+ meanDelineationsPerCell: reduced.cells ? reduced.candidatePairs / reduced.cells : 0,
335
+ coarsenedFeatures: ingested.coarsened,
336
+ storedResolutions: cells.resolutions,
337
+ coverageCells: coverage.cells.length,
338
+ coverageCellsWithoutMapping: coverage.withoutMapping,
339
+ area,
340
+ }
341
+ },
342
+ })
343
+
344
+ return { ...built, sizeBytes: await readFileSize(options.out) }
345
+ }
346
+
347
+ /**
348
+ * What the whole ingest produced, however many processes it took.
349
+ */
350
+ interface StreamResult {
351
+ delineations: number
352
+ coarsened: number
353
+ byArea: Map<string, number>
354
+ observedByCoverageCell: Map<number, number>
355
+ mappedByCoverageCell: Map<number, number>
356
+ nestedM2: number
357
+ allExteriorM2: number
358
+ }
359
+
360
+ /**
361
+ * Add up what the chunks reported.
362
+ *
363
+ * Exported for its own test: the coverage-cell arithmetic is the one part of the batched path a fixture build cannot
364
+ * reach, and getting it wrong produces a well-formed artifact that under-reports how many delineations a cell holds.
365
+ */
366
+ export function aggregateChunks(chunks: ReadonlyArray<SoilChunkResult>): StreamResult {
367
+ const byArea = new Map<string, number>()
368
+ const observedByCoverageCell = new Map<number, number>()
369
+ const mappedByCoverageCell = new Map<number, number>()
370
+
371
+ let delineations = 0
372
+ let coarsened = 0
373
+ let nestedM2 = 0
374
+ let allExteriorM2 = 0
375
+
376
+ for (const chunk of chunks) {
377
+ delineations += chunk.delineations
378
+ coarsened += chunk.coarsened
379
+ nestedM2 += chunk.area.nestedM2
380
+ allExteriorM2 += chunk.area.allExteriorM2
381
+
382
+ byArea.set(chunk.areaSymbol, (byArea.get(chunk.areaSymbol) ?? 0) + chunk.delineations)
383
+
384
+ // A coverage cell straddles chunk boundaries — a range of feature ids is not a region, and a coverage cell can
385
+ // straddle two survey areas — so the counts ADD rather than replace.
386
+ mergeCountsInto(observedByCoverageCell, chunk.observedByCoverageCell)
387
+ mergeCountsInto(mappedByCoverageCell, chunk.mappedByCoverageCell)
388
+ }
389
+
390
+ return { delineations, coarsened, byArea, observedByCoverageCell, mappedByCoverageCell, nestedM2, allExteriorM2 }
391
+ }
392
+
393
+ /**
394
+ * Refuse a build that streamed fewer delineations than a survey area declares.
395
+ *
396
+ * A short read builds a smaller county and reports success, which is the partial result that must throw.
397
+ */
398
+ function assertDelineationCounts(areas: ReadonlyArray<SurveyAreaInput>, streamed: StreamResult): void {
399
+ for (const area of areas) {
400
+ const declared = area.declaredFeatureCount ?? area.source?.declaredFeatureCount
401
+
402
+ if (declared === undefined) continue
403
+
404
+ const actual = streamed.byArea.get(area.attributes.areasymbol) ?? 0
405
+
406
+ if (actual !== declared) {
407
+ throw new Error(
408
+ `soil build: ${area.attributes.areasymbol} streamed ${actual} delineations, its shapefile declares ${declared} — a short read builds a smaller survey area and reports success`
409
+ )
410
+ }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Refuse an artifact whose rings do not add up to the acreage the authority publishes.
416
+ *
417
+ * The message carries the hole-blind total beside the nested one, because the gap between them is the diagnosis: a hole
418
+ * read as an exterior ring answers "inside" for every point in it. A build over survey areas that publish no acreage
419
+ * has no witness — the reading's own type says so, and the `known` count is what a receipt names.
420
+ */
421
+ function assertAreaAgreement(areas: ReadonlyArray<SurveyAreaInput>, streamed: StreamResult): AreaAgreementReading {
422
+ let publishedAcres = 0
423
+ let known = 0
424
+
425
+ for (const input of areas) {
426
+ if (input.attributes.areaAcres !== null) {
427
+ publishedAcres += input.attributes.areaAcres
428
+
429
+ known++
430
+ }
431
+ }
432
+
433
+ const area = areaAgreementFrom(
434
+ { nestedM2: streamed.nestedM2, allExteriorM2: streamed.allExteriorM2 },
435
+ known ? publishedAcres * M2_PER_ACRE : undefined
436
+ )
437
+
438
+ if (area.witness === "source" && area.relativeGap > AREA_TOLERANCE) {
439
+ throw new Error(
440
+ `soil build: the encoded rings total ${area.nestedKM2.toFixed(1)} km² against the ${area.sourceKM2.toFixed(1)} km² the authority publishes for these ${known} survey areas ` +
441
+ `(${(area.relativeGap * 100).toFixed(2)}% apart, tolerance ${(AREA_TOLERANCE * 100).toFixed(0)}%). Read without their holes the same rings total ` +
442
+ `${area.allExteriorKM2.toFixed(1)} km², so compare the two: a hole-blind read answers "inside" for every point in a hole`
443
+ )
444
+ }
445
+
446
+ return area
447
+ }
448
+
449
+ /**
450
+ * Write every survey area's map units, components and vocabulary before any geometry is streamed.
451
+ */
452
+ function writeAttributes(database: DatabaseClient<SoilDatabase>, areas: ReadonlyArray<SurveyAreaInput>): void {
453
+ const insertMapUnit = database.prepare(
454
+ "INSERT OR REPLACE INTO soil_map_unit (mukey, areasymbol, musym, muname, mukind, mustatus, farmlndcl, farmland_scope, niccdcd, niccdcdpct, no_mapping) " +
455
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
456
+ )
457
+
458
+ const insertComponent = database.prepare(
459
+ "INSERT OR REPLACE INTO soil_component (cokey, mukey, comppct_r, compname, compkind, nirrcapcl, nirrcapscl, irrcapcl, irrcapscl, nccpi_v3) " +
460
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
461
+ )
462
+
463
+ database.exec("BEGIN")
464
+
465
+ for (const area of areas) {
466
+ for (const mapUnit of area.attributes.mapUnits) {
467
+ insertMapUnit.run(
468
+ mapUnit.mukey,
469
+ mapUnit.areasymbol,
470
+ mapUnit.musym,
471
+ mapUnit.muname,
472
+ mapUnit.mukind,
473
+ mapUnit.mustatus,
474
+ mapUnit.farmlndcl,
475
+ mapUnit.farmland_scope,
476
+ mapUnit.niccdcd,
477
+ mapUnit.niccdcdpct,
478
+ mapUnit.no_mapping
479
+ )
480
+ }
481
+
482
+ for (const component of area.attributes.components) {
483
+ insertComponent.run(
484
+ component.cokey,
485
+ component.mukey,
486
+ component.comppct_r,
487
+ component.compname,
488
+ component.compkind,
489
+ component.nirrcapcl,
490
+ component.nirrcapscl,
491
+ component.irrcapcl,
492
+ component.irrcapscl,
493
+ component.nccpi_v3
494
+ )
495
+ }
496
+ }
497
+
498
+ database.exec("COMMIT")
499
+ }
500
+
501
+ /**
502
+ * The map units with no soil mapping behind them, from what was just written.
503
+ */
504
+ function noMappingMukeys(areas: ReadonlyArray<SurveyAreaInput>): Set<string> {
505
+ const mukeys = new Set<string>()
506
+
507
+ for (const area of areas) {
508
+ for (const mapUnit of area.attributes.mapUnits) {
509
+ if (mapUnit.no_mapping) {
510
+ mukeys.add(mapUnit.mukey)
511
+ }
512
+ }
513
+ }
514
+
515
+ return mukeys
516
+ }
517
+
518
+ /**
519
+ * The in-process ingest — one chunk per survey area, all in this interpreter. Fixtures only.
520
+ */
521
+ async function ingestInProcess(
522
+ database: DatabaseClient<SoilDatabase>,
523
+ options: BuildSoilOptions
524
+ ): Promise<SoilChunkResult[]> {
525
+ const noMapping = noMappingMukeys(options.areas)
526
+ const chunks: SoilChunkResult[] = []
527
+
528
+ for (const area of options.areas) {
529
+ if (!area.source) {
530
+ throw new Error(
531
+ `soil build: ${area.attributes.areasymbol} was built in-process without a feature source — the in-process path is for fixtures, which supply one`
532
+ )
533
+ }
534
+
535
+ chunks.push(
536
+ await ingestSoilChunk(database, {
537
+ source: area.source,
538
+ indexResolution: options.indexResolution,
539
+ coverageResolution: options.coverageResolution,
540
+ noMappingMukeys: noMapping,
541
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
542
+ })
543
+ )
544
+ }
545
+
546
+ return chunks
547
+ }
548
+
549
+ /**
550
+ * Run the ingest as a sequence of bounded child processes, one per range of one survey area's FIDs. The shared chunk
551
+ * contract — the parent's no-handle rule, and the fail-loud handling of a chunk that dies or prints nothing — lives
552
+ * with `ingestChunkArguments` and `runChunkProcess`.
553
+ */
554
+ async function runBatchedIngest(tmpPath: string, options: BuildSoilOptions): Promise<SoilChunkResult[]> {
555
+ const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE
556
+ const script = resolveModulePath("@mailwoman/soil/scripts/ingest-chunk")
557
+ const noMapping = [...noMappingMukeys(options.areas)]
558
+ const chunks: SoilChunkResult[] = []
559
+
560
+ for (const area of options.areas) {
561
+ if (!area.shapefilePath) {
562
+ throw new Error(
563
+ `soil build: ${area.attributes.areasymbol} has no shapefile path, so no chunk process can open it`
564
+ )
565
+ }
566
+
567
+ const declared = area.declaredFeatureCount ?? 0
568
+
569
+ for (let from = 0; from < Math.max(declared, 1); from += chunkSize) {
570
+ const to = Math.min(from + chunkSize - 1, declared - 1)
571
+
572
+ options.onProgress?.(`${area.attributes.areasymbol}: FID ${from}–${to}`)
573
+
574
+ chunks.push(
575
+ await runChunkProcess<SoilChunkResult>({
576
+ script,
577
+ context: "soil build",
578
+ subject: `chunk ${area.attributes.areasymbol} FID ${from}–${to}`,
579
+ args: ingestChunkArguments({
580
+ database: tmpPath,
581
+ args: [
582
+ "--shapefile",
583
+ area.shapefilePath,
584
+ "--area-symbol",
585
+ area.attributes.areasymbol,
586
+ "--fid-from",
587
+ String(from),
588
+ "--fid-to",
589
+ String(to),
590
+ "--no-mapping-mukeys",
591
+ noMapping.join(","),
592
+ ],
593
+ indexResolution: options.indexResolution,
594
+ coverageResolution: options.coverageResolution,
595
+ }),
596
+ })
597
+ )
598
+ }
599
+ }
600
+
601
+ return chunks
602
+ }
603
+
604
+ /**
605
+ * The coverage rows: one per interior cell of the built footprint that soil mapping actually reaches, and none outside.
606
+ *
607
+ * THE INTERIOR TEST RUNS ONCE OVER THE UNION OF EVERY OUTLINE BUILT, NOT PER SURVEY AREA, and the difference is most of
608
+ * a state. The test is conservative — it keeps only cells lying WHOLLY inside — so applied per area it drops every cell
609
+ * a county border crosses. Measured on Polk County alone at resolution 6: 20 interior cells against the roughly 42 the
610
+ * county spans by area, so more than half of it would read `unknown` while sitting inside a survey the build had
611
+ * ingested. Run over the union, only the OUTER border of the built set is dropped, which is the honest edge: beyond it
612
+ * lies ground this artifact does not hold.
613
+ *
614
+ * The conservatism itself stays. A cell wrongly called interior would state that an authority determined a location it
615
+ * never looked at, and a point in the dropped strip reading `unknown` is the truthful answer for ground the built set
616
+ * may or may not reach.
617
+ *
618
+ * `observed_rows` counts the delineations reaching the cell, which is what the contract's column means. A cell reached
619
+ * only by `NOTCOM` and access-denied polygons gets NO ROW — the polygon exists, the soil mapping behind it does not,
620
+ * and the survey's §3.2 puts that case with the absences rather than with the coverage.
621
+ */
622
+ function buildCoverageCells(
623
+ options: BuildSoilOptions,
624
+ streamed: StreamResult
625
+ ): { cells: CoverageCell[]; cellsByArea: Map<string, number>; withoutMapping: number } {
626
+ const footprint: ParsedGeometry = {
627
+ type: "MultiPolygon",
628
+ coordinates: options.areas.flatMap((input) => outlinePolygons(input.outline)),
629
+ }
630
+
631
+ const interior = interiorCoverageCells(footprint, options.coverageResolution)
632
+
633
+ if (!interior.length) {
634
+ throw new Error(
635
+ `soil build: the ${options.areas.length} built outline(s) yield no interior cell at resolution ${options.coverageResolution} — the artifact would carry no coverage rows and answer "unknown" everywhere while reporting success`
636
+ )
637
+ }
638
+
639
+ const mapped = (h3Cell: number): boolean => (streamed.mappedByCoverageCell.get(h3Cell) ?? 0) > 0
640
+
641
+ const cells = designatedCoverageCells(
642
+ interior.map((cell) => shortCellToInt(cell)),
643
+ streamed.observedByCoverageCell,
644
+ { include: mapped }
645
+ )
646
+
647
+ const cellsByArea = new Map<string, number>()
648
+
649
+ let withoutMapping = 0
650
+
651
+ for (const cell of interior) {
652
+ if (!mapped(shortCellToInt(cell))) {
653
+ withoutMapping++
654
+
655
+ continue
656
+ }
657
+
658
+ // Attributed by the cell's CENTRE, so each row is counted for exactly one survey area even where the cell straddles
659
+ // two. The count is a per-area receipt, not part of the coverage claim — the claim is the row set itself.
660
+ const [latitude, longitude] = cellToLatLng(cell)
661
+ const owner = options.areas.find((input) => geometryContains(input.outline, longitude, latitude))
662
+
663
+ if (owner) {
664
+ const symbol = owner.attributes.areasymbol
665
+
666
+ cellsByArea.set(symbol, (cellsByArea.get(symbol) ?? 0) + 1)
667
+ }
668
+ }
669
+
670
+ return { cells, cellsByArea, withoutMapping }
671
+ }
672
+
673
+ /**
674
+ * One outline's polygons, in the `MultiPolygon` coordinate shape, whichever areal type it arrived as.
675
+ *
676
+ * @throws {TypeError} When the outline is not areal. A survey area whose footprint cannot be read would silently
677
+ * contribute nothing to the union, and the coverage over it would simply be absent.
678
+ */
679
+ function outlinePolygons(outline: ParsedGeometry): MultiPolygonRings {
680
+ const polygons = arealPolygons(outline)
681
+
682
+ if (polygons) return polygons
683
+
684
+ throw new TypeError(
685
+ `soil build: a survey-area outline is a ${outline.type}, which bounds no area — its coverage would be silently absent rather than refused`
686
+ )
687
+ }
688
+
689
+ /**
690
+ * Insert one row per survey area.
691
+ */
692
+ function writeSurveyAreaRows(
693
+ database: DatabaseClient<SoilDatabase>,
694
+ options: BuildSoilOptions,
695
+ cellsByArea: ReadonlyMap<string, number>
696
+ ): void {
697
+ const insert = database.prepare(
698
+ "INSERT OR REPLACE INTO soil_survey_area (areasymbol, areaname, saverest, saversion, survey_source_date, survey_source_title, source_scale, mapping_scale, area_acres, min_lat, min_lon, max_lat, max_lon, coverage_cells, coverage_resolution) " +
699
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
700
+ )
701
+
702
+ database.exec("BEGIN")
703
+
704
+ for (const area of options.areas) {
705
+ const bounds = database
706
+ .prepare(
707
+ "SELECT min(min_lat) AS min_lat, min(min_lon) AS min_lon, max(max_lat) AS max_lat, max(max_lon) AS max_lon FROM soil_map_unit_area WHERE areasymbol = ?"
708
+ )
709
+ .get(area.attributes.areasymbol) as {
710
+ min_lat: number | null
711
+ min_lon: number | null
712
+ max_lat: number | null
713
+ max_lon: number | null
714
+ }
715
+
716
+ const row: SoilSurveyAreaTable = {
717
+ areasymbol: area.attributes.areasymbol,
718
+ areaname: area.attributes.areaname,
719
+ saverest: area.attributes.saverest,
720
+ saversion: area.attributes.saversion,
721
+ survey_source_date: area.attributes.surveySourceDate,
722
+ survey_source_title: area.attributes.surveySourceTitle,
723
+ source_scale: area.attributes.sourceScale,
724
+ mapping_scale: area.attributes.mappingScale,
725
+ area_acres: area.attributes.areaAcres,
726
+ min_lat: bounds.min_lat ?? 0,
727
+ min_lon: bounds.min_lon ?? 0,
728
+ max_lat: bounds.max_lat ?? 0,
729
+ max_lon: bounds.max_lon ?? 0,
730
+ coverage_cells: cellsByArea.get(area.attributes.areasymbol) ?? 0,
731
+ coverage_resolution: options.coverageResolution,
732
+ }
733
+
734
+ insert.run(
735
+ row.areasymbol,
736
+ row.areaname,
737
+ row.saverest,
738
+ row.saversion,
739
+ row.survey_source_date,
740
+ row.survey_source_title,
741
+ row.source_scale,
742
+ row.mapping_scale,
743
+ row.area_acres,
744
+ row.min_lat,
745
+ row.min_lon,
746
+ row.max_lat,
747
+ row.max_lon,
748
+ row.coverage_cells,
749
+ row.coverage_resolution
750
+ )
751
+ }
752
+
753
+ database.exec("COMMIT")
754
+ }
755
+
756
+ /**
757
+ * Insert the authority's declared domains, plus the weighting the shares were produced under.
758
+ *
759
+ * The weighting rides in the vocabulary table as well as on every row: the row-level copy is what a consumer reads, and
760
+ * this one carries the sentence that says what it MEANS, which no column can.
761
+ */
762
+ function writeVocabularyRows(database: DatabaseClient<SoilDatabase>, areas: ReadonlyArray<SurveyAreaInput>): void {
763
+ const insert = database.prepare(
764
+ "INSERT OR REPLACE INTO soil_vocabulary (domain, code, definition, sequence) VALUES (?, ?, ?, ?)"
765
+ )
766
+
767
+ database.exec("BEGIN")
768
+
769
+ for (const area of areas) {
770
+ for (const member of area.attributes.domains) {
771
+ insert.run(member.domain, member.code, member.definition, member.sequence)
772
+ }
773
+ }
774
+
775
+ insert.run("share_weighting", SOIL_SHARE_WEIGHTING, SOIL_SHARE_WEIGHTING_DESCRIPTION, WEIGHT_LATTICE_DEPTH)
776
+
777
+ database.exec("COMMIT")
778
+ }