@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
package/{lib/sdk → sdk}/cells.ts
RENAMED
|
@@ -3,31 +3,7 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
* The delineation-keyed cell index,
|
|
7
|
-
*
|
|
8
|
-
* THE CLASSIFIER ITSELF LIVES IN `@mailwoman/spatial`, because the traps it guards are properties of
|
|
9
|
-
* h3-js rather than of SSURGO: a centre-containment polyfill drops every polygon smaller than a cell, an
|
|
10
|
-
* exhausted WASM allocator reports success and returns zeros, and the allocator is sized from the
|
|
11
|
-
* bounding box. The layer contract states all three as requirements on every polygon builder. What is
|
|
12
|
-
* soil-shaped is the ACCUMULATOR below, which keys on the delineation rather than on a hazard class,
|
|
13
|
-
* because the reduction weights by the area a delineation covers.
|
|
14
|
-
*
|
|
15
|
-
* EXPECT THE `partial` SHARE TO INVERT AGAINST THE FLOOD LAYER, AND DO NOT READ THAT AS A DEFECT. Flood
|
|
16
|
-
* polygons are large against their cells, so most cells fall wholly inside one zone and `compactCells`
|
|
17
|
-
* collapses long uniform interiors. Soil delineations are the opposite: 85.4% of `IA153`'s 17,966 of them
|
|
18
|
-
* are smaller than one resolution-9 cell, and the median is 24,863 m² against a 105,333 m² cell. Small
|
|
19
|
-
* polygons against large cells means most cells are crossed by a boundary — so the `partial` share should
|
|
20
|
-
* be HIGH, `compactCells` should yield close to nothing, and the index alone will rarely answer a point
|
|
21
|
-
* probe. That is not an argument against storing the geometry; it is the argument for why this layer
|
|
22
|
-
* carries the reduced `soil_capability_cell` alongside the index rather than relying on the index the way
|
|
23
|
-
* the flood layer can.
|
|
24
|
-
*
|
|
25
|
-
* TWO NUMBERS GET REPORTED AT EACH CANDIDATE RESOLUTION, AND THEY MOVE IN OPPOSITE DIRECTIONS. The
|
|
26
|
-
* `partial` cell share says whether the containment index answers most probes alone. The share of cells
|
|
27
|
-
* whose top class holds less than half the cell says whether the layer is answering or hedging — the
|
|
28
|
-
* cell-grain analogue of NRCS's own `niccdcdpct` distribution, which reads 3.3% below half nationally.
|
|
29
|
-
* Going coarser improves the first and worsens the second, and picking between them is what the
|
|
30
|
-
* measurement is for.
|
|
6
|
+
* The delineation-keyed cell index, plus the partial-cell and mixed-top-class shares that choose its resolution.
|
|
31
7
|
*/
|
|
32
8
|
|
|
33
9
|
import {
|
|
@@ -41,7 +17,7 @@ import {
|
|
|
41
17
|
import { getResolution } from "h3-js"
|
|
42
18
|
|
|
43
19
|
/**
|
|
44
|
-
* The label this layer's classifier failures
|
|
20
|
+
* The label assigned to this layer's classifier failures.
|
|
45
21
|
*/
|
|
46
22
|
export const SOIL_CELL_LABEL = "soil cells"
|
|
47
23
|
|
|
@@ -74,12 +50,13 @@ export interface SoilCellIndexMeasurement {
|
|
|
74
50
|
*/
|
|
75
51
|
partialCells: number
|
|
76
52
|
/**
|
|
77
|
-
* `partialCells / touchedCells
|
|
53
|
+
* `partialCells / touchedCells`, the share of in-layer probes that cannot
|
|
54
|
+
* be answered using only the index.
|
|
78
55
|
*/
|
|
79
56
|
partialShare: number
|
|
80
57
|
/**
|
|
81
|
-
* Whole cells after `compactCells
|
|
82
|
-
*
|
|
58
|
+
* Whole cells after `compactCells`, expected to be close to `wholeCells` here
|
|
59
|
+
* because small delineations do not produce a uniform interior.
|
|
83
60
|
*/
|
|
84
61
|
compactedWholeCells: number
|
|
85
62
|
/**
|
|
@@ -87,8 +64,8 @@ export interface SoilCellIndexMeasurement {
|
|
|
87
64
|
*/
|
|
88
65
|
cellDelineationPairs: number
|
|
89
66
|
/**
|
|
90
|
-
* The mean number of delineations reaching a cell
|
|
91
|
-
*
|
|
67
|
+
* The mean number of delineations reaching a cell measures how mixed a cell is
|
|
68
|
+
* before any rating is read and rises as the resolution coarsens.
|
|
92
69
|
*/
|
|
93
70
|
meanDelineationsPerCell: number
|
|
94
71
|
/**
|
|
@@ -102,10 +79,9 @@ export interface SoilCellIndexMeasurement {
|
|
|
102
79
|
}
|
|
103
80
|
|
|
104
81
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* over full indexes and round-tripping through the integer form at every step would cost more than the strings do.
|
|
82
|
+
* Accumulates one resolution's cell index over a stream of delineations,
|
|
83
|
+
* held as short-cell strings because `compactCells` needs full h3-js indexes
|
|
84
|
+
* and round-tripping through the integer form would cost more.
|
|
109
85
|
*/
|
|
110
86
|
export class SoilCellIndex {
|
|
111
87
|
readonly resolution: number
|
|
@@ -113,8 +89,8 @@ export class SoilCellIndex {
|
|
|
113
89
|
readonly #whole = new Set<string>()
|
|
114
90
|
readonly #touched = new Set<string>()
|
|
115
91
|
/**
|
|
116
|
-
* `cell → delineation ids
|
|
117
|
-
* fringe
|
|
92
|
+
* `cell → delineation ids` for every touched cell, so the mean is over the
|
|
93
|
+
* real population rather than only the fringe.
|
|
118
94
|
*/
|
|
119
95
|
readonly #byCell = new Map<string, Set<string>>()
|
|
120
96
|
|
|
@@ -157,10 +133,8 @@ export class SoilCellIndex {
|
|
|
157
133
|
}
|
|
158
134
|
|
|
159
135
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* Compaction is applied to the WHOLE set only — a partial cell's parent is not partial in any useful sense, and
|
|
163
|
-
* compacting it would claim the fringe covers ground it does not.
|
|
136
|
+
* Compacts the whole-cell set and reports the measurement, applying compaction to the
|
|
137
|
+
* whole set only because a partial cell's parent would claim fringe ground.
|
|
164
138
|
*/
|
|
165
139
|
finish(): SoilCellIndexMeasurement {
|
|
166
140
|
const compacted = compactAcrossResolutions(this.#whole)
|
|
@@ -196,8 +170,8 @@ export class SoilCellIndex {
|
|
|
196
170
|
}
|
|
197
171
|
|
|
198
172
|
/**
|
|
199
|
-
* The measurement as markdown table
|
|
200
|
-
*
|
|
173
|
+
* The measurement as markdown table rows, one line per element so a caller never
|
|
174
|
+
* has to split a joined string back apart.
|
|
201
175
|
*/
|
|
202
176
|
export function formatSoilResolutionRows(
|
|
203
177
|
measurements: ReadonlyArray<SoilCellIndexMeasurement & { mixedCellShare?: number }>
|
package/sdk/client.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { APIClient, type APIClientConfig, type ClockLike, assertNoOGCServiceException } from "@mailwoman/core/api"
|
|
8
|
+
import { buildDiskStorage } from "@mailwoman/core/api/disk-storage"
|
|
9
|
+
import { parseJSONStrict, stringifyJSON } from "@mailwoman/core/json"
|
|
10
|
+
import type { PathBuilderLike } from "path-ts"
|
|
11
|
+
|
|
12
|
+
import { soilDatabasePath } from "#paths"
|
|
13
|
+
import { saverestToISODate } from "#sdk/tabular"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sends anonymous requests to the Soil Data Access tabular query endpoint.
|
|
17
|
+
* Requests need no key or account.
|
|
18
|
+
*/
|
|
19
|
+
export const SDA_POST_REST_URL = "https://sdmdataaccess.nrcs.usda.gov/Tabular/post.rest"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sets the minimum spacing between Soil Data Access requests.
|
|
23
|
+
* NRCS publishes no rate limit for the service.
|
|
24
|
+
*/
|
|
25
|
+
export const SDA_MIN_REQUEST_INTERVAL_MS = 500
|
|
26
|
+
|
|
27
|
+
const SDA_CACHE_TTL_MS = 12 * 60 * 60 * 1000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One published survey area, as the catalogue reports it.
|
|
31
|
+
*/
|
|
32
|
+
export interface SurveyAreaCatalogEntry {
|
|
33
|
+
areasymbol: string
|
|
34
|
+
areaname: string
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The version-established date as an ISO date.
|
|
38
|
+
* The survey area's archive filename embeds it.
|
|
39
|
+
*/
|
|
40
|
+
saverest: string
|
|
41
|
+
saversion: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A client for Soil Data Access.
|
|
46
|
+
*/
|
|
47
|
+
export class SoilDataAccessClient extends APIClient<APIClientConfig> {
|
|
48
|
+
/**
|
|
49
|
+
* Runs one SQL query and returns its rows as strings, with NULL as an empty string.
|
|
50
|
+
*
|
|
51
|
+
* @throws {OGCServiceError} When the service answers with an exception report,
|
|
52
|
+
* including an HTTP 200 response.
|
|
53
|
+
* A server-side timeout uses that status.
|
|
54
|
+
*/
|
|
55
|
+
public async query(sql: string): Promise<string[][]> {
|
|
56
|
+
const { data } = await this.fetch<string>({
|
|
57
|
+
method: "POST",
|
|
58
|
+
url: SDA_POST_REST_URL,
|
|
59
|
+
|
|
60
|
+
responseType: "text",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
data: { SERVICE: "query", FORMAT: "JSON", QUERY: sql },
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
assertNoOGCServiceException(data, `soil data access (query: ${sql.slice(0, 200)})`)
|
|
66
|
+
|
|
67
|
+
const parsed = parseJSONStrict<{ Table?: unknown }>(data)
|
|
68
|
+
|
|
69
|
+
if (parsed.Table === undefined) return []
|
|
70
|
+
|
|
71
|
+
if (!Array.isArray(parsed.Table)) {
|
|
72
|
+
throw new TypeError(
|
|
73
|
+
`soil data access: the service answered with a Table that is not an array (${typeof parsed.Table}) — the response format changed`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return parsed.Table.map((row) => (row as unknown[]).map((value) => (value === null ? "" : String(value))))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns the published survey areas whose symbol starts with `prefix`,
|
|
82
|
+
* such as a state code or one whole area symbol.
|
|
83
|
+
*
|
|
84
|
+
* @throws {Error} When no survey area matches, because a build over an empty set
|
|
85
|
+
* would otherwise report success having written no rows.
|
|
86
|
+
*/
|
|
87
|
+
public async readSurveyAreaCatalog(prefix: string): Promise<SurveyAreaCatalogEntry[]> {
|
|
88
|
+
const escaped = prefix.replaceAll("'", "''")
|
|
89
|
+
|
|
90
|
+
const rows = await this.query(
|
|
91
|
+
`SELECT areasymbol, areaname, saverest, saversion FROM sacatalog WHERE areasymbol LIKE '${escaped}%' ORDER BY areasymbol`
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if (!rows.length) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`soil data access: the catalogue holds no survey area whose symbol starts with ${stringifyJSON(prefix)} — a build over an empty set would report success having written nothing`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return rows.map((row) => ({
|
|
101
|
+
areasymbol: row[0]!,
|
|
102
|
+
areaname: row[1]!,
|
|
103
|
+
saverest: saverestToISODate(row[2]!),
|
|
104
|
+
saversion: Number(row[3]),
|
|
105
|
+
}))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns the map unit key the service's own geometry assigns at a point, or `undefined`,
|
|
110
|
+
* as a cross-check against the authority through a channel this package never processed.
|
|
111
|
+
*/
|
|
112
|
+
public async mukeyAtPoint(latitude: number, longitude: number): Promise<string | undefined> {
|
|
113
|
+
const rows = await this.query(
|
|
114
|
+
`SELECT mukey FROM SDA_Get_Mukey_from_intersection_with_WktWgs84('point(${longitude} ${latitude})')`
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return rows[0]?.[0] || undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Overrides the clock, the HTTP cache directory and the request spacing used
|
|
123
|
+
* by {@link createSoilDataAccessClient}.
|
|
124
|
+
*/
|
|
125
|
+
export interface CreateSoilDataAccessClientOptions {
|
|
126
|
+
clock?: ClockLike
|
|
127
|
+
cacheDirectory?: PathBuilderLike
|
|
128
|
+
minRequestIntervalMs?: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates a {@link SoilDataAccessClient} with retries, a 12-hour disk cache and the default request pacing.
|
|
133
|
+
*/
|
|
134
|
+
export function createSoilDataAccessClient(options: CreateSoilDataAccessClientOptions = {}): SoilDataAccessClient {
|
|
135
|
+
return new SoilDataAccessClient({
|
|
136
|
+
displayName: "SoilDataAccess",
|
|
137
|
+
minRequestIntervalMs: options.minRequestIntervalMs ?? SDA_MIN_REQUEST_INTERVAL_MS,
|
|
138
|
+
retry: true,
|
|
139
|
+
...(options.clock ? { clock: options.clock } : {}),
|
|
140
|
+
caching: {
|
|
141
|
+
ttl: SDA_CACHE_TTL_MS,
|
|
142
|
+
storage: buildDiskStorage({
|
|
143
|
+
directory: (options.cacheDirectory ?? soilDatabasePath("cache", "http")).toString(),
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
}
|
package/sdk/download.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { tryStat } from "@mailwoman/core/fs/readers"
|
|
8
|
+
import { makeDirectories } from "@mailwoman/core/fs/writers"
|
|
9
|
+
import { runFile } from "@mailwoman/core/process"
|
|
10
|
+
import { streamToDisk } from "@mailwoman/core/utils"
|
|
11
|
+
import { PathBuilder, type PathBuilderLike } from "path-ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Points to the Web Soil Survey download cache that serves survey-area archives.
|
|
15
|
+
*/
|
|
16
|
+
export const WSS_SSA_CACHE_URL = "https://websoilsurvey.sc.egov.usda.gov/DSD/Download/Cache/SSA"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns the archive URL for one survey area at one version date, with the brackets
|
|
20
|
+
* around the date percent-encoded because they are not valid in a URL path.
|
|
21
|
+
*/
|
|
22
|
+
export function surveyAreaArchiveURL(areaSymbol: string, versionDate: string): string {
|
|
23
|
+
return `${WSS_SSA_CACHE_URL}/wss_SSA_${areaSymbol}_%5B${versionDate}%5D.zip`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Configures {@link downloadSurveyArea}, which caches the archive and its
|
|
28
|
+
* extracted tree under `cacheRoot/<versionDate>`.
|
|
29
|
+
*/
|
|
30
|
+
export interface DownloadSurveyAreaOptions {
|
|
31
|
+
areaSymbol: string
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The survey area's version date from `sacatalog.saverest`, formatted as `YYYY-MM-DD`.
|
|
35
|
+
*/
|
|
36
|
+
versionDate: string
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The cache directory, where each version date gets its own subdirectory so a new
|
|
40
|
+
* vintage never overwrites an old one and a repeat run downloads no file.
|
|
41
|
+
*/
|
|
42
|
+
cacheRoot: PathBuilderLike
|
|
43
|
+
onProgress?: (message: string) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const PROGRESS_STRIDE_BYTES = 8 * 1024 * 1024
|
|
47
|
+
|
|
48
|
+
const UNKNOWN_VERSION_STATUS = 400
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What one acquired survey area is, on disk.
|
|
52
|
+
*/
|
|
53
|
+
export interface SurveyAreaArchive {
|
|
54
|
+
areaSymbol: string
|
|
55
|
+
versionDate: string
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The extracted `<areasymbol>/` directory.
|
|
59
|
+
* It contains `spatial/` and `tabular/`.
|
|
60
|
+
*/
|
|
61
|
+
root: PathBuilder
|
|
62
|
+
spatialDirectory: PathBuilder
|
|
63
|
+
tabularDirectory: PathBuilder
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The downloaded ZIP archive, kept so a repeat run skips the transfer and the bytes can be rechecked.
|
|
67
|
+
*/
|
|
68
|
+
archivePath: PathBuilder
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Downloads and unzips one survey area into the cache, skipping completed steps.
|
|
73
|
+
* Returns the paths to its files.
|
|
74
|
+
*
|
|
75
|
+
* The download goes through a `.part` file so an interrupted transfer never looks like a complete archive.
|
|
76
|
+
*
|
|
77
|
+
* @throws {Error} When the host answers anything but 200, or when the extracted
|
|
78
|
+
* tree lacks the `spatial` or `tabular` directory.
|
|
79
|
+
*/
|
|
80
|
+
export async function downloadSurveyArea(options: DownloadSurveyAreaOptions): Promise<SurveyAreaArchive> {
|
|
81
|
+
const vintageDirectory = PathBuilder.from(options.cacheRoot)(options.versionDate)
|
|
82
|
+
const root = vintageDirectory(options.areaSymbol)
|
|
83
|
+
const archivePath = vintageDirectory(`wss_SSA_${options.areaSymbol}.zip`)
|
|
84
|
+
|
|
85
|
+
if (!(await tryStat(root))) {
|
|
86
|
+
await makeDirectories(vintageDirectory)
|
|
87
|
+
|
|
88
|
+
if (await tryStat(archivePath)) {
|
|
89
|
+
options.onProgress?.(`${options.areaSymbol}: archive for ${options.versionDate} already downloaded`)
|
|
90
|
+
} else {
|
|
91
|
+
await streamToDisk({
|
|
92
|
+
url: surveyAreaArchiveURL(options.areaSymbol, options.versionDate),
|
|
93
|
+
destination: archivePath,
|
|
94
|
+
context: "soil download",
|
|
95
|
+
progressStrideBytes: PROGRESS_STRIDE_BYTES,
|
|
96
|
+
describeStatus: (status) =>
|
|
97
|
+
status === UNKNOWN_VERSION_STATUS
|
|
98
|
+
? " — this host answers 400 rather than 404 for a version date it does not hold, so check the date against sacatalog.saverest"
|
|
99
|
+
: undefined,
|
|
100
|
+
|
|
101
|
+
...(options.onProgress
|
|
102
|
+
? { onProgress: (message: string) => options.onProgress?.(`${options.areaSymbol}: ${message}`) }
|
|
103
|
+
: {}),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await runFile("unzip", ["-o", "-q", archivePath, "-d", vintageDirectory])
|
|
108
|
+
} else {
|
|
109
|
+
options.onProgress?.(`${options.areaSymbol}: already extracted for ${options.versionDate}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const spatialDirectory = root("spatial")
|
|
113
|
+
const tabularDirectory = root("tabular")
|
|
114
|
+
|
|
115
|
+
for (const directory of [spatialDirectory, tabularDirectory]) {
|
|
116
|
+
if (!(await tryStat(directory))) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`soil download: ${options.areaSymbol} extracted without a ${directory} directory — every survey area publishes both spatial/ and tabular/, so this archive is not the product`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
areaSymbol: options.areaSymbol,
|
|
125
|
+
versionDate: options.versionDate,
|
|
126
|
+
root,
|
|
127
|
+
spatialDirectory,
|
|
128
|
+
tabularDirectory,
|
|
129
|
+
archivePath,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -3,21 +3,18 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* This streaming pass writes each delineation to `soil_map_unit_area` and the build's touch table.
|
|
7
|
+
* It can process part of one survey area as a unit of work.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Iowa's 99 survey areas hold far more delineations together than any one of them does, which is why the
|
|
16
|
-
* bound is per RANGE rather than per area.
|
|
9
|
+
* H3's wasm heap cannot be reset from JavaScript. It also cannot sustain unlimited polyfill calls.
|
|
10
|
+
* Two runs over the flood layer's real product stopped after roughly 510,000 and 798,000 features.
|
|
11
|
+
* The same geometries classify in milliseconds in a fresh process. A build that completes only when
|
|
12
|
+
* fragmentation stays low is not reproducible. {@linkcode buildSoilDatabase} bounds each process to a range
|
|
13
|
+
* of the shapefile's FIDs. Each range gets a separate process with a fresh heap. Iowa's 99 survey areas
|
|
14
|
+
* contain far more delineations together than each area contains by itself. The bound therefore applies per range.
|
|
17
15
|
*
|
|
18
|
-
*
|
|
19
|
-
* counts
|
|
20
|
-
* and no locking to reason about.
|
|
16
|
+
* The chunk appends rows to a database created by the parent. The parent seals that database and adds up
|
|
17
|
+
* the counts returned by each chunk. Chunks run one at a time against the file, so only one writer uses it.
|
|
21
18
|
*/
|
|
22
19
|
|
|
23
20
|
import { addCoverageCells, encodeRings, ringAreaReadings, ringsBoundingBox, shortCellToInt } from "@mailwoman/spatial"
|
|
@@ -29,8 +26,12 @@ import { classifyDelineationCells } from "#sdk/cells"
|
|
|
29
26
|
import type { SoilFeatureSource } from "#sdk/ingest"
|
|
30
27
|
|
|
31
28
|
/**
|
|
32
|
-
* Rows per bulk-insert transaction.
|
|
33
|
-
*
|
|
29
|
+
* Rows per bulk-insert transaction.
|
|
30
|
+
*
|
|
31
|
+
* Chosen for the geometry table.
|
|
32
|
+
* Each row contains a blob.
|
|
33
|
+
*
|
|
34
|
+
* A larger transaction grows the write-ahead file without improving throughput.
|
|
34
35
|
*/
|
|
35
36
|
const INSERT_TRANSACTION_ROWS = 5000
|
|
36
37
|
|
|
@@ -40,8 +41,9 @@ const INSERT_TRANSACTION_ROWS = 5000
|
|
|
40
41
|
const PROGRESS_STRIDE = 20_000
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* What one chunk produced.
|
|
44
|
-
*
|
|
44
|
+
* What one chunk produced.
|
|
45
|
+
*
|
|
46
|
+
* Every field is JSON-serializable, because a chunk normally reports across a process boundary.
|
|
45
47
|
*/
|
|
46
48
|
export interface SoilChunkResult {
|
|
47
49
|
areaSymbol: string
|
|
@@ -51,19 +53,22 @@ export interface SoilChunkResult {
|
|
|
51
53
|
*/
|
|
52
54
|
coarsened: number
|
|
53
55
|
/**
|
|
54
|
-
* `[coverageCell, delineationsReachingIt]` pairs — an array rather than a `Map`
|
|
56
|
+
* `[coverageCell, delineationsReachingIt]` pairs — an array rather than a `Map`
|
|
57
|
+
* so it survives the process boundary.
|
|
55
58
|
*/
|
|
56
59
|
observedByCoverageCell: Array<[number, number]>
|
|
57
60
|
/**
|
|
58
|
-
* The same, counting only delineations whose map unit
|
|
61
|
+
* The same, counting only delineations whose map unit has soil mapping behind it.
|
|
59
62
|
*
|
|
60
|
-
* Separate from the total because the coverage rule
|
|
61
|
-
* access-denied polygons
|
|
62
|
-
*
|
|
63
|
+
* Separate from the total because the coverage rule depends on this value.
|
|
64
|
+
* A cell reached only by `notcom` or access-denied polygons lies inside a published
|
|
65
|
+
* survey area and contains no digitized soil mapping.
|
|
66
|
+
*
|
|
67
|
+
* Section 3.2 of the survey specification assigns no row to that cell.
|
|
63
68
|
*/
|
|
64
69
|
mappedByCoverageCell: Array<[number, number]>
|
|
65
70
|
/**
|
|
66
|
-
* Square
|
|
71
|
+
* Square meters computed from the encoded rings, with holes and with every ring treated as exterior.
|
|
67
72
|
*/
|
|
68
73
|
area: { nestedM2: number; allExteriorM2: number }
|
|
69
74
|
}
|
|
@@ -73,8 +78,10 @@ export interface IngestSoilChunkOptions {
|
|
|
73
78
|
indexResolution: number
|
|
74
79
|
coverageResolution: number
|
|
75
80
|
/**
|
|
76
|
-
* The map units with
|
|
77
|
-
*
|
|
81
|
+
* The map units with no soil mapping behind them — `notcom`, `notpub`,
|
|
82
|
+
* access denied, or no readable component weights.
|
|
83
|
+
*
|
|
84
|
+
* Passed in rather than joined here so the chunk stays a streaming pass over geometry.
|
|
78
85
|
*/
|
|
79
86
|
noMappingMukeys: ReadonlySet<string>
|
|
80
87
|
onProgress?: (message: string) => void
|
|
@@ -83,7 +90,8 @@ export interface IngestSoilChunkOptions {
|
|
|
83
90
|
/**
|
|
84
91
|
* Stream one chunk of one survey area into `database`.
|
|
85
92
|
*
|
|
86
|
-
* @throws {Error} On a delineation the classifier refuses — which includes the
|
|
93
|
+
* @throws {Error} On a delineation the classifier refuses — which includes the
|
|
94
|
+
* allocator's silent zero-cell answer.
|
|
87
95
|
*/
|
|
88
96
|
export async function ingestSoilChunk(
|
|
89
97
|
database: DatabaseClient<SoilDatabase>,
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
* One chunk of the soil ingest
|
|
7
|
-
* The process boundary and the stdout contract live with `runIngestChunkScript`; what stays here is only
|
|
8
|
-
* this product's flags and its feature-source constructor.
|
|
6
|
+
* One chunk of the soil ingest as its own process, spawned by `buildSoilDatabase` and never run by hand.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import { requiredArgument } from "@mailwoman/core/scripting/arguments"
|
|
@@ -14,7 +12,7 @@ import type { DatabaseClient } from "@mailwoman/sqlite/client"
|
|
|
14
12
|
|
|
15
13
|
import type { SoilDatabase } from "#schema"
|
|
16
14
|
import { createShapefileFeatureSource } from "#sdk/ingest"
|
|
17
|
-
import { ingestSoilChunk } from "#sdk/ingest
|
|
15
|
+
import { ingestSoilChunk } from "#sdk/ingest/chunk"
|
|
18
16
|
|
|
19
17
|
await runIngestChunkScript({
|
|
20
18
|
context: "soil ingest-chunk",
|
|
@@ -33,15 +31,18 @@ await runIngestChunkScript({
|
|
|
33
31
|
areaSymbol: requiredArgument("soil ingest-chunk", "area-symbol", values["area-symbol"]),
|
|
34
32
|
fidFrom: Number(requiredArgument("soil ingest-chunk", "fid-from", values["fid-from"])),
|
|
35
33
|
fidTo: Number(requiredArgument("soil ingest-chunk", "fid-to", values["fid-to"])),
|
|
36
|
-
// A range's own count is not knowable up front — `ogrinfo` reports the layer's total
|
|
37
|
-
//
|
|
34
|
+
// A range's own count is not knowable up front — `ogrinfo` reports the layer's total
|
|
35
|
+
// and no narrower count — so the chunk makes no assertion about its size
|
|
36
|
+
// and the parent checks the per-area sum against the shapefile's.
|
|
38
37
|
declaredFeatureCount: 0,
|
|
39
38
|
}),
|
|
40
39
|
indexResolution: chunk.indexResolution,
|
|
41
40
|
coverageResolution: chunk.coverageResolution,
|
|
42
|
-
// An empty string is an empty set
|
|
43
|
-
//
|
|
44
|
-
|
|
41
|
+
// An empty string is an empty set rather than "every map unit": a build
|
|
42
|
+
// where every map unit has soil mapping passes one.
|
|
43
|
+
// `"".split(",")` yields one empty element that has to be dropped
|
|
44
|
+
// rather than joined against as a mukey.
|
|
45
|
+
noMappingMukeys: new Set((values["no-mapping-mukeys"] ?? "").split(",").filter((mukey) => mukey.length)),
|
|
45
46
|
onProgress: chunk.onProgress,
|
|
46
47
|
}),
|
|
47
48
|
})
|