@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/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. A run whose streamed total differs read a truncated file.
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. A short read produces a
42
- * well-formed table describing a smaller county, which is the partial result that must throw.
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
+ }