@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,467 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * One survey area's attributes, its own metadata, and its mapped footprint.
7
+ *
8
+ * THE FOOTPRINT IS THE SURVEY-AREA OUTLINE, NEVER THE UNION OF THE RATED POLYGONS. `NOTCOM`,
9
+ * access-denied and `NOTPUB` map units are INSIDE the footprint and carry no rating, so a footprint taken
10
+ * from the rated set would report them as unmapped when the authority has declared exactly what they are.
11
+ * The archive ships the outline as its own shapefile — `soilsa_a_<areasymbol>.shp`, one feature — which is
12
+ * why this layer never has to reconstruct it.
13
+ *
14
+ * THE REFRESH DATE IS NOT THE SURVEY DATE, AND CONFLATING THEM IS THE CURRENCY LIE THIS FILE EXISTS TO
15
+ * PREVENT. `IA153` carries `saverest` 2025-09-09 and version 28, and the FGDC lineage inside the same
16
+ * archive cites `Soil Survey of Polk County, Iowa`, 1:15,840, **1960**. The dataset's own
17
+ * time-period-of-content runs 1998-09-22 to 2025-09-09, so a consumer reading that as survey currency
18
+ * reads it wrong by sixty-five years. Both dates are stored, apart, with the title the older one came
19
+ * from so it is checkable rather than assertible.
20
+ *
21
+ * TWO SCALES, ALSO DIFFERENT FACTS. `legend.projectscale` is 12,000 for `IA153` — the scale the map units
22
+ * were digitized at. The 1960 source citation's own `srcscale` is 15,840 — the scale the ground was
23
+ * walked at. Storing one as the other would answer the enlargement caveat's question wrongly.
24
+ *
25
+ * THE LICENCE IS CHECKED PER SURVEY AREA, against the `useconst` element of the metadata that area ships.
26
+ * An area whose use constraints no longer say "This is public information" is a licence change, and a
27
+ * build that absorbed one would ship an artifact under terms nobody checked. The text is boilerplate
28
+ * repeated across SSURGO, which is why asserting it is cheap and why a change in it is loud.
29
+ */
30
+
31
+ import { parseJSONStrict } from "@mailwoman/core/objects"
32
+ import { runFile } from "@mailwoman/core/process"
33
+ import type { ParsedGeometry } from "@mailwoman/spatial"
34
+ import type { PathBuilderLike } from "path-ts"
35
+
36
+ import type { SoilComponentTable, SoilMapUnitTable } from "#schema"
37
+ import { domainCodes, readDeclaredDomains, readTable, readTabularDictionary, type DomainMember } from "#sdk/tabular"
38
+ import {
39
+ COINTERP_OVERALL_RULE_DEPTH,
40
+ farmlandScope,
41
+ NCCPI_V3_RULE_NAME,
42
+ SSURGO_NO_MAPPING_NAMES,
43
+ SSURGO_NO_MAPPING_SYMBOLS,
44
+ SSURGO_PUBLIC_INFORMATION_SENTENCE,
45
+ } from "#vocabulary"
46
+
47
+ /**
48
+ * The declared domains this layer validates against, and stores.
49
+ *
50
+ * `capability_class` is shared by `nirrcapcl`, `irrcapcl` and `muaggatt.niccdcd`, which is why one domain covers three
51
+ * columns.
52
+ */
53
+ export const STORED_DOMAINS = [
54
+ "capability_class",
55
+ "capability_subclass",
56
+ "farmland_classification",
57
+ "component_kind",
58
+ "mapunit_kind",
59
+ "mapunit_status",
60
+ ] as const
61
+
62
+ /**
63
+ * One survey area's tabular attributes, already joined and validated.
64
+ */
65
+ export interface SurveyAreaAttributes {
66
+ areasymbol: string
67
+ areaname: string
68
+ saverest: string
69
+ saversion: number | null
70
+ surveySourceDate: string | null
71
+ surveySourceTitle: string | null
72
+ sourceScale: number | null
73
+ mappingScale: number | null
74
+ /**
75
+ * The area the authority publishes for this survey area, in acres — the independent witness the ring-area check
76
+ * compares against.
77
+ */
78
+ areaAcres: number | null
79
+ mapUnits: SoilMapUnitTable[]
80
+ components: SoilComponentTable[]
81
+ domains: DomainMember[]
82
+ }
83
+
84
+ /**
85
+ * Read one survey area's tabular export.
86
+ *
87
+ * @throws {Error} When the metadata's use constraints no longer carry the public-information sentence, when a `Choice`
88
+ * column holds a value outside the authority's own declared domain, or when the export declares no legend row.
89
+ */
90
+ export async function readSurveyAreaAttributes(
91
+ tabularDirectory: PathBuilderLike,
92
+ areaSymbol: string
93
+ ): Promise<SurveyAreaAttributes> {
94
+ const dictionary = await readTabularDictionary(tabularDirectory)
95
+ const domains = await readDeclaredDomains(tabularDirectory)
96
+
97
+ const catalog = await readTable(tabularDirectory, dictionary, "sacatalog", [
98
+ "areasymbol",
99
+ "areaname",
100
+ "saverest",
101
+ "saversion",
102
+ "fgdcmetadata",
103
+ ])
104
+
105
+ if (catalog.rows.length !== 1) {
106
+ throw new Error(
107
+ `soil survey area: ${areaSymbol}'s sacatlog.txt holds ${catalog.rows.length} records, expected 1 — a survey-area archive describes exactly one survey area`
108
+ )
109
+ }
110
+
111
+ const catalogRow = catalog.rows[0]!
112
+ const metadata = readFGDCMetadata(catalogRow.fgdcmetadata!, areaSymbol)
113
+
114
+ const legend = await readTable(tabularDirectory, dictionary, "legend", [
115
+ "areasymbol",
116
+ "areaname",
117
+ "areaacres",
118
+ "projectscale",
119
+ ])
120
+
121
+ if (!legend.rows.length) {
122
+ throw new Error(`soil survey area: ${areaSymbol}'s legend.txt holds no record, so the published area is unknown`)
123
+ }
124
+
125
+ const legendRow = legend.rows.find((row) => row.areasymbol === areaSymbol) ?? legend.rows[0]!
126
+
127
+ const mapUnitRows = await readTable(tabularDirectory, dictionary, "mapunit", [
128
+ "mukey",
129
+ "musym",
130
+ "muname",
131
+ "mukind",
132
+ "mustatus",
133
+ "farmlndcl",
134
+ ])
135
+
136
+ const aggregate = await readTable(tabularDirectory, dictionary, "muaggatt", ["mukey", "niccdcd", "niccdcdpct"])
137
+ const aggregateByMukey = new Map(aggregate.rows.map((row) => [row.mukey!, row]))
138
+
139
+ const componentRows = await readTable(tabularDirectory, dictionary, "component", [
140
+ "cokey",
141
+ "mukey",
142
+ "comppct_r",
143
+ "compname",
144
+ "compkind",
145
+ "nirrcapcl",
146
+ "nirrcapscl",
147
+ "irrcapcl",
148
+ "irrcapscl",
149
+ ])
150
+
151
+ const nccpiByCokey = await readNCCPI(tabularDirectory, dictionary)
152
+
153
+ const classCodes = domainCodes(domains, "capability_class")
154
+ const subclassCodes = domainCodes(domains, "capability_subclass")
155
+ const componentKinds = domainCodes(domains, "component_kind")
156
+ const farmlandCodes = domainCodes(domains, "farmland_classification")
157
+ const mapUnitKinds = domainCodes(domains, "mapunit_kind")
158
+
159
+ const componentsByMukey = new Map<string, number>()
160
+
161
+ const components: SoilComponentTable[] = componentRows.rows.map((row) => {
162
+ assertDeclared(classCodes, row.nirrcapcl, "capability_class", `component ${row.cokey}.nirrcapcl`)
163
+ assertDeclared(classCodes, row.irrcapcl, "capability_class", `component ${row.cokey}.irrcapcl`)
164
+ assertDeclared(subclassCodes, row.nirrcapscl, "capability_subclass", `component ${row.cokey}.nirrcapscl`)
165
+ assertDeclared(subclassCodes, row.irrcapscl, "capability_subclass", `component ${row.cokey}.irrcapscl`)
166
+ assertDeclared(componentKinds, row.compkind, "component_kind", `component ${row.cokey}.compkind`)
167
+
168
+ componentsByMukey.set(row.mukey!, (componentsByMukey.get(row.mukey!) ?? 0) + 1)
169
+
170
+ return {
171
+ cokey: row.cokey!,
172
+ mukey: row.mukey!,
173
+ // A blank `comppct_r` is a component with no declared weight. Zero is the truthful reading — it contributes
174
+ // nothing to a weighted share — and it is recorded rather than dropped, so the component still appears.
175
+ comppct_r: row.comppct_r ? Number(row.comppct_r) : 0,
176
+ compname: nullable(row.compname),
177
+ compkind: nullable(row.compkind),
178
+ nirrcapcl: nullable(row.nirrcapcl),
179
+ nirrcapscl: nullable(row.nirrcapscl),
180
+ irrcapcl: nullable(row.irrcapcl),
181
+ irrcapscl: nullable(row.irrcapscl),
182
+ nccpi_v3: nccpiByCokey.get(row.cokey!) ?? null,
183
+ }
184
+ })
185
+
186
+ const mapUnits: SoilMapUnitTable[] = mapUnitRows.rows.map((row) => {
187
+ assertDeclared(farmlandCodes, row.farmlndcl, "farmland_classification", `map unit ${row.mukey}.farmlndcl`)
188
+ assertDeclared(mapUnitKinds, row.mukind, "mapunit_kind", `map unit ${row.mukey}.mukind`)
189
+
190
+ const aggregated = aggregateByMukey.get(row.mukey!)
191
+
192
+ assertDeclared(classCodes, aggregated?.niccdcd, "capability_class", `map unit ${row.mukey}.niccdcd`)
193
+
194
+ return {
195
+ mukey: row.mukey!,
196
+ areasymbol: areaSymbol,
197
+ musym: row.musym!,
198
+ muname: row.muname!,
199
+ mukind: nullable(row.mukind),
200
+ mustatus: nullable(row.mustatus),
201
+ farmlndcl: nullable(row.farmlndcl),
202
+ farmland_scope: farmlandScope(row.farmlndcl),
203
+ niccdcd: nullable(aggregated?.niccdcd),
204
+ niccdcdpct: aggregated?.niccdcdpct ? Number(aggregated.niccdcdpct) : null,
205
+ no_mapping: isNoMapping(row.musym!, row.muname!, componentsByMukey.get(row.mukey!) ?? 0) ? 1 : 0,
206
+ }
207
+ })
208
+
209
+ return {
210
+ areasymbol: areaSymbol,
211
+ areaname: legendRow.areaname || catalogRow.areaname!,
212
+ saverest: metadata.publicationDate,
213
+ saversion: catalogRow.saversion ? Number(catalogRow.saversion) : null,
214
+ surveySourceDate: metadata.oldestSourceDate,
215
+ surveySourceTitle: metadata.oldestSourceTitle,
216
+ sourceScale: metadata.oldestSourceScale,
217
+ mappingScale: legendRow.projectscale ? Number(legendRow.projectscale) : null,
218
+ areaAcres: legendRow.areaacres ? Number(legendRow.areaacres) : null,
219
+ mapUnits,
220
+ components,
221
+ domains: domains.filter((member) => (STORED_DOMAINS as ReadonlyArray<string>).includes(member.domain)),
222
+ }
223
+ }
224
+
225
+ /**
226
+ * A polygon the authority drew with no soil mapping behind it.
227
+ *
228
+ * Three signals rather than one, because the source encodes the same fact three ways and each on its own has a gap: the
229
+ * symbol (`NOTCOM`, `NOTPUB`), the name (`Area not surveyed, access denied`), and the structural case of a map unit
230
+ * carrying NO components at all. A map unit with no components has nothing to rate whatever it is called, and reading
231
+ * it as "rated nothing" rather than "no mapping" would put it in `unrated_share` — a claim that the survey looked and
232
+ * declined, when it did not look.
233
+ */
234
+ function isNoMapping(musym: string, muname: string, componentCount: number): boolean {
235
+ if (SSURGO_NO_MAPPING_SYMBOLS.has(musym.toUpperCase())) return true
236
+
237
+ if (SSURGO_NO_MAPPING_NAMES.has(muname.trim().toLowerCase())) return true
238
+
239
+ return componentCount === 0
240
+ }
241
+
242
+ /**
243
+ * Refuse a value outside the authority's own declared domain.
244
+ *
245
+ * An unknown code is a source-schema change, which is the event a reader most needs to hear about; coercing it to a
246
+ * nearest neighbour or to NULL converts "the source changed" into "there is nothing here". A BLANK is not a violation:
247
+ * NULL is a real state in every one of these columns and means something specific — for `nirrcapcl` it means the survey
248
+ * did not rate the component, which is not class 8.
249
+ */
250
+ function assertDeclared(declared: ReadonlySet<string>, value: string | undefined, domain: string, where: string): void {
251
+ if (!value) return
252
+
253
+ if (declared.has(value)) return
254
+
255
+ throw new Error(
256
+ `soil survey area: ${where} holds ${JSON.stringify(value)}, which is not in the authority's declared ${domain} domain (${declared.size} members, read from the archive's own msdomdet.txt) — an unknown code is a source-schema change, and coercing it would turn "the source changed" into "there is nothing here"`
257
+ )
258
+ }
259
+
260
+ function nullable(value: string | undefined): string | null {
261
+ return value || null
262
+ }
263
+
264
+ /**
265
+ * The NCCPI v3.0 overall index per component.
266
+ *
267
+ * `cointerp` is the largest table in the export — 157,063 rows for `IA153`, read in 0.36 s — and the overall rule is
268
+ * one row per component at {@link COINTERP_OVERALL_RULE_DEPTH}: 369 of 369 components on `IA153`, of which 327 carry a
269
+ * value. Sub-rules at greater depths are the submodels (corn, soybeans, small grains, cotton), which this layer does
270
+ * not carry.
271
+ */
272
+ async function readNCCPI(
273
+ tabularDirectory: PathBuilderLike,
274
+ dictionary: Awaited<ReturnType<typeof readTabularDictionary>>
275
+ ): Promise<Map<string, number>> {
276
+ const rows = await readTable(tabularDirectory, dictionary, "cointerp", [
277
+ "cokey",
278
+ "mrulename",
279
+ "ruledepth",
280
+ "interphr",
281
+ ])
282
+
283
+ const byCokey = new Map<string, number>()
284
+
285
+ for (const row of rows.rows) {
286
+ if (row.mrulename !== NCCPI_V3_RULE_NAME) continue
287
+
288
+ if (row.ruledepth !== COINTERP_OVERALL_RULE_DEPTH) continue
289
+
290
+ if (!row.interphr) continue
291
+
292
+ byCokey.set(row.cokey!, Number(row.interphr))
293
+ }
294
+
295
+ return byCokey
296
+ }
297
+
298
+ /**
299
+ * What the shipped FGDC metadata says about this survey area's dates and its licence.
300
+ */
301
+ export interface FGDCMetadata {
302
+ /**
303
+ * The citation's own `pubdate`, as an ISO date — the refresh.
304
+ */
305
+ publicationDate: string
306
+ /**
307
+ * The OLDEST source citation date in the lineage, as an ISO date or a bare year.
308
+ */
309
+ oldestSourceDate: string | null
310
+ oldestSourceTitle: string | null
311
+ oldestSourceScale: number | null
312
+ }
313
+
314
+ /**
315
+ * Read the metadata NRCS ships inside the archive.
316
+ *
317
+ * Targeted extraction rather than a general XML parse, and NOT for want of a parser — `@mailwoman/core` ships
318
+ * `htmlparser2`. A parser RECOVERS an unclosed element by giving it the rest of the document as its content, and the
319
+ * two values below that throw would then stamp the artifact with that content instead. {@link elementText} answers
320
+ * `undefined` for an element it cannot read, which is what makes the throw reachable. Every value this reader cannot
321
+ * find is reported as `null` EXCEPT the publication date and the licence sentence, which throw — those two decide the
322
+ * artifact's vintage and whether it may be shipped at all, and neither has a safe default.
323
+ *
324
+ * @throws {Error} When the metadata carries no publication date, or its use constraints no longer carry the
325
+ * public-information sentence.
326
+ */
327
+ export function readFGDCMetadata(xml: string, areaSymbol: string): FGDCMetadata {
328
+ const useConstraints = elementText(xml, "useconst")
329
+
330
+ if (!useConstraints?.includes(SSURGO_PUBLIC_INFORMATION_SENTENCE)) {
331
+ throw new Error(
332
+ `soil survey area: ${areaSymbol}'s FGDC use constraints do not carry ${JSON.stringify(SSURGO_PUBLIC_INFORMATION_SENTENCE)} — that sentence is the grant this layer ships on, so a survey area without it must not be built into a distributable artifact`
333
+ )
334
+ }
335
+
336
+ const publicationDate = elementText(xml, "pubdate")
337
+
338
+ if (!publicationDate) {
339
+ throw new Error(
340
+ `soil survey area: ${areaSymbol}'s FGDC metadata carries no publication date — stamping the artifact with a guessed vintage would give it a version that means nothing`
341
+ )
342
+ }
343
+
344
+ const sources = readSourceCitations(xml)
345
+ const oldest = sources.toSorted((left, right) => (left.date < right.date ? -1 : 1))[0]
346
+
347
+ return {
348
+ publicationDate: normalizeFGDCDate(publicationDate),
349
+ oldestSourceDate: oldest ? normalizeFGDCDate(oldest.date) : null,
350
+ oldestSourceTitle: oldest?.title ?? null,
351
+ oldestSourceScale: oldest?.scale ?? null,
352
+ }
353
+ }
354
+
355
+ /**
356
+ * The lineage's source citations: what the polygons rest on, and when each was made.
357
+ */
358
+ function readSourceCitations(xml: string): Array<{ date: string; title: string; scale: number | null }> {
359
+ const citations: Array<{ date: string; title: string; scale: number | null }> = []
360
+
361
+ for (const body of elementBlocks(xml, "srcinfo")) {
362
+ // `caldate` for a single date, `begdate` for a range. A range's END is when the source stopped being collected;
363
+ // its BEGINNING is when the ground was first looked at, which is the fact this layer is carrying.
364
+ const date = elementText(body, "caldate") ?? elementText(body, "begdate")
365
+
366
+ if (!date) continue
367
+
368
+ const scale = elementText(body, "srcscale")
369
+
370
+ citations.push({
371
+ date: date.trim(),
372
+ title: (elementText(body, "title") ?? "").replaceAll(/\s+/gu, " ").trim(),
373
+ scale: scale ? Number(scale) : null,
374
+ })
375
+ }
376
+
377
+ return citations
378
+ }
379
+
380
+ /**
381
+ * The text of the first `<name>` element, whitespace left alone.
382
+ *
383
+ * INDEX SCANS RATHER THAN A REGEX, AND THAT IS A CORRECTNESS CHOICE RATHER THAN A SPEED ONE. The obvious form — ``new
384
+ * RegExp(`<${name}>([\\s\\S]*?)</${name}>`)`` — backtracks polynomially on a document whose opening tag has no closing
385
+ * partner: the lazy run re-scans to the end from every candidate start. The input here is a 43,251-character document
386
+ * that arrived over the network inside a downloaded archive, so "a malformed one cannot happen" is not a claim this
387
+ * reader gets to make. Two `indexOf` calls answer the same question in one pass.
388
+ */
389
+ function elementText(xml: string, name: string): string | undefined {
390
+ const open = `<${name}>`
391
+ const start = xml.indexOf(open)
392
+
393
+ if (start === -1) return undefined
394
+
395
+ const from = start + open.length
396
+ const end = xml.indexOf(`</${name}>`, from)
397
+
398
+ // An element with no closing tag is unreadable, not empty — the same answer an absent element gets, because both
399
+ // mean the value could not be read rather than that it is blank.
400
+ return end === -1 ? undefined : xml.slice(from, end)
401
+ }
402
+
403
+ /**
404
+ * Every `<name>` element's inner text, in document order. The repeating counterpart of {@link elementText}, and linear
405
+ * for the same reason.
406
+ */
407
+ function elementBlocks(xml: string, name: string): string[] {
408
+ const open = `<${name}>`
409
+ const close = `</${name}>`
410
+ const blocks: string[] = []
411
+
412
+ let cursor = 0
413
+
414
+ for (;;) {
415
+ const start = xml.indexOf(open, cursor)
416
+
417
+ if (start === -1) return blocks
418
+
419
+ const from = start + open.length
420
+ const end = xml.indexOf(close, from)
421
+
422
+ if (end === -1) return blocks
423
+
424
+ blocks.push(xml.slice(from, end))
425
+ cursor = end + close.length
426
+ }
427
+ }
428
+
429
+ /**
430
+ * FGDC dates arrive as `YYYY` or `YYYYMMDD`. Both are kept as they are meant — a bare year is a bare year, and padding
431
+ * it to January 1 would invent a precision the citation does not claim.
432
+ */
433
+ function normalizeFGDCDate(value: string): string {
434
+ const trimmed = value.trim()
435
+ const matched = /^(\d{4})(\d{2})(\d{2})$/u.exec(trimmed)
436
+
437
+ return matched ? `${matched[1]}-${matched[2]}-${matched[3]}` : trimmed
438
+ }
439
+
440
+ /**
441
+ * Read the survey area's own outline shapefile as a GeoJSON geometry.
442
+ *
443
+ * @throws {Error} When the shapefile holds anything other than exactly one feature. Taking the first of several would
444
+ * silently choose which ground the coverage claim is about.
445
+ */
446
+ export async function readSurveyAreaOutline(shapefilePath: string): Promise<ParsedGeometry> {
447
+ const { stdout } = await runFile("ogr2ogr", ["-f", "GeoJSON", "/vsistdout/", "-t_srs", "EPSG:4326", shapefilePath], {
448
+ maxBuffer: 256 * 1024 * 1024,
449
+ })
450
+
451
+ const collection = parseJSONStrict<{ features?: Array<{ geometry?: ParsedGeometry }> }>(stdout)
452
+ const features = collection.features ?? []
453
+
454
+ if (features.length !== 1) {
455
+ throw new Error(
456
+ `soil survey area: ${shapefilePath} holds ${features.length} features, expected exactly 1 — a survey area publishes one outline, and taking the first of several would silently choose which ground the coverage claim is about`
457
+ )
458
+ }
459
+
460
+ const geometry = features[0]!.geometry
461
+
462
+ if (!geometry) {
463
+ throw new Error(`soil survey area: ${shapefilePath}'s single feature carries no geometry`)
464
+ }
465
+
466
+ return geometry
467
+ }