@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
@@ -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, and the two numbers the index resolution is chosen from.
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 carry.
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` — the share of in-layer probes that cannot be answered from the index alone.
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`. Expected to be close to `wholeCells` here rather than far below it: compaction
82
- * needs a uniform interior, and small delineations do not produce one.
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 — the direct measure of how mixed a cell is before any rating is
91
- * read, and the number that rises as the resolution coarsens.
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
- * Accumulate one resolution's cell index over a stream of delineations.
106
- *
107
- * Held as short-cell STRINGS rather than the integers the tables store, because `compactCells` is an h3-js function
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`. Every touched cell, so the mean below is over the real population rather than over the
117
- * fringe alone.
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
- * Compact the whole-cell set and report the measurement.
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 ROWS — what a build receipt carries, one line per element so a caller printing them
200
- * never has to split a joined string back apart.
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
+ }
@@ -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
- * The streaming pass — every delineation into `soil_map_unit_area` and into the build's touch table — as a
7
- * unit of work that can run over PART of one survey area.
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
- * WHY THIS IS A CHUNK RATHER THAN A WHOLE FILE. h3's WASM heap cannot be reset from JavaScript, and it does
10
- * not survive an unbounded number of polyfill calls: over the flood layer's real product, runs died after
11
- * roughly 510,000 and 798,000 features on geometry that classifies in milliseconds in a fresh process. A
12
- * build that completes only when fragmentation happens to stay low is not a reproducible build, so the
13
- * classification is bounded by construction — {@linkcode buildSoilDatabase} runs one of these per range of
14
- * the shapefile's own FIDs, each in its own process, and each therefore against a heap that starts empty.
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
- * THE CHUNK OWNS NO ARTIFACT. It appends rows to a database the parent created and will seal, and returns
19
- * counts the parent adds up. Chunks run one at a time against that file, so there is no concurrent writer
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. Chosen for the geometry table, whose rows carry a blob: a larger transaction grows
33
- * the write-ahead file without improving throughput.
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. Every field is JSON-serializable, because a chunk normally reports across a process
44
- * boundary.
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` so it survives the process boundary.
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 HAS soil mapping behind it.
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 turns on it: a coverage cell reached only by `NOTCOM` and
61
- * access-denied polygons is inside a published survey area and carries no digitized soil mapping, and the survey's
62
- * §3.2 gives it no row.
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 metres: the encoded rings read WITH their holes, and read without.
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 NO soil mapping behind them — `NOTCOM`, `NOTPUB`, access denied, or no readable component
77
- * weights. Passed in rather than joined here so the chunk stays a streaming pass over geometry.
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 allocator's silent zero-cell answer.
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, as its own process — spawned by `buildSoilDatabase`, never run by hand.
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-chunk"
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 and nothing narrower — so
37
- // the chunk asserts nothing about its size and the PARENT checks the per-area sum against the shapefile's.
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, not "every map unit": a build where nothing lacks soil mapping passes one, and
43
- // `"".split(",")` yields one empty element that has to be dropped rather than joined against as a mukey.
44
- noMappingMukeys: new Set((values["no-mapping-mukeys"] ?? "").split(",").filter((mukey) => mukey.length > 0)),
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
  })