@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/sdk/ingest.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { declaredFeatureCount } from "@mailwoman/core/layers"
|
|
8
|
+
import { assertRingsInsideExtent, requireArealPolygons, type MultiPolygonRings } from "@mailwoman/spatial"
|
|
9
|
+
import { readOGRLayerIdentity } from "@mailwoman/spatial/tools/ogr"
|
|
10
|
+
import { ogr2ogrGeoJSONSeq } from "@mailwoman/spatial/tools/ogr/stream"
|
|
11
|
+
import { basename, PathBuilder, type PathBuilderLike } from "path-ts"
|
|
12
|
+
|
|
13
|
+
import { SSURGO_SOURCE_EPSG } from "#vocabulary"
|
|
14
|
+
|
|
15
|
+
const COORDINATE_PRECISION = 9
|
|
16
|
+
|
|
17
|
+
const BBOX_MARGIN_DEGREES = 0.1
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns the path of the shapefile that holds a survey area's map-unit polygons.
|
|
21
|
+
*/
|
|
22
|
+
export function mapUnitShapefile(spatialDirectory: PathBuilderLike, areaSymbol: string): string {
|
|
23
|
+
return PathBuilder.from(spatialDirectory)(`soilmu_a_${areaSymbol.toLowerCase()}.shp`).toString()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the path of the shapefile holding a survey area's outline.
|
|
28
|
+
*
|
|
29
|
+
* The build takes the footprint from this outline because unrated map units such as `notcom` lie inside it.
|
|
30
|
+
* A union of rated polygons would leave them out.
|
|
31
|
+
*/
|
|
32
|
+
export function surveyAreaShapefile(spatialDirectory: PathBuilderLike, areaSymbol: string): PathBuilder {
|
|
33
|
+
return PathBuilder.from(spatialDirectory)(`soilsa_a_${areaSymbol.toLowerCase()}.shp`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One map-unit delineation, reprojected to WGS84.
|
|
38
|
+
*/
|
|
39
|
+
export interface SoilDelineation {
|
|
40
|
+
/**
|
|
41
|
+
* The delineation's identifier, formed as `<areasymbol>:<fid>`.
|
|
42
|
+
*/
|
|
43
|
+
areaID: string
|
|
44
|
+
mukey: string
|
|
45
|
+
areasymbol: string
|
|
46
|
+
polygons: MultiPolygonRings
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The metadata that a shapefile declares, read before any feature.
|
|
51
|
+
*/
|
|
52
|
+
export interface SoilSourceIdentity {
|
|
53
|
+
epsg: number
|
|
54
|
+
featureCount: number
|
|
55
|
+
layer: string
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The layer's declared extent as `[minLon, minLat, maxLon, maxLat]`.
|
|
59
|
+
*/
|
|
60
|
+
bbox: readonly [number, number, number, number]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Options for reading one SSURGO map-unit shapefile.
|
|
65
|
+
*/
|
|
66
|
+
export interface SoilIngestOptions {
|
|
67
|
+
shapefilePath: string
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The layer inside the shapefile.
|
|
71
|
+
*
|
|
72
|
+
* The default is the file's base name.
|
|
73
|
+
* The ESRI driver reports that name.
|
|
74
|
+
*/
|
|
75
|
+
layer?: string
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The EPSG code that the source must declare.
|
|
79
|
+
* The default is the SSURGO source projection.
|
|
80
|
+
*/
|
|
81
|
+
expectEPSG?: number
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The inclusive range of shapefile FIDs to read.
|
|
85
|
+
* A build can use it to read one chunk.
|
|
86
|
+
*/
|
|
87
|
+
fidFrom?: number
|
|
88
|
+
fidTo?: number
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The maximum number of features to read.
|
|
92
|
+
* Fixture and smoke runs use it.
|
|
93
|
+
*/
|
|
94
|
+
limit?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads the projection, feature count, layer name and extent that a shapefile declares.
|
|
99
|
+
*
|
|
100
|
+
* It throws when the shapefile declares a projection other than `expectEPSG`.
|
|
101
|
+
*/
|
|
102
|
+
export async function readSoilSourceIdentity(options: SoilIngestOptions): Promise<SoilSourceIdentity> {
|
|
103
|
+
const identity = await readOGRLayerIdentity({
|
|
104
|
+
path: options.shapefilePath,
|
|
105
|
+
layer: options.layer ?? basename(options.shapefilePath, ".shp"),
|
|
106
|
+
expectEPSG: options.expectEPSG ?? SSURGO_SOURCE_EPSG,
|
|
107
|
+
context: "soil ingest",
|
|
108
|
+
requireExtent: true,
|
|
109
|
+
messages: {
|
|
110
|
+
noAuthorityCode: "the projection cannot be checked, and reading one datum's coordinates as another's is silent",
|
|
111
|
+
epsgMismatch:
|
|
112
|
+
"SSURGO publishes geographic WGS84, so a different code is a product change rather than a variation to absorb",
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return { epsg: identity.epsg, featureCount: identity.featureCount, layer: identity.layer, bbox: identity.extent! }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function delineationSelectSQL(layer: string, options: SoilIngestOptions): string {
|
|
120
|
+
const select = `SELECT FID AS fid, MUKEY AS mukey, AREASYMBOL AS areasymbol FROM "${layer}"`
|
|
121
|
+
const bounds: string[] = []
|
|
122
|
+
|
|
123
|
+
if (options.fidFrom !== undefined) {
|
|
124
|
+
bounds.push(`FID >= ${options.fidFrom}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options.fidTo !== undefined) {
|
|
128
|
+
bounds.push(`FID <= ${options.fidTo}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return bounds.length ? `${select} WHERE ${bounds.join(" AND ")}` : select
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface RawFeature {
|
|
135
|
+
properties: { fid: number | string; mukey: number | string | null; areasymbol: string | null }
|
|
136
|
+
geometry: { type: string; coordinates: unknown } | null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Streams a shapefile's map-unit delineations reprojected to WGS84.
|
|
141
|
+
*
|
|
142
|
+
* It throws when a feature lacks geometry or `mukey`.
|
|
143
|
+
* It also throws when a vertex falls outside the declared extent.
|
|
144
|
+
*
|
|
145
|
+
* The extent check catches swapped coordinate axes that the projection check misses.
|
|
146
|
+
*/
|
|
147
|
+
export async function* readSoilDelineations(
|
|
148
|
+
options: SoilIngestOptions & { bbox: readonly [number, number, number, number] }
|
|
149
|
+
): AsyncGenerator<SoilDelineation> {
|
|
150
|
+
const layer = options.layer ?? basename(options.shapefilePath, ".shp")
|
|
151
|
+
const [minLon, minLat, maxLon, maxLat] = options.bbox
|
|
152
|
+
|
|
153
|
+
const args = [
|
|
154
|
+
"-f",
|
|
155
|
+
"GeoJSONSeq",
|
|
156
|
+
"/vsistdout/",
|
|
157
|
+
|
|
158
|
+
"-t_srs",
|
|
159
|
+
"EPSG:4326",
|
|
160
|
+
"-lco",
|
|
161
|
+
`COORDINATE_PRECISION=${COORDINATE_PRECISION}`,
|
|
162
|
+
...(options.limit === undefined ? [] : ["-limit", String(options.limit)]),
|
|
163
|
+
"-sql",
|
|
164
|
+
delineationSelectSQL(layer, options),
|
|
165
|
+
options.shapefilePath,
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
for await (const raw of ogr2ogrGeoJSONSeq<RawFeature>(args, "soil ingest")) {
|
|
169
|
+
yield toDelineation(raw, { minLon, minLat, maxLon, maxLat })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toDelineation(
|
|
174
|
+
raw: RawFeature,
|
|
175
|
+
extent: { minLon: number; minLat: number; maxLon: number; maxLat: number }
|
|
176
|
+
): SoilDelineation {
|
|
177
|
+
const { properties, geometry } = raw
|
|
178
|
+
|
|
179
|
+
if (!geometry) {
|
|
180
|
+
throw new Error(`soil ingest: delineation ${properties.fid} carries no geometry`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (properties.mukey === null || properties.mukey === "") {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`soil ingest: delineation ${properties.fid} carries no MUKEY — a delineation with no map unit joins to nothing and would read downstream as unmapped ground`
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!properties.areasymbol) {
|
|
190
|
+
throw new Error(`soil ingest: delineation ${properties.fid} carries no AREASYMBOL`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const polygons = requireArealPolygons(geometry, `delineation ${properties.fid}`, "soil ingest")
|
|
194
|
+
|
|
195
|
+
assertRingsInsideExtent(polygons, `delineation ${properties.fid}`, extent, BBOX_MARGIN_DEGREES, "soil ingest")
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
areaID: `${properties.areasymbol}:${properties.fid}`,
|
|
199
|
+
mukey: String(properties.mukey),
|
|
200
|
+
areasymbol: properties.areasymbol,
|
|
201
|
+
polygons,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Supplies a soil build with its delineations and the source's declared identity.
|
|
207
|
+
*
|
|
208
|
+
* The builder takes this instead of a path so that fixtures can run the database
|
|
209
|
+
* build without network access or GDAL.
|
|
210
|
+
*/
|
|
211
|
+
export interface SoilFeatureSource {
|
|
212
|
+
areaSymbol: string
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The feature count that the source declares.
|
|
216
|
+
*
|
|
217
|
+
* The build throws when its streamed total differs from this count.
|
|
218
|
+
*/
|
|
219
|
+
declaredFeatureCount: number
|
|
220
|
+
layer: string
|
|
221
|
+
epsg: number
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A description of where the delineations came from, such as the shapefile path.
|
|
225
|
+
*/
|
|
226
|
+
origin: string
|
|
227
|
+
delineations: () => AsyncIterable<SoilDelineation>
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Creates a {@link SoilFeatureSource} for one survey area's shapefile.
|
|
232
|
+
*
|
|
233
|
+
* It reads the shapefile's identity immediately and streams features when asked.
|
|
234
|
+
*/
|
|
235
|
+
export async function createShapefileFeatureSource(
|
|
236
|
+
options: SoilIngestOptions & { areaSymbol: string; declaredFeatureCount?: number }
|
|
237
|
+
): Promise<SoilFeatureSource> {
|
|
238
|
+
const identity = await readSoilSourceIdentity(options)
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
areaSymbol: options.areaSymbol,
|
|
242
|
+
|
|
243
|
+
declaredFeatureCount: declaredFeatureCount({
|
|
244
|
+
declared: options.declaredFeatureCount,
|
|
245
|
+
limit: options.limit,
|
|
246
|
+
layerCount: identity.featureCount,
|
|
247
|
+
}),
|
|
248
|
+
layer: identity.layer,
|
|
249
|
+
epsg: identity.epsg,
|
|
250
|
+
origin: options.shapefilePath,
|
|
251
|
+
delineations: () => readSoilDelineations({ ...options, bbox: identity.bbox }),
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -2,19 +2,6 @@
|
|
|
2
2
|
* @copyright Sister Software
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
|
-
*
|
|
6
|
-
* The index resolution is a MEASUREMENT this layer takes, not a number argued to.
|
|
7
|
-
*
|
|
8
|
-
* ONE STREAM, EVERY RESOLUTION. Re-reading a survey area's shapefile per candidate buys nothing — the
|
|
9
|
-
* classification is per delineation, so every candidate index folds the same delineation in turn. The cost
|
|
10
|
-
* is memory: each resolution holds its own cell sets, and the finest candidate dominates.
|
|
11
|
-
*
|
|
12
|
-
* THIS INSTRUMENT REPORTS THE FIRST OF THE TWO NUMBERS §4.7 NAMES — the `partial` cell share, plus the mean
|
|
13
|
-
* delineations per cell that drives it. The SECOND number, the share of cells whose top class holds less
|
|
14
|
-
* than half the cell, is not measurable here: it needs the attribute join and the area weighting, which
|
|
15
|
-
* are the build. So it comes off the SHIPPING ARTIFACT instead — {@linkcode buildSoilDatabase} counts it
|
|
16
|
-
* while it writes the rows, and the build receipt reports it. That is the flood layer's lesson applied:
|
|
17
|
-
* the number that describes the artifact is the one taken from the artifact.
|
|
18
5
|
*/
|
|
19
6
|
|
|
20
7
|
import type { ResolutionMeasurementOptions } from "@mailwoman/core/layers"
|
|
@@ -27,7 +14,8 @@ export interface MeasureSoilResolutionsOptions extends SoilIngestOptions, Resolu
|
|
|
27
14
|
export interface SoilResolutionReport {
|
|
28
15
|
delineations: number
|
|
29
16
|
/**
|
|
30
|
-
* The count the shapefile declares for itself.
|
|
17
|
+
* The count the shapefile declares for itself.
|
|
18
|
+
* A streamed total that differs read a truncated file.
|
|
31
19
|
*/
|
|
32
20
|
declaredFeatureCount: number
|
|
33
21
|
measurements: SoilCellIndexMeasurement[]
|
|
@@ -38,8 +26,9 @@ const DEFAULT_PROGRESS_EVERY = 5000
|
|
|
38
26
|
/**
|
|
39
27
|
* Measure every candidate resolution over one survey area's real delineations.
|
|
40
28
|
*
|
|
41
|
-
* @throws {Error} When the streamed count does not match the count the shapefile declares.
|
|
42
|
-
*
|
|
29
|
+
* @throws {Error} When the streamed count does not match the count the shapefile declares.
|
|
30
|
+
* A short read produces a well-formed table describing a smaller county.
|
|
31
|
+
* which is the partial result that must throw.
|
|
43
32
|
*/
|
|
44
33
|
export async function measureSoilCellResolutions(
|
|
45
34
|
options: MeasureSoilResolutionsOptions
|
package/sdk/reduce.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Reduces the delineations that reach a cell into that cell's capability-class distribution.
|
|
7
|
+
*
|
|
8
|
+
* The result is a distribution because most map units mix several soil components.
|
|
9
|
+
* The survey cannot separate those components at its mapping scale. Shares are normalized over the mapped part of the cell.
|
|
10
|
+
* `mapped_share` records how large that part is. Capability class 8 is a rated class, so it is stored as a
|
|
11
|
+
* class share. The unrated share, the no-data share and the not-rateable share each have their own column.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseJSONStrict, stringifyJSON } from "@mailwoman/core/json"
|
|
15
|
+
import { pointInEncodedRings, type H3Cell } from "@mailwoman/spatial"
|
|
16
|
+
import { cellToChildren, cellToLatLng } from "h3-js"
|
|
17
|
+
|
|
18
|
+
import type { SoilCapabilityCellTable, SoilComponentTable, SoilMapUnitTable } from "#schema"
|
|
19
|
+
import { SOIL_SHARE_WEIGHTING } from "#vocabulary"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The number of H3 levels below the index resolution at which the weighting lattice samples.
|
|
23
|
+
*
|
|
24
|
+
* Children at a finer resolution have equal area, so counting which delineation
|
|
25
|
+
* covers each child center estimates the covered area.
|
|
26
|
+
* A depth of 2 gives 49 children, or about 2% per child.
|
|
27
|
+
*
|
|
28
|
+
* NRCS publishes component shares no finer than 2%, and each extra level costs seven times as much.
|
|
29
|
+
*/
|
|
30
|
+
export const WEIGHT_LATTICE_DEPTH = 2
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The class share below which a class is added to `other_share` instead of stored.
|
|
34
|
+
*
|
|
35
|
+
* The floor is below the lattice's 2% step, so it only removes the small shares
|
|
36
|
+
* that minor components contribute.
|
|
37
|
+
* The reducer adds them to `other_share` so all shares sum to 1.
|
|
38
|
+
*/
|
|
39
|
+
export const CLASS_SHARE_FLOOR = 0.01
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One delineation that reaches a cell.
|
|
43
|
+
*/
|
|
44
|
+
export interface CellCandidate {
|
|
45
|
+
areaID: string
|
|
46
|
+
mukey: string
|
|
47
|
+
containment: string
|
|
48
|
+
minLat: number
|
|
49
|
+
minLon: number
|
|
50
|
+
maxLat: number
|
|
51
|
+
maxLon: number
|
|
52
|
+
rings: Uint8Array
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The shares that a map unit contributes per unit of area.
|
|
57
|
+
*/
|
|
58
|
+
export interface MapUnitProfile {
|
|
59
|
+
/**
|
|
60
|
+
* Each class code's share of the map unit.
|
|
61
|
+
* These shares and the three absence shares sum to 1.
|
|
62
|
+
*/
|
|
63
|
+
classShares: ReadonlyMap<string, number>
|
|
64
|
+
unrated: number
|
|
65
|
+
notRateable: number
|
|
66
|
+
noData: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Builds the profile of one map unit from its components.
|
|
71
|
+
*
|
|
72
|
+
* A `no_mapping` map unit contributes only to `noData`, because it has no soil mapping.
|
|
73
|
+
*
|
|
74
|
+
* Components are weighted by `comppct_r`, the component's representative percentage of its map unit.
|
|
75
|
+
* The weights are divided by their actual total, because the percentages may not sum to 100.
|
|
76
|
+
*/
|
|
77
|
+
export function mapUnitProfile(
|
|
78
|
+
mapUnit: Pick<SoilMapUnitTable, "no_mapping">,
|
|
79
|
+
components: ReadonlyArray<Pick<SoilComponentTable, "comppct_r" | "compkind" | "nirrcapcl">>
|
|
80
|
+
): MapUnitProfile {
|
|
81
|
+
if (mapUnit.no_mapping) {
|
|
82
|
+
return { classShares: new Map(), unrated: 0, notRateable: 0, noData: 1 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let total = 0
|
|
86
|
+
|
|
87
|
+
for (const component of components) {
|
|
88
|
+
total += component.comppct_r
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Components with zero total weight give no proportions, so the map unit counts as no data.
|
|
92
|
+
// An empty distribution would drop the delineation's area from every share.
|
|
93
|
+
if (total <= 0) {
|
|
94
|
+
return { classShares: new Map(), unrated: 0, notRateable: 0, noData: 1 }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const classShares = new Map<string, number>()
|
|
98
|
+
|
|
99
|
+
let unrated = 0
|
|
100
|
+
let notRateable = 0
|
|
101
|
+
|
|
102
|
+
for (const component of components) {
|
|
103
|
+
const weight = component.comppct_r / total
|
|
104
|
+
|
|
105
|
+
if (weight <= 0) continue
|
|
106
|
+
|
|
107
|
+
if (component.nirrcapcl) {
|
|
108
|
+
classShares.set(component.nirrcapcl, (classShares.get(component.nirrcapcl) ?? 0) + weight)
|
|
109
|
+
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A miscellaneous area, such as rock outcrop or water, cannot take a capability rating.
|
|
114
|
+
// Any other component with a NULL rating is a soil that the survey did not rate.
|
|
115
|
+
if (component.compkind === "Miscellaneous area") {
|
|
116
|
+
notRateable += weight
|
|
117
|
+
} else {
|
|
118
|
+
unrated += weight
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { classShares, unrated, notRateable, noData: 0 }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The stored row for one cell, plus diagnostics for the build report.
|
|
127
|
+
*/
|
|
128
|
+
export interface ReducedCell {
|
|
129
|
+
row: SoilCapabilityCellTable
|
|
130
|
+
/**
|
|
131
|
+
* Whether the top class covers less than half the cell.
|
|
132
|
+
*/
|
|
133
|
+
topClassUnderHalf: boolean
|
|
134
|
+
/**
|
|
135
|
+
* Whether the lattice was used instead of the whole-cell fast path.
|
|
136
|
+
*/
|
|
137
|
+
sampled: boolean
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reduces one cell.
|
|
142
|
+
*
|
|
143
|
+
* @throws {Error} When a candidate's map unit has no profile.
|
|
144
|
+
* A missing profile means the attribute join is incomplete.
|
|
145
|
+
* The remaining candidates would describe only part of the cell.
|
|
146
|
+
*/
|
|
147
|
+
export function reduceCell(
|
|
148
|
+
cell: H3Cell,
|
|
149
|
+
resolution: number,
|
|
150
|
+
candidates: ReadonlyArray<CellCandidate>,
|
|
151
|
+
profiles: ReadonlyMap<string, MapUnitProfile>,
|
|
152
|
+
h3Cell: number
|
|
153
|
+
): ReducedCell {
|
|
154
|
+
const weights = new Map<string, number>()
|
|
155
|
+
let sampled = false
|
|
156
|
+
let mappedShare = 1
|
|
157
|
+
|
|
158
|
+
const whole = candidates.length === 1 ? candidates.find((candidate) => candidate.containment === "whole") : undefined
|
|
159
|
+
|
|
160
|
+
if (whole) {
|
|
161
|
+
// A single delineation covers the whole cell, so the lattice would give the same answer.
|
|
162
|
+
weights.set(whole.mukey, 1)
|
|
163
|
+
} else {
|
|
164
|
+
sampled = true
|
|
165
|
+
|
|
166
|
+
const children = cellToChildren(cell, resolution + WEIGHT_LATTICE_DEPTH)
|
|
167
|
+
let covered = 0
|
|
168
|
+
|
|
169
|
+
for (const child of children) {
|
|
170
|
+
const [latitude, longitude] = cellToLatLng(child)
|
|
171
|
+
const owner = candidateAt(candidates, latitude, longitude)
|
|
172
|
+
|
|
173
|
+
if (!owner) continue
|
|
174
|
+
|
|
175
|
+
covered++
|
|
176
|
+
weights.set(owner.mukey, (weights.get(owner.mukey) ?? 0) + 1)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!covered) {
|
|
180
|
+
// No child center fell inside a delineation.
|
|
181
|
+
// A sliver can clip only a corner.
|
|
182
|
+
// This row has a mapped share of zero.
|
|
183
|
+
// The caller drops it.
|
|
184
|
+
return {
|
|
185
|
+
row: emptyRow(h3Cell, candidates.length),
|
|
186
|
+
topClassUnderHalf: false,
|
|
187
|
+
sampled,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
mappedShare = covered / children.length
|
|
192
|
+
|
|
193
|
+
for (const [mukey, count] of weights) {
|
|
194
|
+
weights.set(mukey, count / covered)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return assembleRow(h3Cell, weights, profiles, mappedShare, candidates.length, sampled)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns the delineation that covers a point, or `undefined` when none does.
|
|
203
|
+
*
|
|
204
|
+
* A bounding-box test runs first so that the ray cast runs only on delineations
|
|
205
|
+
* that could contain the point.
|
|
206
|
+
*/
|
|
207
|
+
function candidateAt(
|
|
208
|
+
candidates: ReadonlyArray<CellCandidate>,
|
|
209
|
+
latitude: number,
|
|
210
|
+
longitude: number
|
|
211
|
+
): CellCandidate | undefined {
|
|
212
|
+
for (const candidate of candidates) {
|
|
213
|
+
if (
|
|
214
|
+
longitude < candidate.minLon ||
|
|
215
|
+
longitude > candidate.maxLon ||
|
|
216
|
+
latitude < candidate.minLat ||
|
|
217
|
+
latitude > candidate.maxLat
|
|
218
|
+
) {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (pointInEncodedRings(candidate.rings, longitude, latitude)) return candidate
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Combines the per-map-unit weights with their profiles into the stored row.
|
|
230
|
+
*/
|
|
231
|
+
function assembleRow(
|
|
232
|
+
h3Cell: number,
|
|
233
|
+
weights: ReadonlyMap<string, number>,
|
|
234
|
+
profiles: ReadonlyMap<string, MapUnitProfile>,
|
|
235
|
+
mappedShare: number,
|
|
236
|
+
delineations: number,
|
|
237
|
+
sampled: boolean
|
|
238
|
+
): ReducedCell {
|
|
239
|
+
const classShares = new Map<string, number>()
|
|
240
|
+
|
|
241
|
+
let unrated = 0
|
|
242
|
+
let notRateable = 0
|
|
243
|
+
let noData = 0
|
|
244
|
+
|
|
245
|
+
for (const [mukey, weight] of weights) {
|
|
246
|
+
const profile = profiles.get(mukey)
|
|
247
|
+
|
|
248
|
+
if (!profile) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`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`
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const [code, share] of profile.classShares) {
|
|
255
|
+
classShares.set(code, (classShares.get(code) ?? 0) + share * weight)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
unrated += profile.unrated * weight
|
|
259
|
+
notRateable += profile.notRateable * weight
|
|
260
|
+
noData += profile.noData * weight
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Classes below the floor go into `other_share` so that the shares still sum to 1.
|
|
264
|
+
let other = 0
|
|
265
|
+
const kept: Array<[string, number]> = []
|
|
266
|
+
|
|
267
|
+
for (const [code, share] of classShares) {
|
|
268
|
+
if (share < CLASS_SHARE_FLOOR) {
|
|
269
|
+
other += share
|
|
270
|
+
} else {
|
|
271
|
+
kept.push([code, share])
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
kept.sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : 1))
|
|
276
|
+
|
|
277
|
+
const top = kept[0]
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
row: {
|
|
281
|
+
h3_cell: h3Cell,
|
|
282
|
+
class_shares: stringifyJSON(Object.fromEntries(kept.map(([code, share]) => [code, round(share)]))),
|
|
283
|
+
unrated_share: round(unrated),
|
|
284
|
+
notrateable_share: round(notRateable),
|
|
285
|
+
nodata_share: round(noData),
|
|
286
|
+
other_share: round(other),
|
|
287
|
+
mapped_share: round(mappedShare),
|
|
288
|
+
top_class: top ? top[0] : null,
|
|
289
|
+
top_class_share: top ? round(top[1]) : null,
|
|
290
|
+
weighting: SOIL_SHARE_WEIGHTING,
|
|
291
|
+
delineations,
|
|
292
|
+
},
|
|
293
|
+
topClassUnderHalf: !top || top[1] < 0.5,
|
|
294
|
+
sampled,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Returns the row for a cell that no lattice point landed in.
|
|
300
|
+
*
|
|
301
|
+
* The caller drops a row with a zero `mapped_share`, because storing it would
|
|
302
|
+
* look like a surveyed cell with no soil.
|
|
303
|
+
*/
|
|
304
|
+
function emptyRow(h3Cell: number, delineations: number): SoilCapabilityCellTable {
|
|
305
|
+
return {
|
|
306
|
+
h3_cell: h3Cell,
|
|
307
|
+
class_shares: "{}",
|
|
308
|
+
unrated_share: 0,
|
|
309
|
+
notrateable_share: 0,
|
|
310
|
+
nodata_share: 0,
|
|
311
|
+
other_share: 0,
|
|
312
|
+
mapped_share: 0,
|
|
313
|
+
top_class: null,
|
|
314
|
+
top_class_share: null,
|
|
315
|
+
weighting: SOIL_SHARE_WEIGHTING,
|
|
316
|
+
delineations,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The number of decimals kept in a stored share, far finer than the lattice's 2% step.
|
|
322
|
+
*/
|
|
323
|
+
const SHARE_DECIMALS = 6
|
|
324
|
+
|
|
325
|
+
function round(value: number): number {
|
|
326
|
+
return Number(value.toFixed(SHARE_DECIMALS))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Returns the sum of a stored row's class shares and four other shares.
|
|
331
|
+
*
|
|
332
|
+
* Tests use it to check that the shares sum to 1.
|
|
333
|
+
*/
|
|
334
|
+
export function shareTotal(row: SoilCapabilityCellTable): number {
|
|
335
|
+
const classes = parseJSONStrict<Record<string, number>>(row.class_shares)
|
|
336
|
+
|
|
337
|
+
let total = row.unrated_share + row.notrateable_share + row.nodata_share + row.other_share
|
|
338
|
+
|
|
339
|
+
for (const share of Object.values(classes)) {
|
|
340
|
+
total += share
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return total
|
|
344
|
+
}
|