@mailwoman/soil 9.4.0 → 10.1.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 (115) hide show
  1. package/README.md +118 -116
  2. package/lib/index.ts +66 -98
  3. package/lib/paths.ts +24 -0
  4. package/lib/schema.ts +188 -120
  5. package/lib/vocabulary.ts +40 -107
  6. package/out/index.d.ts +52 -70
  7. package/out/index.d.ts.map +1 -1
  8. package/out/index.js +24 -72
  9. package/out/index.js.map +1 -1
  10. package/out/paths.d.ts +19 -0
  11. package/out/paths.d.ts.map +1 -0
  12. package/out/paths.js +21 -0
  13. package/out/paths.js.map +1 -0
  14. package/out/schema.d.ts +187 -119
  15. package/out/schema.d.ts.map +1 -1
  16. package/out/schema.js +31 -31
  17. package/out/schema.js.map +1 -1
  18. package/out/sdk/acquire.d.ts +15 -24
  19. package/out/sdk/acquire.d.ts.map +1 -1
  20. package/out/sdk/acquire.js +5 -20
  21. package/out/sdk/acquire.js.map +1 -1
  22. package/out/sdk/build-soil.d.ts +67 -70
  23. package/out/sdk/build-soil.d.ts.map +1 -1
  24. package/out/sdk/build-soil.js +65 -95
  25. package/out/sdk/build-soil.js.map +1 -1
  26. package/out/sdk/cell-tiers.d.ts +7 -19
  27. package/out/sdk/cell-tiers.d.ts.map +1 -1
  28. package/out/sdk/cell-tiers.js +19 -38
  29. package/out/sdk/cell-tiers.js.map +1 -1
  30. package/out/sdk/cells.d.ts +15 -41
  31. package/out/sdk/cells.d.ts.map +1 -1
  32. package/out/sdk/cells.js +11 -38
  33. package/out/sdk/cells.js.map +1 -1
  34. package/out/sdk/client.d.ts +23 -48
  35. package/out/sdk/client.d.ts.map +1 -1
  36. package/out/sdk/client.js +19 -63
  37. package/out/sdk/client.js.map +1 -1
  38. package/out/sdk/download.d.ts +24 -47
  39. package/out/sdk/download.d.ts.map +1 -1
  40. package/out/sdk/download.js +14 -54
  41. package/out/sdk/download.js.map +1 -1
  42. package/out/sdk/ingest/chunk.d.ts +77 -0
  43. package/out/sdk/ingest/chunk.d.ts.map +1 -0
  44. package/out/sdk/{ingest-chunk.js → ingest/chunk.js} +19 -17
  45. package/out/sdk/ingest/chunk.js.map +1 -0
  46. package/out/sdk/ingest/worker.d.ts +9 -0
  47. package/out/sdk/ingest/worker.d.ts.map +1 -0
  48. package/out/{scripts/ingest-chunk.js → sdk/ingest/worker.js} +11 -10
  49. package/out/sdk/ingest/worker.js.map +1 -0
  50. package/out/sdk/ingest.d.ts +40 -52
  51. package/out/sdk/ingest.d.ts.map +1 -1
  52. package/out/sdk/ingest.js +18 -61
  53. package/out/sdk/ingest.js.map +1 -1
  54. package/out/sdk/measure-resolutions.d.ts +5 -16
  55. package/out/sdk/measure-resolutions.d.ts.map +1 -1
  56. package/out/sdk/measure-resolutions.js +3 -15
  57. package/out/sdk/measure-resolutions.js.map +1 -1
  58. package/out/sdk/reduce.d.ts +33 -59
  59. package/out/sdk/reduce.d.ts.map +1 -1
  60. package/out/sdk/reduce.js +47 -78
  61. package/out/sdk/reduce.js.map +1 -1
  62. package/out/sdk/survey-area.d.ts +16 -48
  63. package/out/sdk/survey-area.d.ts.map +1 -1
  64. package/out/sdk/survey-area.js +33 -77
  65. package/out/sdk/survey-area.js.map +1 -1
  66. package/out/sdk/tabular.d.ts +32 -31
  67. package/out/sdk/tabular.d.ts.map +1 -1
  68. package/out/sdk/tabular.js +58 -56
  69. package/out/sdk/tabular.js.map +1 -1
  70. package/out/sdk/test-kit.d.ts +57 -0
  71. package/out/sdk/test-kit.d.ts.map +1 -0
  72. package/out/{test-kit.js → sdk/test-kit.js} +18 -39
  73. package/out/sdk/test-kit.js.map +1 -0
  74. package/out/sdk/verify.d.ts +15 -51
  75. package/out/sdk/verify.d.ts.map +1 -1
  76. package/out/sdk/verify.js +19 -77
  77. package/out/sdk/verify.js.map +1 -1
  78. package/out/vocabulary.d.ts +37 -103
  79. package/out/vocabulary.d.ts.map +1 -1
  80. package/out/vocabulary.js +34 -107
  81. package/out/vocabulary.js.map +1 -1
  82. package/package.json +36 -190
  83. package/{lib/sdk → sdk}/acquire.ts +17 -27
  84. package/{lib/sdk → sdk}/build-soil.ts +114 -128
  85. package/{lib/sdk → sdk}/cell-tiers.ts +20 -39
  86. package/{lib/sdk → sdk}/cells.ts +17 -43
  87. package/sdk/client.ts +147 -0
  88. package/sdk/download.ts +131 -0
  89. package/{lib/sdk/ingest-chunk.ts → sdk/ingest/chunk.ts} +34 -26
  90. package/{lib/scripts/ingest-chunk.ts → sdk/ingest/worker.ts} +10 -9
  91. package/sdk/ingest.ts +253 -0
  92. package/{lib/sdk → sdk}/measure-resolutions.ts +5 -16
  93. package/sdk/reduce.ts +344 -0
  94. package/{lib/sdk → sdk}/survey-area.ts +39 -83
  95. package/{lib/sdk → sdk}/tabular.ts +62 -59
  96. package/{lib → sdk}/test-kit.ts +18 -40
  97. package/{lib/sdk → sdk}/verify.ts +29 -85
  98. package/lib/sdk/client.ts +0 -184
  99. package/lib/sdk/download.ts +0 -161
  100. package/lib/sdk/index.ts +0 -20
  101. package/lib/sdk/ingest.ts +0 -278
  102. package/lib/sdk/reduce.ts +0 -375
  103. package/out/scripts/ingest-chunk.d.ts +0 -11
  104. package/out/scripts/ingest-chunk.d.ts.map +0 -1
  105. package/out/scripts/ingest-chunk.js.map +0 -1
  106. package/out/sdk/index.d.ts +0 -20
  107. package/out/sdk/index.d.ts.map +0 -1
  108. package/out/sdk/index.js +0 -20
  109. package/out/sdk/index.js.map +0 -1
  110. package/out/sdk/ingest-chunk.d.ts +0 -73
  111. package/out/sdk/ingest-chunk.d.ts.map +0 -1
  112. package/out/sdk/ingest-chunk.js.map +0 -1
  113. package/out/test-kit.d.ts +0 -79
  114. package/out/test-kit.d.ts.map +0 -1
  115. package/out/test-kit.js.map +0 -1
