@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
package/lib/sdk/ingest.ts DELETED
@@ -1,278 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- *
6
- * Read a survey area's published shapefiles as a stream of WGS84 delineations, through ogr2ogr.
7
- *
8
- * OGR IS BUILD TOOLING, NEVER A SERVE DEPENDENCY (SCOPE invariant 6). It converts the authority's geometry
9
- * into the structure the runtime probes, and nothing downstream of this module knows GDAL exists.
10
- *
11
- * THE SOURCE IS ALREADY IN WGS84, AND CHECKING IT IS STILL THE CHECK. Each `.prj` is an ESRI WKT reading
12
- * `GEOGCS["GCS_WGS_1984",…]`, which GDAL resolves to EPSG:4326 — so no reprojection is needed before H3.
13
- * The authority code is asserted anyway before a single feature is read, and the reprojected stream is
14
- * asserted against the layer's own declared extent, which is the check that catches a coordinate-order
15
- * mistake the projection check cannot see.
16
- *
17
- * THE DATUM GUARD RUNS EVEN THOUGH THE ANSWER IS THE IDENTITY, AND THAT IS THE POINT. PROJ substitutes a
18
- * ballpark datum shift SILENTLY when the accurate grid is missing — measured on the flood layer at 3.4 m
19
- * over an entire country, visible only as eight disagreements out of 59 against the authority's own
20
- * service. For an EPSG:4326 source `projinfo` answers `Null geographic offset from WGS 84 to WGS 84, 0 m,
21
- * World.` and the guard passes in one process. Skipping it on the reasoning that this source needs no
22
- * shift is how the guard comes to be missing on the day a source arrives that does.
23
- *
24
- * THE ID IS THE SHAPEFILE'S OWN FID, AND IT HAS TO BE, because SSURGO publishes no per-delineation key:
25
- * `MUKEY` names the MAP UNIT and one map unit has many delineations — `IA153` holds 17,966 delineations
26
- * across 152 map units. So `area_id` is `<areasymbol>:<fid>`, which is stable across runs and is what makes
27
- * a bounded chunk name the same features every time.
28
- */
29
-
30
- import { declaredFeatureCount } from "@mailwoman/core/layers"
31
- import { assertRingsInsideExtent, requireArealPolygons, type MultiPolygonRings } from "@mailwoman/spatial"
32
- import { readOGRLayerIdentity } from "@mailwoman/spatial/tools/ogr"
33
- import { ogr2ogrGeoJSONSeq } from "@mailwoman/spatial/tools/ogr-stream"
34
- import { basename, join } from "path-ts"
35
-
36
- import { SSURGO_SOURCE_EPSG } from "#vocabulary"
37
-
38
- /**
39
- * Coordinate decimals ogr2ogr writes into the stream. Nine is well past the source's own precision — the metadata
40
- * states compilation to base maps meeting National Map Accuracy Standards at 1 inch = 1,000 feet — and is chosen so the
41
- * round trip contributes nothing measurable to the area cross-check.
42
- */
43
- const COORDINATE_PRECISION = 9
44
-
45
- /**
46
- * How far outside the layer's declared extent a vertex may fall before the ingest refuses.
47
- *
48
- * A tenth of a degree is about 11 km — small enough that an unprojected or axis-swapped read, which lands whole
49
- * hemispheres away, still fails, and loose enough that a rounded declared extent is not brittle.
50
- */
51
- const BBOX_MARGIN_DEGREES = 0.1
52
-
53
- /**
54
- * The shapefile holding a survey area's map-unit polygons — the delineations this layer stores.
55
- */
56
- export function mapUnitShapefile(spatialDirectory: string, areaSymbol: string): string {
57
- return join(spatialDirectory, `soilmu_a_${areaSymbol.toLowerCase()}.shp`)
58
- }
59
-
60
- /**
61
- * The shapefile holding a survey area's own OUTLINE. The footprint comes from HERE and never from the union of the
62
- * rated polygons — `NOTCOM` and access-denied map units are inside the footprint and carry no rating, so a footprint
63
- * derived from the rated set would report them as unmapped when the authority has declared exactly what they are.
64
- */
65
- export function surveyAreaShapefile(spatialDirectory: string, areaSymbol: string): string {
66
- return join(spatialDirectory, `soilsa_a_${areaSymbol.toLowerCase()}.shp`)
67
- }
68
-
69
- /**
70
- * One map-unit delineation, reprojected to WGS84.
71
- */
72
- export interface SoilDelineation {
73
- /**
74
- * `<areasymbol>:<fid>`.
75
- */
76
- areaID: string
77
- mukey: string
78
- areasymbol: string
79
- polygons: MultiPolygonRings
80
- }
81
-
82
- /**
83
- * What a shapefile says about itself, read before any feature is.
84
- */
85
- export interface SoilSourceIdentity {
86
- epsg: number
87
- featureCount: number
88
- layer: string
89
- /**
90
- * The layer's own declared extent, `[minLon, minLat, maxLon, maxLat]`.
91
- */
92
- bbox: readonly [number, number, number, number]
93
- }
94
-
95
- export interface SoilIngestOptions {
96
- shapefilePath: string
97
- /**
98
- * Layer inside it. Defaults to the shapefile's base name, which is what the ESRI driver reports.
99
- */
100
- layer?: string
101
- /**
102
- * The EPSG code the source must declare.
103
- */
104
- expectEPSG?: number
105
- /**
106
- * Read only the shapefile's own FIDs in `[fidFrom, fidTo]`, inclusive — what makes a bounded chunk possible.
107
- */
108
- fidFrom?: number
109
- fidTo?: number
110
- /**
111
- * Stop after this many features. The fixture and smoke rungs use it; a full build does not set it.
112
- */
113
- limit?: number
114
- }
115
-
116
- /**
117
- * Read what the shapefile declares about itself, and refuse a projection this ingest was not written for.
118
- *
119
- * @throws {Error} When the layer is missing, declares no EPSG authority code, declares one other than `expectEPSG`, or
120
- * reports no feature count.
121
- */
122
- export async function readSoilSourceIdentity(options: SoilIngestOptions): Promise<SoilSourceIdentity> {
123
- const identity = await readOGRLayerIdentity({
124
- path: options.shapefilePath,
125
- layer: options.layer ?? basename(options.shapefilePath, ".shp"),
126
- expectEPSG: options.expectEPSG ?? SSURGO_SOURCE_EPSG,
127
- context: "soil ingest",
128
- requireExtent: true,
129
- messages: {
130
- noAuthorityCode: "the projection cannot be checked, and reading one datum's coordinates as another's is silent",
131
- epsgMismatch:
132
- "SSURGO publishes geographic WGS84, so a different code is a product change rather than a variation to absorb",
133
- },
134
- })
135
-
136
- return { epsg: identity.epsg, featureCount: identity.featureCount, layer: identity.layer, bbox: identity.extent! }
137
- }
138
-
139
- /**
140
- * The ingest's `SELECT`, with the FID range applied when one is asked for.
141
- */
142
- function delineationSelectSQL(layer: string, options: SoilIngestOptions): string {
143
- const select = `SELECT FID AS fid, MUKEY AS mukey, AREASYMBOL AS areasymbol FROM "${layer}"`
144
- const bounds: string[] = []
145
-
146
- if (options.fidFrom !== undefined) {
147
- bounds.push(`FID >= ${options.fidFrom}`)
148
- }
149
-
150
- if (options.fidTo !== undefined) {
151
- bounds.push(`FID <= ${options.fidTo}`)
152
- }
153
-
154
- return bounds.length ? `${select} WHERE ${bounds.join(" AND ")}` : select
155
- }
156
-
157
- interface RawFeature {
158
- properties: { fid: number | string; mukey: number | string | null; areasymbol: string | null }
159
- geometry: { type: string; coordinates: unknown } | null
160
- }
161
-
162
- /**
163
- * Stream the map-unit delineations as WGS84 features.
164
- *
165
- * Every feature is checked against the declared extent as it passes. A swapped coordinate order survives a projection
166
- * check — both axes are still numbers in a plausible range — and shows up here immediately.
167
- *
168
- * @throws {Error} When ogr2ogr fails, when a feature carries no geometry or no `MUKEY`, or when a vertex falls outside
169
- * the declared extent.
170
- */
171
- export async function* readSoilDelineations(
172
- options: SoilIngestOptions & { bbox: readonly [number, number, number, number] }
173
- ): AsyncGenerator<SoilDelineation> {
174
- const layer = options.layer ?? basename(options.shapefilePath, ".shp")
175
- const [minLon, minLat, maxLon, maxLat] = options.bbox
176
-
177
- const args = [
178
- "-f",
179
- "GeoJSONSeq",
180
- "/vsistdout/",
181
- // The OUTPUT projection. `expectEPSG` is the assertion `readSoilSourceIdentity` makes about the SOURCE and is not
182
- // the same thing: the consumer reads WGS84, whatever the shapefile declares.
183
- "-t_srs",
184
- "EPSG:4326",
185
- "-lco",
186
- `COORDINATE_PRECISION=${COORDINATE_PRECISION}`,
187
- ...(options.limit === undefined ? [] : ["-limit", String(options.limit)]),
188
- "-sql",
189
- delineationSelectSQL(layer, options),
190
- options.shapefilePath,
191
- ]
192
-
193
- for await (const raw of ogr2ogrGeoJSONSeq<RawFeature>(args, "soil ingest")) {
194
- yield toDelineation(raw, { minLon, minLat, maxLon, maxLat })
195
- }
196
- }
197
-
198
- /**
199
- * Validate one raw GeoJSON feature and narrow it. Split out so the generator body stays a loop.
200
- */
201
- function toDelineation(
202
- raw: RawFeature,
203
- extent: { minLon: number; minLat: number; maxLon: number; maxLat: number }
204
- ): SoilDelineation {
205
- const { properties, geometry } = raw
206
-
207
- if (!geometry) {
208
- throw new Error(`soil ingest: delineation ${properties.fid} carries no geometry`)
209
- }
210
-
211
- if (properties.mukey === null || properties.mukey === "") {
212
- throw new Error(
213
- `soil ingest: delineation ${properties.fid} carries no MUKEY — a delineation with no map unit joins to nothing and would read downstream as unmapped ground`
214
- )
215
- }
216
-
217
- if (!properties.areasymbol) {
218
- throw new Error(`soil ingest: delineation ${properties.fid} carries no AREASYMBOL`)
219
- }
220
-
221
- const polygons = requireArealPolygons(geometry, `delineation ${properties.fid}`, "soil ingest")
222
-
223
- assertRingsInsideExtent(polygons, `delineation ${properties.fid}`, extent, BBOX_MARGIN_DEGREES, "soil ingest")
224
-
225
- return {
226
- areaID: `${properties.areasymbol}:${properties.fid}`,
227
- mukey: String(properties.mukey),
228
- areasymbol: properties.areasymbol,
229
- polygons,
230
- }
231
- }
232
-
233
- /**
234
- * Where a build's delineations come from, and what the source declares about itself.
235
- *
236
- * The builder takes ONE of these rather than a path, which is what makes the fixture rung possible: hand-built geometry
237
- * with no network and no GDAL still exercises the whole database half — the domain check, the cell classification, the
238
- * reduction, the coverage rows, the manifest and the seal.
239
- */
240
- export interface SoilFeatureSource {
241
- areaSymbol: string
242
- /**
243
- * What the source says it holds. The build compares its own streamed total against this, so a short read throws
244
- * instead of building a smaller county.
245
- */
246
- declaredFeatureCount: number
247
- layer: string
248
- epsg: number
249
- /**
250
- * A description of where these delineations came from, for the receipt.
251
- */
252
- origin: string
253
- delineations: () => AsyncIterable<SoilDelineation>
254
- }
255
-
256
- /**
257
- * One survey area's map-unit shapefile as a feature source — identity read up front, features streamed on demand.
258
- */
259
- export async function createShapefileFeatureSource(
260
- options: SoilIngestOptions & { areaSymbol: string; declaredFeatureCount?: number }
261
- ): Promise<SoilFeatureSource> {
262
- const identity = await readSoilSourceIdentity(options)
263
-
264
- return {
265
- areaSymbol: options.areaSymbol,
266
- // A RANGE's own count is supplied by the caller, because `ogrinfo` reports the layer's total and nothing narrower.
267
- // The whole-file total is still checked: the builder sums what its chunks streamed and compares that.
268
- declaredFeatureCount: declaredFeatureCount({
269
- declared: options.declaredFeatureCount,
270
- limit: options.limit,
271
- layerCount: identity.featureCount,
272
- }),
273
- layer: identity.layer,
274
- epsg: identity.epsg,
275
- origin: options.shapefilePath,
276
- delineations: () => readSoilDelineations({ ...options, bbox: identity.bbox }),
277
- }
278
- }
package/lib/sdk/reduce.ts DELETED
@@ -1,375 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- *
6
- * The reduction: the containment index, read once, into one per-cell distribution both consumers share.
7
- *
8
- * A DISTRIBUTION, NEVER A WINNER, AND THAT IS FORCED BY MEASUREMENT. 84.0% of the 339,191 national map
9
- * units hold two or more components; in 16.8% the largest component covers under half the map unit; and
10
- * 85.4% of `IA153`'s delineations are smaller than one resolution-9 cell. No affordable cell size removes
11
- * the mixture — it is a property of the survey, whose own `mukind` says so: 128,499 map units (38.0%) are
12
- * complexes, associations or undifferentiated groups, which is NRCS stating that the soils are
13
- * intermingled and cannot be separated at the mapping scale. A winner class would satisfy the result-level
14
- * consumer and starve the signal consumer, which needs a magnitude to vary over.
15
- *
16
- * THE WEIGHT IS A UNIFORM-AREA LATTICE OVER THE CELL, AND THE GRAIN IS CHOSEN AGAINST THE AUTHORITY'S OWN.
17
- * A cell's children at {@link WEIGHT_LATTICE_DEPTH} levels finer have equal area by construction, so
18
- * counting which delineation covers each child centre estimates covered area without a polygon clip. At
19
- * depth 2 that is 49 children — 2.04% per child, which is the finest share NRCS's own
20
- * `muaggatt.niccdcdpct` ever reports (observed minimum: 2%). Resolving finer than the authority publishes
21
- * would be precision this layer cannot source.
22
- *
23
- * A WHOLE CELL SKIPS THE LATTICE ENTIRELY, and that is exact rather than an optimization: a cell lying
24
- * wholly inside one delineation is covered by that delineation and by nothing else, so its distribution is
25
- * that map unit's component split and its `mapped_share` is 1.
26
- *
27
- * `mapped_share` EXISTS BECAUSE A SURVEY-AREA EDGE CELL IS PARTLY OUTSIDE EVERY DELINEATION. Without it,
28
- * the unmapped remainder would silently deflate every class share — an absence represented as a small
29
- * number, which is the one thing this schema exists to prevent. The five shares are normalized over the
30
- * mapped part, so they sum to 1 exactly, and `mapped_share` says how much of the cell that was.
31
- *
32
- * CLASS 8 IS A CLASS SHARE, NOT AN ABSENCE. It is a determination — the survey looked and rated the land
33
- * as precluding commercial plant production, and 67,547 national components carry it. Folding it in with
34
- * `NOTCOM`, a water body and an unrated series would produce a well-formed wrong answer, and separating
35
- * the four absences from the one positive negative is the whole reason this table has five columns rather
36
- * than one.
37
- */
38
-
39
- import { parseJSONStrict } from "@mailwoman/core/json"
40
- import { pointInEncodedRings, type H3Cell } from "@mailwoman/spatial"
41
- import { cellToChildren, cellToLatLng } from "h3-js"
42
-
43
- import type { SoilCapabilityCellTable, SoilComponentTable, SoilMapUnitTable } from "#schema"
44
- import { SOIL_SHARE_WEIGHTING } from "#vocabulary"
45
-
46
- /**
47
- * How many resolution levels finer than the index the weighting lattice runs.
48
- *
49
- * Two, giving 49 children per cell and a 2.04% share granularity. That is deliberately matched to the finest share the
50
- * authority itself publishes — `muaggatt.niccdcdpct`'s observed minimum is 2% — because a lattice finer than the
51
- * source's own reporting grain buys precision this layer cannot source, at 7× the cost per level.
52
- */
53
- export const WEIGHT_LATTICE_DEPTH = 2
54
-
55
- /**
56
- * Class shares below this are folded into `other_share` rather than stored.
57
- *
58
- * One percent, which sits BELOW the lattice's own 2.04% granularity, so nothing a single child cell produces is
59
- * truncated — what lands here is the long tail that component percentages create inside a child (a 1%-weight component
60
- * inside one child cell contributes 0.02%). Truncating a long tail is legitimate; doing it silently is not, which is
61
- * why the remainder is stored explicitly and the shares still sum to 1.
62
- */
63
- export const CLASS_SHARE_FLOOR = 0.01
64
-
65
- /**
66
- * One delineation reaching a cell, as the reduction needs it.
67
- */
68
- export interface CellCandidate {
69
- areaID: string
70
- mukey: string
71
- containment: string
72
- minLat: number
73
- minLon: number
74
- maxLat: number
75
- maxLon: number
76
- rings: Uint8Array
77
- }
78
-
79
- /**
80
- * What a map unit contributes per unit of area — computed once per map unit and reused for every cell it reaches.
81
- */
82
- export interface MapUnitProfile {
83
- /**
84
- * Class code → share of the map unit, summing to 1 across classes and the three mapped-absence buckets.
85
- */
86
- classShares: ReadonlyMap<string, number>
87
- unrated: number
88
- notRateable: number
89
- noData: number
90
- }
91
-
92
- /**
93
- * Turn one map unit and its components into the per-unit-area profile the reduction folds in.
94
- *
95
- * A `no_mapping` map unit contributes wholly to `nodata` and NEVER to a class: it is a polygon the authority drew with
96
- * no soil mapping behind it, and reading it as a low class would be the reassuring wrong number §3.2 of the survey is
97
- * about.
98
- *
99
- * The split across components is by `comppct_r`, the component's representative percentage of its map unit, normalized
100
- * by the total actually present rather than assumed to be 100 — measured on `IA153` all 152 map units sum to exactly
101
- * 100, and a national build must not depend on that holding everywhere.
102
- */
103
- export function mapUnitProfile(
104
- mapUnit: Pick<SoilMapUnitTable, "no_mapping">,
105
- components: ReadonlyArray<Pick<SoilComponentTable, "comppct_r" | "compkind" | "nirrcapcl">>
106
- ): MapUnitProfile {
107
- if (mapUnit.no_mapping) {
108
- return { classShares: new Map(), unrated: 0, notRateable: 0, noData: 1 }
109
- }
110
-
111
- let total = 0
112
-
113
- for (const component of components) {
114
- total += component.comppct_r
115
- }
116
-
117
- // A map unit whose components carry no weight at all publishes no readable proportion, so nothing can be apportioned
118
- // from it. It is marked `no_mapping` upstream for exactly this reason; reaching here with a zero total means the
119
- // upstream check and this one disagree, and answering with an empty distribution would silently drop the delineation's
120
- // area out of every share.
121
- if (total <= 0) {
122
- return { classShares: new Map(), unrated: 0, notRateable: 0, noData: 1 }
123
- }
124
-
125
- const classShares = new Map<string, number>()
126
-
127
- let unrated = 0
128
- let notRateable = 0
129
-
130
- for (const component of components) {
131
- const weight = component.comppct_r / total
132
-
133
- if (weight <= 0) continue
134
-
135
- if (component.nirrcapcl) {
136
- classShares.set(component.nirrcapcl, (classShares.get(component.nirrcapcl) ?? 0) + weight)
137
-
138
- continue
139
- }
140
-
141
- // A NULL rating means the survey did not rate this component, and WHY it did not is what separates the two buckets.
142
- // A miscellaneous area is a non-soil area — rock outcrop, water — that the capability rating does not apply to; a
143
- // named soil with no rating is one the survey chose not to rate. Read as one number they would both say "not
144
- // arable", which neither of them says.
145
- if (component.compkind === "Miscellaneous area") {
146
- notRateable += weight
147
- } else {
148
- unrated += weight
149
- }
150
- }
151
-
152
- return { classShares, unrated, notRateable, noData: 0 }
153
- }
154
-
155
- /**
156
- * What one cell's reduction produced, plus the diagnostics the receipt reports.
157
- */
158
- export interface ReducedCell {
159
- row: SoilCapabilityCellTable
160
- /**
161
- * True when the top class covers less than half the cell — the §4.7 number, counted here so it comes off the artifact
162
- * rather than out of a separate harness.
163
- */
164
- topClassUnderHalf: boolean
165
- /**
166
- * True when the lattice was used rather than the whole-cell fast path.
167
- */
168
- sampled: boolean
169
- }
170
-
171
- /**
172
- * Reduce one cell.
173
- *
174
- * @throws {Error} When a candidate names a map unit the profile map does not hold. A missing profile means the
175
- * attribute join is short, and answering with the remaining candidates would report a well-formed distribution over
176
- * part of the cell.
177
- */
178
- export function reduceCell(
179
- cell: H3Cell,
180
- resolution: number,
181
- candidates: ReadonlyArray<CellCandidate>,
182
- profiles: ReadonlyMap<string, MapUnitProfile>,
183
- h3Cell: number
184
- ): ReducedCell {
185
- const weights = new Map<string, number>()
186
- let sampled = false
187
- let mappedShare = 1
188
-
189
- const whole = candidates.length === 1 ? candidates.find((candidate) => candidate.containment === "whole") : undefined
190
-
191
- if (whole) {
192
- // Exactly one delineation, and it covers the cell entirely. Nothing else can reach it, so the lattice would return
193
- // the same answer at 49 times the cost.
194
- weights.set(whole.mukey, 1)
195
- } else {
196
- sampled = true
197
-
198
- const children = cellToChildren(cell, resolution + WEIGHT_LATTICE_DEPTH)
199
- let covered = 0
200
-
201
- for (const child of children) {
202
- const [latitude, longitude] = cellToLatLng(child)
203
- const owner = candidateAt(candidates, latitude, longitude)
204
-
205
- if (!owner) continue
206
-
207
- covered++
208
- weights.set(owner.mukey, (weights.get(owner.mukey) ?? 0) + 1)
209
- }
210
-
211
- if (!covered) {
212
- // Every child centre fell outside every delineation reaching the cell. The cell IS touched — the index says so —
213
- // but no lattice point landed inside, which happens when a sliver clips a corner. Reporting shares over nothing
214
- // would divide by zero; reporting a mapped share of zero is the truthful answer, and the row is dropped by the
215
- // caller rather than stored as an all-zero distribution.
216
- return {
217
- row: emptyRow(h3Cell, candidates.length),
218
- topClassUnderHalf: false,
219
- sampled,
220
- }
221
- }
222
-
223
- mappedShare = covered / children.length
224
-
225
- for (const [mukey, count] of weights) {
226
- weights.set(mukey, count / covered)
227
- }
228
- }
229
-
230
- return assembleRow(h3Cell, weights, profiles, mappedShare, candidates.length, sampled)
231
- }
232
-
233
- /**
234
- * The delineation covering a point, or `undefined` where none does.
235
- *
236
- * The bounding box is the prefilter the geometry table stores precisely so the ray cast runs on the few delineations
237
- * that could contain the point rather than on every delineation reaching the cell.
238
- */
239
- function candidateAt(
240
- candidates: ReadonlyArray<CellCandidate>,
241
- latitude: number,
242
- longitude: number
243
- ): CellCandidate | undefined {
244
- for (const candidate of candidates) {
245
- if (
246
- longitude < candidate.minLon ||
247
- longitude > candidate.maxLon ||
248
- latitude < candidate.minLat ||
249
- latitude > candidate.maxLat
250
- ) {
251
- continue
252
- }
253
-
254
- if (pointInEncodedRings(candidate.rings, longitude, latitude)) return candidate
255
- }
256
-
257
- return undefined
258
- }
259
-
260
- /**
261
- * Fold the per-map-unit weights through their profiles into the stored row.
262
- */
263
- function assembleRow(
264
- h3Cell: number,
265
- weights: ReadonlyMap<string, number>,
266
- profiles: ReadonlyMap<string, MapUnitProfile>,
267
- mappedShare: number,
268
- delineations: number,
269
- sampled: boolean
270
- ): ReducedCell {
271
- const classShares = new Map<string, number>()
272
-
273
- let unrated = 0
274
- let notRateable = 0
275
- let noData = 0
276
-
277
- for (const [mukey, weight] of weights) {
278
- const profile = profiles.get(mukey)
279
-
280
- if (!profile) {
281
- throw new Error(
282
- `soil reduce: cell ${h3Cell} names map unit ${mukey}, which the attribute join does not hold — a missing profile means the join is short, and reducing the remaining candidates would report a well-formed distribution over part of the cell`
283
- )
284
- }
285
-
286
- for (const [code, share] of profile.classShares) {
287
- classShares.set(code, (classShares.get(code) ?? 0) + share * weight)
288
- }
289
-
290
- unrated += profile.unrated * weight
291
- notRateable += profile.notRateable * weight
292
- noData += profile.noData * weight
293
- }
294
-
295
- // The floor truncates the long tail component percentages create inside a lattice child. The remainder is stored
296
- // rather than dropped, so the five shares sum to 1 and a reader can see how much was folded away.
297
- let other = 0
298
- const kept: Array<[string, number]> = []
299
-
300
- for (const [code, share] of classShares) {
301
- if (share < CLASS_SHARE_FLOOR) {
302
- other += share
303
- } else {
304
- kept.push([code, share])
305
- }
306
- }
307
-
308
- kept.sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : 1))
309
-
310
- const top = kept[0]
311
-
312
- return {
313
- row: {
314
- h3_cell: h3Cell,
315
- class_shares: JSON.stringify(Object.fromEntries(kept.map(([code, share]) => [code, round(share)]))),
316
- unrated_share: round(unrated),
317
- notrateable_share: round(notRateable),
318
- nodata_share: round(noData),
319
- other_share: round(other),
320
- mapped_share: round(mappedShare),
321
- top_class: top ? top[0] : null,
322
- top_class_share: top ? round(top[1]) : null,
323
- weighting: SOIL_SHARE_WEIGHTING,
324
- delineations,
325
- },
326
- topClassUnderHalf: !top || top[1] < 0.5,
327
- sampled,
328
- }
329
- }
330
-
331
- /**
332
- * A cell no lattice point landed inside. `mapped_share` zero says exactly that, and the caller drops it rather than
333
- * storing an all-zero distribution that would read as a surveyed cell holding nothing.
334
- */
335
- function emptyRow(h3Cell: number, delineations: number): SoilCapabilityCellTable {
336
- return {
337
- h3_cell: h3Cell,
338
- class_shares: "{}",
339
- unrated_share: 0,
340
- notrateable_share: 0,
341
- nodata_share: 0,
342
- other_share: 0,
343
- mapped_share: 0,
344
- top_class: null,
345
- top_class_share: null,
346
- weighting: SOIL_SHARE_WEIGHTING,
347
- delineations,
348
- }
349
- }
350
-
351
- /**
352
- * Six decimals — a millionth of a cell, far below the lattice's own 2% granularity, and enough that the stored shares
353
- * still sum to 1 within a rounding error a reader can see is rounding.
354
- */
355
- const SHARE_DECIMALS = 6
356
-
357
- function round(value: number): number {
358
- return Number(value.toFixed(SHARE_DECIMALS))
359
- }
360
-
361
- /**
362
- * The sum of a stored row's five shares. Exported because the invariant it checks — that they sum to 1 — is what makes
363
- * `other_share` required rather than decorative, and a test that could not state the sum could not pin it.
364
- */
365
- export function shareTotal(row: SoilCapabilityCellTable): number {
366
- const classes = parseJSONStrict<Record<string, number>>(row.class_shares)
367
-
368
- let total = row.unrated_share + row.notrateable_share + row.nodata_share + row.other_share
369
-
370
- for (const share of Object.values(classes)) {
371
- total += share
372
- }
373
-
374
- return total
375
- }
@@ -1,11 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
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.
9
- */
10
- export {};
11
- //# sourceMappingURL=ingest-chunk.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ingest-chunk.d.ts","sourceRoot":"","sources":["../../lib/scripts/ingest-chunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"ingest-chunk.js","sourceRoot":"","sources":["../../lib/scripts/ingest-chunk.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAA;AACtE,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,+CAA+C,CAAA;AAIxG,OAAO,EAAE,4BAA4B,EAAE,MAAM,aAAa,CAAA;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAEnD,MAAM,oBAAoB,CAAC;IAC1B,OAAO,EAAE,mBAAmB;IAC5B,OAAO,EAAE;QACR,GAAG,kBAAkB;QACrB,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC7B,aAAa,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QACjC,UAAU,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC9B,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;QAC5B,mBAAmB,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;KACvC;IACD,GAAG,EAAE,KAAK,EAAE,QAAsC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,CACpE,eAAe,CAAC,QAAQ,EAAE;QACzB,MAAM,EAAE,MAAM,4BAA4B,CAAC;YAC1C,aAAa,EAAE,gBAAgB,CAAC,mBAAmB,EAAE,WAAW,EAAE,MAAM,CAAC,SAAS,CAAC;YACnF,UAAU,EAAE,gBAAgB,CAAC,mBAAmB,EAAE,aAAa,EAAE,MAAM,CAAC,aAAa,CAAC,CAAC;YACvF,OAAO,EAAE,MAAM,CAAC,gBAAgB,CAAC,mBAAmB,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC;YACtF,KAAK,EAAE,MAAM,CAAC,gBAAgB,CAAC,mBAAmB,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAChF,+GAA+G;YAC/G,2GAA2G;YAC3G,oBAAoB,EAAE,CAAC;SACvB,CAAC;QACF,eAAe,EAAE,KAAK,CAAC,eAAe;QACtC,kBAAkB,EAAE,KAAK,CAAC,kBAAkB;QAC5C,kHAAkH;QAClH,yGAAyG;QACzG,eAAe,EAAE,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,mBAAmB,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QAC5G,UAAU,EAAE,KAAK,CAAC,UAAU;KAC5B,CAAC;CACH,CAAC,CAAA"}
@@ -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
- export * from "#sdk/acquire";
9
- export * from "#sdk/build-soil";
10
- export * from "#sdk/cell-tiers";
11
- export * from "#sdk/cells";
12
- export * from "#sdk/client";
13
- export * from "#sdk/download";
14
- export * from "#sdk/ingest";
15
- export * from "#sdk/measure-resolutions";
16
- export * from "#sdk/reduce";
17
- export * from "#sdk/survey-area";
18
- export * from "#sdk/tabular";
19
- export * from "#sdk/verify";
20
- //# sourceMappingURL=index.d.ts.map