@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.
- package/README.md +118 -116
- package/lib/index.ts +66 -98
- package/lib/paths.ts +24 -0
- package/lib/schema.ts +188 -120
- package/lib/vocabulary.ts +40 -107
- package/out/index.d.ts +52 -70
- package/out/index.d.ts.map +1 -1
- package/out/index.js +24 -72
- package/out/index.js.map +1 -1
- package/out/paths.d.ts +19 -0
- package/out/paths.d.ts.map +1 -0
- package/out/paths.js +21 -0
- package/out/paths.js.map +1 -0
- package/out/schema.d.ts +187 -119
- package/out/schema.d.ts.map +1 -1
- package/out/schema.js +31 -31
- package/out/schema.js.map +1 -1
- package/out/sdk/acquire.d.ts +15 -24
- package/out/sdk/acquire.d.ts.map +1 -1
- package/out/sdk/acquire.js +5 -20
- package/out/sdk/acquire.js.map +1 -1
- package/out/sdk/build-soil.d.ts +67 -70
- package/out/sdk/build-soil.d.ts.map +1 -1
- package/out/sdk/build-soil.js +65 -95
- package/out/sdk/build-soil.js.map +1 -1
- package/out/sdk/cell-tiers.d.ts +7 -19
- package/out/sdk/cell-tiers.d.ts.map +1 -1
- package/out/sdk/cell-tiers.js +19 -38
- package/out/sdk/cell-tiers.js.map +1 -1
- package/out/sdk/cells.d.ts +15 -41
- package/out/sdk/cells.d.ts.map +1 -1
- package/out/sdk/cells.js +11 -38
- package/out/sdk/cells.js.map +1 -1
- package/out/sdk/client.d.ts +23 -48
- package/out/sdk/client.d.ts.map +1 -1
- package/out/sdk/client.js +19 -63
- package/out/sdk/client.js.map +1 -1
- package/out/sdk/download.d.ts +24 -47
- package/out/sdk/download.d.ts.map +1 -1
- package/out/sdk/download.js +14 -54
- package/out/sdk/download.js.map +1 -1
- package/out/sdk/ingest/chunk.d.ts +77 -0
- package/out/sdk/ingest/chunk.d.ts.map +1 -0
- package/out/sdk/{ingest-chunk.js → ingest/chunk.js} +19 -17
- package/out/sdk/ingest/chunk.js.map +1 -0
- package/out/sdk/ingest/worker.d.ts +9 -0
- package/out/sdk/ingest/worker.d.ts.map +1 -0
- package/out/{scripts/ingest-chunk.js → sdk/ingest/worker.js} +11 -10
- package/out/sdk/ingest/worker.js.map +1 -0
- package/out/sdk/ingest.d.ts +40 -52
- package/out/sdk/ingest.d.ts.map +1 -1
- package/out/sdk/ingest.js +18 -61
- package/out/sdk/ingest.js.map +1 -1
- package/out/sdk/measure-resolutions.d.ts +5 -16
- package/out/sdk/measure-resolutions.d.ts.map +1 -1
- package/out/sdk/measure-resolutions.js +3 -15
- package/out/sdk/measure-resolutions.js.map +1 -1
- package/out/sdk/reduce.d.ts +33 -59
- package/out/sdk/reduce.d.ts.map +1 -1
- package/out/sdk/reduce.js +47 -78
- package/out/sdk/reduce.js.map +1 -1
- package/out/sdk/survey-area.d.ts +16 -48
- package/out/sdk/survey-area.d.ts.map +1 -1
- package/out/sdk/survey-area.js +33 -77
- package/out/sdk/survey-area.js.map +1 -1
- package/out/sdk/tabular.d.ts +32 -31
- package/out/sdk/tabular.d.ts.map +1 -1
- package/out/sdk/tabular.js +58 -56
- package/out/sdk/tabular.js.map +1 -1
- package/out/sdk/test-kit.d.ts +57 -0
- package/out/sdk/test-kit.d.ts.map +1 -0
- package/out/{test-kit.js → sdk/test-kit.js} +18 -39
- package/out/sdk/test-kit.js.map +1 -0
- package/out/sdk/verify.d.ts +15 -51
- package/out/sdk/verify.d.ts.map +1 -1
- package/out/sdk/verify.js +19 -77
- package/out/sdk/verify.js.map +1 -1
- package/out/vocabulary.d.ts +37 -103
- package/out/vocabulary.d.ts.map +1 -1
- package/out/vocabulary.js +34 -107
- package/out/vocabulary.js.map +1 -1
- package/package.json +36 -190
- package/{lib/sdk → sdk}/acquire.ts +17 -27
- package/{lib/sdk → sdk}/build-soil.ts +114 -128
- package/{lib/sdk → sdk}/cell-tiers.ts +20 -39
- package/{lib/sdk → sdk}/cells.ts +17 -43
- package/sdk/client.ts +147 -0
- package/sdk/download.ts +131 -0
- package/{lib/sdk/ingest-chunk.ts → sdk/ingest/chunk.ts} +34 -26
- package/{lib/scripts/ingest-chunk.ts → sdk/ingest/worker.ts} +10 -9
- package/sdk/ingest.ts +253 -0
- package/{lib/sdk → sdk}/measure-resolutions.ts +5 -16
- package/sdk/reduce.ts +344 -0
- package/{lib/sdk → sdk}/survey-area.ts +39 -83
- package/{lib/sdk → sdk}/tabular.ts +62 -59
- package/{lib → sdk}/test-kit.ts +18 -40
- package/{lib/sdk → sdk}/verify.ts +29 -85
- package/lib/sdk/client.ts +0 -184
- package/lib/sdk/download.ts +0 -161
- package/lib/sdk/index.ts +0 -20
- package/lib/sdk/ingest.ts +0 -278
- package/lib/sdk/reduce.ts +0 -375
- package/out/scripts/ingest-chunk.d.ts +0 -11
- package/out/scripts/ingest-chunk.d.ts.map +0 -1
- package/out/scripts/ingest-chunk.js.map +0 -1
- package/out/sdk/index.d.ts +0 -20
- package/out/sdk/index.d.ts.map +0 -1
- package/out/sdk/index.js +0 -20
- package/out/sdk/index.js.map +0 -1
- package/out/sdk/ingest-chunk.d.ts +0 -73
- package/out/sdk/ingest-chunk.d.ts.map +0 -1
- package/out/sdk/ingest-chunk.js.map +0 -1
- package/out/test-kit.d.ts +0 -79
- package/out/test-kit.d.ts.map +0 -1
- 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
|
|
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
|
-
*
|
|
41
|
+
* Meters from the point to the nearest edge of the delineation the artifact matched.
|
|
69
42
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
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
|
|
101
|
-
* name is a coordinate nobody can check.
|
|
73
|
+
* Points outside the pilot region.
|
|
102
74
|
*
|
|
103
|
-
* Every
|
|
104
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
96
|
+
databasePath: PathBuilderLike
|
|
132
97
|
client: Pick<SoilDataAccessClient, "mukeyAtPoint">
|
|
133
98
|
/**
|
|
134
|
-
* Points to re-ask the service about
|
|
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
|
|
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
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
-
//
|
|
237
|
-
//
|
|
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
|
-
*
|
|
254
|
-
* edge.
|
|
207
|
+
* The map unit that the artifact's geometry places at a point.
|
|
255
208
|
*
|
|
256
|
-
*
|
|
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
|
-
*
|
|
239
|
+
* Meters from a point to the nearest edge of an encoded ring set.
|
|
288
240
|
*
|
|
289
|
-
*
|
|
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:
|
|
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
|
-
//
|
|
338
|
-
//
|
|
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
|
-
}
|
package/lib/sdk/download.ts
DELETED
|
@@ -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"
|