@@ -2,35 +2,6 @@
2
2
  * @copyright Sister Software
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
- *
6
- * The two-path agreement check, and its negative half.
7
- *
8
- * POSITIVE HALF. A sample of points is answered from the sealed artifact and then re-asked of Soil Data
9
- * Access — the same authority, a different distribution channel, and geometry this package has never
10
- * touched. What is compared is the MAP UNIT the two channels put at the point, which is the thing a
11
- * conversion can get wrong; comparing the derived capability class instead would let a wrong delineation
12
- * agree by accident whenever two neighbouring map units happen to share a class.
13
- *
14
- * NEGATIVE HALF, AND IT MATTERS AS MUCH. A sample of points in states with no rows must come back
15
- * `unknown` — no coverage row at all — and never a low-capability reading. The positive half alone would
16
- * pass on an artifact that answered class 8 for the whole planet.
17
- *
18
- * A DISAGREEMENT NEAR A DELINEATION EDGE IS NOT A DEFECT, AND THE DISTANCE IS MEASURED TO THE EDGE RATHER
19
- * THAN TO THE NEAREST VERTEX. A point a centimetre from a long edge can be metres from every vertex of it
20
- * — the flood layer's one near-miss read 1.58 m to vertices and 0.009 m to edges, a 9 mm difference
21
- * overstated 175-fold. Measuring vertices makes the boundary tolerance far stricter than it reads, which
22
- * is how a rendering difference gets reported as a conversion defect.
23
- *
24
- * THE ARTIFACT'S OWN ANSWER IS THE CELL SUMMARY, AND THE POINT'S MAP UNIT IS UNDER IT. So the comparison
25
- * reaches the GEOMETRY — the truth table — rather than the reduction: the reduction is a per-cell
26
- * distribution and has no single map unit to compare. That makes this a check on the CONVERSION, which is
27
- * what it is for; the reduction is checked by the fixtures and by the share-sum invariant.
28
- *
29
- * IT REACHES IT THROUGH THE CELL INDEX, NOT THROUGH A BOUNDING-BOX SCAN. A `WHERE min_lat <= ? AND …` over
30
- * the geometry table reads like a prefilter and is a full table scan: none of those columns is indexed and
31
- * every row carries a ring blob, so at the pilot's 2.7 million delineations it reads gigabytes per point.
32
- * Naming the point's cell is a primary-key range scan over a `WITHOUT ROWID` table, which is the whole
33
- * reason the index exists.
34
5
  */
35
6
 
36
7
  import {
@@ -43,13 +14,15 @@ import {
43
14
  } from "@mailwoman/spatial"
44
15
  import { DatabaseClient } from "@mailwoman/sqlite/client"
45
16
  import { cellToParent, latLngToCell } from "h3-js"
17
+ import type { PathBuilderLike } from "path-ts"
46
18
 
47
19
  import { SoilCapabilityLookup, SoilReadingKind } from "#index"
48
20
  import type { SoilDatabase } from "#schema"
49
21
  import type { SoilDataAccessClient } from "#sdk/client"
50
22
 
51
23
  /**
52
- * One point, both verdicts, and whether they agree.
24
+ * One comparison row for a point.
25
+ * It records both verdicts and whether they agree.
53
26
  */
54
27
  export interface SoilAgreementRow {
55
28
  label: string
@@ -65,10 +38,10 @@ export interface SoilAgreementRow {
65
38
  serviceMukey: string | null
66
39
  outcome: "agree" | "disagree" | "boundary_tolerance"
67
40
  /**
68
- * Metres from the point to the nearest EDGE of the delineation the artifact matched.
41
+ * Meters from the point to the nearest edge of the delineation the artifact matched.
69
42
  *
70
- * Carried on every row rather than only the tolerated ones, because it is what separates a real defect from two
71
- * channels rendering the same edge differently — and a receipt that omits it forces a re-run.
43
+ * Included on every row because it separates a real defect from two channels
44
+ * rendering the same edge differently.
72
45
  */
73
46
  nearestEdgeMetres?: number
74
47
  }
@@ -82,7 +55,7 @@ export interface SoilOutsideRow {
82
55
  longitude: number
83
56
  kind: SoilReadingKind
84
57
  /**
85
- * True when the artifact answered `unknown` — the only acceptable reading outside the built survey areas.
58
+ * True when the artifact answered `unknown`, the only acceptable reading outside the built survey areas.
86
59
  */
87
60
  passed: boolean
88
61
  }
@@ -97,13 +70,10 @@ export interface VerifySoilResult {
97
70
  }
98
71
 
99
72
  /**
100
- * Points outside the pilot region, named. Each is a place, not a bare pair of numbers: a coordinate a reader cannot
101
- * name is a coordinate nobody can check.
73
+ * Points outside the pilot region.
102
74
  *
103
- * Every neighbouring state is included, because the failure this half catches is a footprint that leaked past the
104
- * survey-area outlines — and a footprint accidentally clipped to "the Midwest" would pass a one-state check. Two of
105
- * these sit close to the Iowa border on purpose: the outline test is conservative, so a near-border point must read
106
- * unknown rather than borrow Iowa's coverage.
75
+ * Every neighboring state is included because a footprint clipped to "the
76
+ * Midwest" would pass a one-state check.
107
77
  */
108
78
  export const OUTSIDE_PILOT_POINTS: ReadonlyArray<{ label: string; latitude: number; longitude: number }> = [
109
79
  { label: "Lincoln, Nebraska", latitude: 40.8136, longitude: -96.7026 },
@@ -117,21 +87,16 @@ export const OUTSIDE_PILOT_POINTS: ReadonlyArray<{ label: string; latitude: numb
117
87
  ]
118
88
 
119
89
  /**
120
- * How close to a delineation edge a disagreement is attributed to the channels' differing rendering rather than to the
121
- * conversion.
122
- *
123
- * One metre. The published shapefile carries nine decimals through this package's ingest and Soil Data Access renders
124
- * its own geometry independently; NRCS's own positional-accuracy statement says the difference between a boundary's
125
- * field location and its digitized location "is unknown", so this tolerance is about the two RENDERINGS agreeing rather
126
- * than about ground truth. One metre is far below the median delineation, which is 24,863 m² — about 158 m across.
90
+ * One meter, far below the median delineation, so a disagreement within it is the two
91
+ * channels rendering the same edge differently rather than a conversion defect.
127
92
  */
128
93
  const BOUNDARY_TOLERANCE_METRES = 1
129
94
 
130
95
  export interface VerifySoilOptions {
131
- databasePath: string
96
+ databasePath: PathBuilderLike
132
97
  client: Pick<SoilDataAccessClient, "mukeyAtPoint">
133
98
  /**
134
- * Points to re-ask the service about. A caller samples them from the artifact — see {@link sampleAgreementPoints}.
99
+ * Points to re-ask the service about, sampled from the artifact — see {@link sampleAgreementPoints}.
135
100
  */
136
101
  points: ReadonlyArray<{ label: string; latitude: number; longitude: number }>
137
102
  outsidePoints?: ReadonlyArray<{ label: string; latitude: number; longitude: number }>
@@ -145,7 +110,7 @@ export async function verifySoilDatabase(options: VerifySoilOptions): Promise<Ve
145
110
  const database = new DatabaseClient<SoilDatabase>(options.databasePath, { readOnly: true })
146
111
  const lookup = new SoilCapabilityLookup({ databasePath: options.databasePath })
147
112
 
148
- // Read once: the stored index is mixed-resolution because the whole tier is compacted, and a probe that assumed one
113
+ // Read once: the stored index is mixed-resolution, so a probe that assumed one
149
114
  // resolution would read every row at the others as an absence.
150
115
  const resolutions = (
151
116
  database.prepare("SELECT DISTINCT resolution FROM soil_map_unit_cell ORDER BY resolution").all() as Array<{
@@ -198,16 +163,9 @@ export async function verifySoilDatabase(options: VerifySoilOptions): Promise<Ve
198
163
  }
199
164
 
200
165
  /**
201
- * The candidate delineations reaching a point, found THROUGH THE CELL INDEX rather than by scanning the geometry table.
202
- *
203
- * A bounding-box `WHERE` over `soil_map_unit_area` reads as a prefilter and is a FULL TABLE SCAN: none of those columns
204
- * is indexed, the rows carry the ring blobs, and at the pilot's scale that is 2.7 million rows and several gigabytes
205
- * read per point. The cell index exists to make exactly this question cheap — `soil_map_unit_cell` is `WITHOUT ROWID`
206
- * keyed `(h3_cell, area_id)`, so naming the point's cell is a primary-key range scan.
207
- *
208
- * EVERY STORED RESOLUTION IS PROBED, not just the index one. The whole tier is compacted parent-ward, so a delineation
209
- * that fills a run of cells is stored at a coarser resolution and a probe at the index resolution alone would read it
210
- * as an absence — the same ancestor walk the reader does, and the same false negative it avoids.
166
+ * The candidate delineations reaching a point, found through the cell index
167
+ * because a bounding-box scan over `soil_map_unit_area` is a full table scan. every
168
+ * stored resolution is probed since the tier is compacted parent-ward.
211
169
  */
212
170
  function candidateDelineations(
213
171
  database: DatabaseClient<SoilDatabase>,
@@ -233,12 +191,8 @@ function candidateDelineations(
233
191
  mukey: string
234
192
  rings: Uint8Array
235
193
  }>) {
236
- // DEDUPE ON THE DELINEATION, NEVER ON ITS MAP UNIT. A delineation reached through two resolutions is one
237
- // delineation and must be tested once; two DIFFERENT delineations of the same map unit are two shapes covering
238
- // different ground and must both be tested. Keying on the map unit drops the second, and it drops it silently —
239
- // the point test simply finds nothing and the row reads as a disagreement with the authority. Measured at Iowa
240
- // scale: one point in 60, where the artifact's own geometry does contain the point and the index-driven read
241
- // could not reach the delineation that holds it.
194
+ // Dedupe on the delineation rather than its map unit, since two different delineations
195
+ // of one map unit cover different ground and both must be tested.
242
196
  if (seen.has(row.area_id)) continue
243
197
 
244
198
  seen.add(row.area_id)
@@ -250,11 +204,9 @@ function candidateDelineations(
250
204
  }
251
205
 
252
206
  /**
253
- * Which map unit the ARTIFACT's own geometry puts at a point, and how far the point is from that delineation's nearest
254
- * edge.
207
+ * The map unit that the artifact's geometry places at a point.
255
208
  *
256
- * The cell index narrows; the ray cast decides. The edge distance is measured against every candidate, so a near-miss
257
- * is reported with a distance rather than with nothing.
209
+ * Also records the distance to that delineation's nearest edge.
258
210
  */
259
211
  function localDelineationAt(
260
212
  database: DatabaseClient<SoilDatabase>,
@@ -284,10 +236,9 @@ function localDelineationAt(
284
236
  }
285
237
 
286
238
  /**
287
- * Metres from a point to the nearest edge of an encoded ring set.
239
+ * Meters from a point to the nearest edge of an encoded ring set.
288
240
  *
289
- * Decoding here rather than walking the blob directly: this runs a few hundred times in a verification, not per
290
- * geocode, and the decoded form is what makes the segment walk readable.
241
+ * This function decodes here because it runs once per verification rather than once per geocode.
291
242
  */
292
243
  function nearestEdgeDistance(blob: Uint8Array, lon: number, lat: number): number {
293
244
  const { polygons } = decodeRings(blob)
@@ -315,17 +266,11 @@ function nearestEdgeDistance(blob: Uint8Array, lon: number, lat: number): number
315
266
  }
316
267
 
317
268
  /**
318
- * Draw a reproducible sample of points from the artifact.
319
- *
320
- * The draw is a deterministic stride over the primary key, not a random one, so a re-run compares the same points and a
321
- * disagreement can be looked at rather than re-rolled.
322
- *
323
- * ONE ROW IS READ PER SAMPLE POINT AND NO MORE. A `WHERE rowid % stride = 0` scan looks like the same thing and is not:
324
- * it walks the table itself, which means reading every ring blob to keep a few dozen. `ORDER BY area_id LIMIT 1 OFFSET
325
- * n` walks the primary-key index to the offset and fetches exactly the row it lands on.
269
+ * Draw a reproducible sample of points from the artifact, a deterministic stride
270
+ * over the primary key so a re-run compares the same points.
326
271
  */
327
272
  export function sampleAgreementPoints(
328
- databasePath: string,
273
+ databasePath: PathBuilderLike,
329
274
  options: { count?: number } = {}
330
275
  ): Array<{ label: string; latitude: number; longitude: number }> {
331
276
  const count = options.count ?? 60
@@ -334,9 +279,8 @@ export function sampleAgreementPoints(
334
279
  const total = (database.prepare("SELECT count(*) AS n FROM soil_map_unit_area").get() as { n: number }).n
335
280
  const stride = Math.max(1, Math.floor(total / Math.max(1, count)))
336
281
 
337
- // One OFFSET probe per sample point rather than one materialized key list. The list looks cheap because it reads
338
- // only the primary key, and at the pilot's 2.7 million delineations it is still 2.7 million strings held to keep
339
- // sixty of them.
282
+ // Use one offset probe per sample point instead of materializing a key list.
283
+ // That list would retain every primary key to select sixty points.
340
284
  const selectByOffset = database.prepare(
341
285
  "SELECT area_id, mukey, min_lat, min_lon, max_lat, max_lon, rings FROM soil_map_unit_area ORDER BY area_id LIMIT 1 OFFSET ?"
342
286
  )
package/lib/sdk/client.ts DELETED
@@ -1,184 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- *
6
- * Soil Data Access — NRCS's live SQL service, and the two things this layer asks it: which survey areas
7
- * exist with what version date, and which map unit covers a point.
8
- *
9
- * THIS IS AN API REQUEST AND IT GOES THROUGH {@linkcode APIClient}. Small bodies, repeated calls, a
10
- * third-party host with a server-side query timeout and no published rate limit — the pacing, bounded
11
- * retry, response caching and `ResourceError` mapping are exactly what it needs. The survey-area
12
- * ARCHIVES are not: they are 13 to 41 MB file transfers, they stream to disk on raw `fetch`, and
13
- * `download.ts` says so in place.
14
- *
15
- * FAILURES COME BACK AS XML, INCLUDING ON A TIMEOUT, AND A JSON-ONLY PARSER MIS-READS THEM. A bad column,
16
- * a blocked query and a query that exceeded the server's own timeout all return an OGC
17
- * `ServiceExceptionReport` document. Measured messages: `Invalid query: Invalid column name
18
- * 'nosuchcolumn'.` (HTTP 400), `Invalid query - access denied.`, and `Your query timed out.` — and the
19
- * last one arrives on an HTTP 200. So every response is read as TEXT and checked for the report before
20
- * anything tries to parse it as JSON. A client that branched on the status code alone would read a
21
- * timeout as a successful empty answer, which is the exact shape of lie this program keeps writing down.
22
- *
23
- * SCHEMA INTROSPECTION IS REFUSED, SO THE COLUMN NAMES ARE THE PUBLISHED DATA DICTIONARY'S.
24
- * `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS` answers `Invalid query - access denied.` The
25
- * columns this file names were each verified by querying them successfully.
26
- *
27
- * FRESHNESS IS `sacatalog.saverest` AND NEVER A LENGTH PROBE. The download host answers `HEAD` with HTTP
28
- * 405 and IGNORES `Range` — a request with `Range: bytes=0-0` returned HTTP 200 and transferred the whole
29
- * 27,598,377 bytes — so "just check the size" starts a real download. The tabular service answers the
30
- * freshness question directly instead, and the version date it returns is what the archive's filename
31
- * embeds.
32
- */
33
-
34
- import { APIClient, type APIClientConfig, type ClockLike, assertNoOGCServiceException } from "@mailwoman/core/api"
35
- import { buildDiskStorage } from "@mailwoman/core/api/disk-storage"
36
- import { dataRootPath } from "@mailwoman/core/data-root"
37
- import { parseJSONStrict } from "@mailwoman/core/json"
38
-
39
- import { saverestToISODate } from "#sdk/tabular"
40
-
41
- // Re-exported so a caller branching on this client's failures needs exactly one import.
42
-
43
- /**
44
- * The tabular endpoint. Anonymous: no key, no account, and no rate-limit header on any observed response.
45
- */
46
- export const SDA_POST_REST_URL = "https://sdmdataaccess.nrcs.usda.gov/Tabular/post.rest"
47
-
48
- /**
49
- * Minimum spacing between Soil Data Access requests, in milliseconds.
50
- *
51
- * NRCS publishes no rate limit for this service and returned no rate-limit header on any request, so this is courtesy
52
- * pacing rather than a published ceiling — stated as such rather than dressed up as a measured limit. It costs an
53
- * acquisition run nothing: a whole-state build makes one catalogue call, and the verification's per-point calls are
54
- * measured at 1.8 s each anyway.
55
- */
56
- export const SDA_MIN_REQUEST_INTERVAL_MS = 500
57
-
58
- /**
59
- * How long a cached Soil Data Access response stays fresh.
60
- *
61
- * Twelve hours, chosen against the product's cadence rather than a wall-clock intuition: NRCS performs ONE coordinated
62
- * Annual Soils Refresh, on October 1. Grouping `sacatalog` by year of `saverest` returns 2016: 1, 2025: 3,323, 2026: 56
63
- * — 98.3% of survey areas carry a single version date from one refresh rather than a per-area drift. A shorter TTL buys
64
- * nothing.
65
- */
66
- const SDA_CACHE_TTL_MS = 12 * 60 * 60 * 1000
67
-
68
- /**
69
- * One published survey area, as the catalogue reports it.
70
- */
71
- export interface SurveyAreaCatalogEntry {
72
- areasymbol: string
73
- areaname: string
74
- /**
75
- * The version-established date as an ISO date — what the archive's filename embeds.
76
- */
77
- saverest: string
78
- saversion: number
79
- }
80
-
81
- /**
82
- * A client for Soil Data Access.
83
- */
84
- export class SoilDataAccessClient extends APIClient<APIClientConfig> {
85
- /**
86
- * Run one query and return its rows.
87
- *
88
- * @throws {OGCServiceError} When the service answers with an exception report — including on an HTTP 200, which is
89
- * what a server-side timeout does.
90
- */
91
- public async query(sql: string): Promise<string[][]> {
92
- const { data } = await this.fetch<string>({
93
- method: "POST",
94
- url: SDA_POST_REST_URL,
95
- // TEXT, not JSON, and that is the whole trap. A JSON response type hands a failure body to a JSON parser,
96
- // which either throws something unrelated to what went wrong or — on a 200 — yields nothing at all.
97
- responseType: "text",
98
- headers: { "Content-Type": "application/json" },
99
- data: { SERVICE: "query", FORMAT: "JSON", QUERY: sql },
100
- })
101
-
102
- assertNoOGCServiceException(data, `soil data access (query: ${sql.slice(0, 200)})`)
103
-
104
- const parsed = parseJSONStrict<{ Table?: unknown }>(data)
105
-
106
- // An answer with NO rows is `{}` rather than `{"Table":[]}`, so an absent `Table` is a real empty result and not a
107
- // read failure — the exception check above has already separated the two.
108
- if (parsed.Table === undefined) return []
109
-
110
- if (!Array.isArray(parsed.Table)) {
111
- throw new TypeError(
112
- `soil data access: the service answered with a Table that is not an array (${typeof parsed.Table}) — the response format changed`
113
- )
114
- }
115
-
116
- return parsed.Table.map((row) => (row as unknown[]).map((value) => (value === null ? "" : String(value))))
117
- }
118
-
119
- /**
120
- * The published survey areas whose symbol starts with `prefix` — a state code for a state-scoped build, or a whole
121
- * symbol for the single-area rung.
122
- *
123
- * @throws {Error} When the catalogue returns nothing. An empty catalogue for a prefix a caller named is either a typo
124
- * or a service change, and building zero survey areas while reporting success is the shape this refuses.
125
- */
126
- public async readSurveyAreaCatalog(prefix: string): Promise<SurveyAreaCatalogEntry[]> {
127
- const escaped = prefix.replaceAll("'", "''")
128
-
129
- const rows = await this.query(
130
- `SELECT areasymbol, areaname, saverest, saversion FROM sacatalog WHERE areasymbol LIKE '${escaped}%' ORDER BY areasymbol`
131
- )
132
-
133
- if (!rows.length) {
134
- throw new Error(
135
- `soil data access: the catalogue holds no survey area whose symbol starts with ${JSON.stringify(prefix)} — a build over an empty set would report success having written nothing`
136
- )
137
- }
138
-
139
- return rows.map((row) => ({
140
- areasymbol: row[0]!,
141
- areaname: row[1]!,
142
- saverest: saverestToISODate(row[2]!),
143
- saversion: Number(row[3]),
144
- }))
145
- }
146
-
147
- /**
148
- * Which map unit the service's OWN geometry assigns at a point, or `undefined` where it assigns none.
149
- *
150
- * This is the second path the built artifact is checked against: same authority, different distribution channel, and
151
- * geometry this package has never touched. Measured at 1.807 s per point, so a few hundred points is minutes.
152
- */
153
- public async mukeyAtPoint(latitude: number, longitude: number): Promise<string | undefined> {
154
- const rows = await this.query(
155
- `SELECT mukey FROM SDA_Get_Mukey_from_intersection_with_WktWgs84('point(${longitude} ${latitude})')`
156
- )
157
-
158
- return rows[0]?.[0] || undefined
159
- }
160
- }
161
-
162
- export interface CreateSoilDataAccessClientOptions {
163
- clock?: ClockLike
164
- cacheDirectory?: string
165
- minRequestIntervalMs?: number
166
- }
167
-
168
- /**
169
- * Build a {@link SoilDataAccessClient} with the disk cache and pacing this package's acquisition path expects.
170
- */
171
- export function createSoilDataAccessClient(options: CreateSoilDataAccessClientOptions = {}): SoilDataAccessClient {
172
- return new SoilDataAccessClient({
173
- displayName: "SoilDataAccess",
174
- minRequestIntervalMs: options.minRequestIntervalMs ?? SDA_MIN_REQUEST_INTERVAL_MS,
175
- retry: true,
176
- ...(options.clock ? { clock: options.clock } : {}),
177
- caching: {
178
- ttl: SDA_CACHE_TTL_MS,
179
- storage: buildDiskStorage({
180
- directory: options.cacheDirectory ?? String(dataRootPath("soil", "cache", "http")),
181
- }),
182
- },
183
- })
184
- }
@@ -1,161 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- *
6
- * Acquire one survey area's published archive — 13 to 41 MB streamed to disk and unzipped.
7
- *
8
- * THE TRANSFER ITSELF LIVES IN `@mailwoman/core/utils`, and `streamToDisk` carries why a file transfer of
9
- * this size keeps raw `fetch` instead of going through `APIClient`, plus the `.part`-rename rule. What is
10
- * soil's, and stays here, is the URL shape, the cache key, and the two facts below that the shared
11
- * transfer is told rather than assumes: the progress stride and what a 400 means. The METADATA reads
12
- * around this one do go through `APIClient` — see `client.ts`.
13
- *
14
- * FRESHNESS IS `sacatalog.saverest`, NEVER A LENGTH PROBE, AND THE HOST LEAVES NO CHOICE. It answers `HEAD`
15
- * with HTTP 405 (`allow: GET`) and IGNORES `Range`: a request with `Range: bytes=0-0` returned HTTP 200 and
16
- * transferred the whole 27,598,377 bytes in 7.23 s. So "check the size first" starts a real download. The
17
- * cache is keyed on the version date the tabular service reports instead, and a vintage already on disk is
18
- * never re-fetched. The `Range` behaviour is PATH-SPECIFIC rather than host-wide — `/DataAvailability/`
19
- * does answer 206 — so a client must probe per path rather than conclude from one.
20
- *
21
- * THE FILENAME EMBEDS THE VERSION DATE AND A WRONG ONE IS AN HTTP 400. Not a 404: asking for a date the
22
- * host does not hold reads as a malformed request rather than a missing file, which is why the date comes
23
- * from the catalogue rather than from a guess. The square brackets must be sent literally, so the URL is
24
- * built with them percent-encoded.
25
- *
26
- * TWO CACHE VARIANTS EXIST AND THE BARE ONE IS WANTED. `wss_SSA_IA153_[2025-09-09].zip` is 25,474,922 bytes;
27
- * `wss_SSA_IA153_soildb_IA_2003_[2025-09-09].zip` is 27,598,377 and differs only by an EMPTY Microsoft Access
28
- * template container for a workflow this program does not use. Confirmed on a second area (`IA015`:
29
- * 38,981,269 against 41,104,724 bytes) and on a third that ships no template at all (`TX299`, 13,455,641
30
- * bytes, 97 files, no `.mdb`).
31
- */
32
-
33
- import { tryStat } from "@mailwoman/core/fs/readers"
34
- import { makeDirectories } from "@mailwoman/core/fs/writers"
35
- import { runFile } from "@mailwoman/core/process"
36
- import { streamToDisk } from "@mailwoman/core/utils"
37
- import { join } from "path-ts"
38
-
39
- /**
40
- * The download service's survey-area cache. Documented at `https://websoilsurvey.sc.egov.usda.gov/DSD/Download/help`,
41
- * which lists `GET /{CacheName}/{FileName}`.
42
- */
43
- export const WSS_SSA_CACHE_URL = "https://websoilsurvey.sc.egov.usda.gov/DSD/Download/Cache/SSA"
44
-
45
- /**
46
- * The archive URL for one survey area at one version date.
47
- *
48
- * The brackets are percent-encoded rather than sent raw: they are not valid in a URL path, and a client that sends them
49
- * literally depends on the fetcher tolerating them.
50
- */
51
- export function surveyAreaArchiveURL(areaSymbol: string, versionDate: string): string {
52
- return `${WSS_SSA_CACHE_URL}/wss_SSA_${areaSymbol}_%5B${versionDate}%5D.zip`
53
- }
54
-
55
- export interface DownloadSurveyAreaOptions {
56
- areaSymbol: string
57
- /**
58
- * The version date from `sacatalog.saverest`, as `YYYY-MM-DD`.
59
- */
60
- versionDate: string
61
- /**
62
- * Where vintages are kept. Each version date gets its own directory, so a new refresh never overwrites the old one in
63
- * place and a re-run against the same vintage never re-transfers.
64
- */
65
- cacheRoot: string
66
- onProgress?: (message: string) => void
67
- }
68
-
69
- /**
70
- * Bytes between progress reports. Smaller than the shared default because these archives are 13–41 MB, and the default
71
- * stride would leave the smallest of them reporting once.
72
- */
73
- const PROGRESS_STRIDE_BYTES = 8 * 1024 * 1024
74
-
75
- /**
76
- * What this host answers for a version date it does not hold. NOT a 404: it reads as a malformed request rather than a
77
- * missing file, which is why the message below says so and why the date comes from the catalogue rather than a guess.
78
- */
79
- const UNKNOWN_VERSION_STATUS = 400
80
-
81
- /**
82
- * What one acquired survey area is, on disk.
83
- */
84
- export interface SurveyAreaArchive {
85
- areaSymbol: string
86
- versionDate: string
87
- /**
88
- * The extracted `<areasymbol>/` directory, holding `spatial/` and `tabular/`.
89
- */
90
- root: string
91
- spatialDirectory: string
92
- tabularDirectory: string
93
- /**
94
- * The archive as transferred. Kept so a re-run costs nothing and so the bytes are re-checkable.
95
- */
96
- archivePath: string
97
- }
98
-
99
- /**
100
- * Download and unzip one survey area, returning where its pieces landed.
101
- *
102
- * Downloads to a `.part` file and renames only on a clean finish, so an interrupted transfer never presents as a
103
- * complete archive — the same discipline the database build uses, for the same reason.
104
- *
105
- * @throws {Error} When the host answers anything but 200, or when the extracted tree does not hold the two directories
106
- * every survey area publishes.
107
- */
108
- export async function downloadSurveyArea(options: DownloadSurveyAreaOptions): Promise<SurveyAreaArchive> {
109
- const vintageDirectory = join(options.cacheRoot, options.versionDate)
110
- const root = join(vintageDirectory, options.areaSymbol)
111
- const archivePath = join(vintageDirectory, `wss_SSA_${options.areaSymbol}.zip`)
112
-
113
- if (!(await tryStat(root))) {
114
- await makeDirectories(vintageDirectory)
115
-
116
- if (await tryStat(archivePath)) {
117
- options.onProgress?.(`${options.areaSymbol}: archive for ${options.versionDate} already downloaded`)
118
- } else {
119
- await streamToDisk({
120
- url: surveyAreaArchiveURL(options.areaSymbol, options.versionDate),
121
- destination: archivePath,
122
- context: "soil download",
123
- progressStrideBytes: PROGRESS_STRIDE_BYTES,
124
- describeStatus: (status) =>
125
- status === UNKNOWN_VERSION_STATUS
126
- ? " — this host answers 400 rather than 404 for a version date it does not hold, so check the date against sacatalog.saverest"
127
- : undefined,
128
- // Every progress line names the area, because a full acquisition interleaves hundreds of them.
129
- ...(options.onProgress
130
- ? { onProgress: (message: string) => options.onProgress?.(`${options.areaSymbol}: ${message}`) }
131
- : {}),
132
- })
133
- }
134
-
135
- // The archive holds its files under an `<AREASYMBOL>/` root already, so it unzips into the vintage directory
136
- // rather than into a directory named for itself.
137
- await runFile("unzip", ["-o", "-q", archivePath, "-d", vintageDirectory])
138
- } else {
139
- options.onProgress?.(`${options.areaSymbol}: already extracted for ${options.versionDate}`)
140
- }
141
-
142
- const spatialDirectory = join(root, "spatial")
143
- const tabularDirectory = join(root, "tabular")
144
-
145
- for (const directory of [spatialDirectory, tabularDirectory]) {
146
- if (!(await tryStat(directory))) {
147
- throw new Error(
148
- `soil download: ${options.areaSymbol} extracted without a ${directory} directory — every survey area publishes both spatial/ and tabular/, so this archive is not the product`
149
- )
150
- }
151
- }
152
-
153
- return {
154
- areaSymbol: options.areaSymbol,
155
- versionDate: options.versionDate,
156
- root,
157
- spatialDirectory,
158
- tabularDirectory,
159
- archivePath,
160
- }
161
- }
package/lib/sdk/index.ts DELETED
@@ -1,20 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- * @file The acquisition + build surface for the NRCS SSURGO soil-capability layer. The READER is the
6
- * package root.
7
- */
8
-
9
- export * from "#sdk/acquire"
10
- export * from "#sdk/build-soil"
11
- export * from "#sdk/cell-tiers"
12
- export * from "#sdk/cells"
13
- export * from "#sdk/client"
14
- export * from "#sdk/download"
15
- export * from "#sdk/ingest"
16
- export * from "#sdk/measure-resolutions"
17
- export * from "#sdk/reduce"
18
- export * from "#sdk/survey-area"
19
- export * from "#sdk/tabular"
20
- export * from "#sdk/verify"