@mailwoman/soil 9.2.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 +221 -0
- package/lib/index.ts +474 -0
- package/lib/schema.ts +493 -0
- package/lib/scripts/ingest-chunk.ts +47 -0
- package/lib/sdk/acquire.ts +124 -0
- package/lib/sdk/build-soil.ts +778 -0
- package/lib/sdk/cell-tiers.ts +310 -0
- package/lib/sdk/cells.ts +224 -0
- package/lib/sdk/client.ts +273 -0
- package/lib/sdk/download.ts +161 -0
- package/lib/sdk/index.ts +20 -0
- package/lib/sdk/ingest-chunk.ts +182 -0
- package/lib/sdk/ingest.ts +271 -0
- package/lib/sdk/measure-resolutions.ts +81 -0
- package/lib/sdk/reduce.ts +375 -0
- package/lib/sdk/survey-area.ts +467 -0
- package/lib/sdk/tabular.ts +296 -0
- package/lib/sdk/verify.ts +369 -0
- package/lib/test-kit.ts +255 -0
- package/lib/vocabulary.ts +241 -0
- package/out/index.d.ts +199 -0
- package/out/index.d.ts.map +1 -0
- package/out/index.js +255 -0
- package/out/index.js.map +1 -0
- package/out/schema.d.ts +369 -0
- package/out/schema.d.ts.map +1 -0
- package/out/schema.js +186 -0
- package/out/schema.js.map +1 -0
- package/out/scripts/ingest-chunk.d.ts +11 -0
- package/out/scripts/ingest-chunk.d.ts.map +1 -0
- package/out/scripts/ingest-chunk.js +42 -0
- package/out/scripts/ingest-chunk.js.map +1 -0
- package/out/sdk/acquire.d.ts +60 -0
- package/out/sdk/acquire.d.ts.map +1 -0
- package/out/sdk/acquire.js +74 -0
- package/out/sdk/acquire.js.map +1 -0
- package/out/sdk/build-soil.d.ts +197 -0
- package/out/sdk/build-soil.d.ts.map +1 -0
- package/out/sdk/build-soil.js +434 -0
- package/out/sdk/build-soil.js.map +1 -0
- package/out/sdk/cell-tiers.d.ts +47 -0
- package/out/sdk/cell-tiers.d.ts.map +1 -0
- package/out/sdk/cell-tiers.js +194 -0
- package/out/sdk/cell-tiers.js.map +1 -0
- package/out/sdk/cells.d.ts +118 -0
- package/out/sdk/cells.d.ts.map +1 -0
- package/out/sdk/cells.js +140 -0
- package/out/sdk/cells.js.map +1 -0
- package/out/sdk/client.d.ts +120 -0
- package/out/sdk/client.d.ts.map +1 -0
- package/out/sdk/client.js +214 -0
- package/out/sdk/client.js.map +1 -0
- package/out/sdk/download.d.ts +84 -0
- package/out/sdk/download.d.ts.map +1 -0
- package/out/sdk/download.js +117 -0
- package/out/sdk/download.js.map +1 -0
- package/out/sdk/index.d.ts +20 -0
- package/out/sdk/index.d.ts.map +1 -0
- package/out/sdk/index.js +20 -0
- package/out/sdk/index.js.map +1 -0
- package/out/sdk/ingest-chunk.d.ts +73 -0
- package/out/sdk/ingest-chunk.d.ts.map +1 -0
- package/out/sdk/ingest-chunk.js +98 -0
- package/out/sdk/ingest-chunk.js.map +1 -0
- package/out/sdk/ingest.d.ts +132 -0
- package/out/sdk/ingest.d.ts.map +1 -0
- package/out/sdk/ingest.js +163 -0
- package/out/sdk/ingest.js.map +1 -0
- package/out/sdk/measure-resolutions.d.ts +39 -0
- package/out/sdk/measure-resolutions.d.ts.map +1 -0
- package/out/sdk/measure-resolutions.js +52 -0
- package/out/sdk/measure-resolutions.js.map +1 -0
- package/out/sdk/reduce.d.ts +122 -0
- package/out/sdk/reduce.d.ts.map +1 -0
- package/out/sdk/reduce.js +266 -0
- package/out/sdk/reduce.js.map +1 -0
- package/out/sdk/survey-area.d.ts +105 -0
- package/out/sdk/survey-area.d.ts.map +1 -0
- package/out/sdk/survey-area.js +346 -0
- package/out/sdk/survey-area.js.map +1 -0
- package/out/sdk/tabular.d.ts +106 -0
- package/out/sdk/tabular.d.ts.map +1 -0
- package/out/sdk/tabular.js +194 -0
- package/out/sdk/tabular.js.map +1 -0
- package/out/sdk/verify.d.ts +135 -0
- package/out/sdk/verify.d.ts.map +1 -0
- package/out/sdk/verify.js +221 -0
- package/out/sdk/verify.js.map +1 -0
- package/out/test-kit.d.ts +80 -0
- package/out/test-kit.d.ts.map +1 -0
- package/out/test-kit.js +228 -0
- package/out/test-kit.js.map +1 -0
- package/out/vocabulary.d.ts +179 -0
- package/out/vocabulary.d.ts.map +1 -0
- package/out/vocabulary.js +215 -0
- package/out/vocabulary.js.map +1 -0
- package/package.json +276 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The two phases between the streamed touches and the artifact a consumer reads: the stored containment
|
|
7
|
+
* index, and the reduction above it.
|
|
8
|
+
*
|
|
9
|
+
* BOTH READ THE TOUCH TABLE, AND ONLY ONE OF THEM COMPACTS. `resolveCells` writes the tiers a probe walks
|
|
10
|
+
* and collapses uniform interiors parent-ward; `reduceCells` reads the UNCOMPACTED touches, because a
|
|
11
|
+
* compacted parent no longer names the cells the reduction has to answer.
|
|
12
|
+
*
|
|
13
|
+
* THE REDUCTION IS THE SLOW PHASE AND ITS MEMORY IS BOUNDED BY CONSTRUCTION. A lattice of 49 point tests
|
|
14
|
+
* per sampled cell, over a delineation cache that is cleared whole rather than evicted one entry at a
|
|
15
|
+
* time — see {@link GEOMETRY_CACHE_ENTRIES}. Memory stays flat in row count, which is the property the poi
|
|
16
|
+
* build lost when a reader materialized instead of streaming.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { expandShortCellInt, shortCellToInt, type H3Cell } from "@mailwoman/spatial"
|
|
20
|
+
import { beginBatched } from "@mailwoman/sqlite/batched"
|
|
21
|
+
import type { DatabaseClient } from "@mailwoman/sqlite/client"
|
|
22
|
+
import { compactCells, getResolution } from "h3-js"
|
|
23
|
+
|
|
24
|
+
import { SoilCellContainment, type SoilCapabilityCellTable, type SoilDatabase } from "#schema"
|
|
25
|
+
import { mapUnitProfile, reduceCell, type CellCandidate, type MapUnitProfile } from "#sdk/reduce"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the touch table into the stored containment index.
|
|
29
|
+
*
|
|
30
|
+
* Compaction happens HERE and only on the whole side. It is expected to yield close to nothing on this layer, which is
|
|
31
|
+
* the inversion the survey predicts: compaction needs a uniform interior, and 85.4% of `IA153`'s delineations are
|
|
32
|
+
* smaller than one resolution-9 cell.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveCells(
|
|
35
|
+
database: DatabaseClient<SoilDatabase>,
|
|
36
|
+
indexResolution: number
|
|
37
|
+
): { wholeRows: number; partialRows: number; resolutions: number[] } {
|
|
38
|
+
database.exec("CREATE INDEX build_cell_touch_area_cell ON build_cell_touch (area_id, resolution, is_full, h3_cell)")
|
|
39
|
+
|
|
40
|
+
const groups = database
|
|
41
|
+
.prepare("SELECT DISTINCT area_id, resolution FROM build_cell_touch WHERE is_full = 1")
|
|
42
|
+
.all() as Array<{ area_id: string; resolution: number }>
|
|
43
|
+
|
|
44
|
+
const insertCell = database.prepare(
|
|
45
|
+
"INSERT OR REPLACE INTO soil_map_unit_cell (h3_cell, resolution, area_id, containment) VALUES (?, ?, ?, ?)"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const resolutions = new Set<number>()
|
|
49
|
+
|
|
50
|
+
let wholeRows = 0
|
|
51
|
+
|
|
52
|
+
// One group per (delineation, resolution): `compactCells` takes a single resolution, and an adaptively-indexed layer
|
|
53
|
+
// has several. Pooling them throws; compacting only the target-resolution group would silently drop every coarsened
|
|
54
|
+
// delineation's interior.
|
|
55
|
+
database.exec("BEGIN")
|
|
56
|
+
|
|
57
|
+
for (const { area_id: areaID, resolution } of groups) {
|
|
58
|
+
const whole = database
|
|
59
|
+
.prepare("SELECT DISTINCT h3_cell FROM build_cell_touch WHERE area_id = ? AND resolution = ? AND is_full = 1")
|
|
60
|
+
.all(areaID, resolution) as Array<{ h3_cell: number }>
|
|
61
|
+
|
|
62
|
+
for (const cell of compactCells(whole.map((row) => expandShortCellInt(row.h3_cell, resolution)))) {
|
|
63
|
+
const cellResolution = getResolution(cell)
|
|
64
|
+
|
|
65
|
+
resolutions.add(cellResolution)
|
|
66
|
+
insertCell.run(shortCellToInt(cell as H3Cell), cellResolution, areaID, SoilCellContainment.Whole)
|
|
67
|
+
|
|
68
|
+
wholeRows++
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
database.exec("COMMIT")
|
|
73
|
+
|
|
74
|
+
// The partial rows are every touch that is not whole for its own delineation. `INSERT OR REPLACE` above already put
|
|
75
|
+
// the whole rows in, and the primary key is `(h3_cell, area_id)`, so this insert must skip them explicitly rather
|
|
76
|
+
// than rely on the key: a partial row replacing a whole one would demote an answered cell to a ray cast.
|
|
77
|
+
database.exec(
|
|
78
|
+
"INSERT OR IGNORE INTO soil_map_unit_cell (h3_cell, resolution, area_id, containment) " +
|
|
79
|
+
"SELECT DISTINCT t.h3_cell, t.resolution, t.area_id, 'partial' FROM build_cell_touch t WHERE t.is_full = 0"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const partialRows = (
|
|
83
|
+
database.prepare("SELECT count(*) AS n FROM soil_map_unit_cell WHERE containment = 'partial'").get() as {
|
|
84
|
+
n: number
|
|
85
|
+
}
|
|
86
|
+
).n
|
|
87
|
+
|
|
88
|
+
for (const row of database.prepare("SELECT DISTINCT resolution FROM soil_map_unit_cell").all() as Array<{
|
|
89
|
+
resolution: number
|
|
90
|
+
}>) {
|
|
91
|
+
resolutions.add(row.resolution)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
resolutions.add(indexResolution)
|
|
95
|
+
|
|
96
|
+
return { wholeRows, partialRows, resolutions: [...resolutions].toSorted((left, right) => left - right) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Cells reduced per progress report. The reduction is the slow phase — a lattice of 49 point tests per sampled cell —
|
|
101
|
+
* so it reports often enough that a long run is visibly alive.
|
|
102
|
+
*/
|
|
103
|
+
const REDUCE_PROGRESS_STRIDE = 50_000
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One delineation's geometry, as the reduction reads it.
|
|
107
|
+
*/
|
|
108
|
+
interface StoredDelineation {
|
|
109
|
+
mukey: string
|
|
110
|
+
min_lat: number
|
|
111
|
+
min_lon: number
|
|
112
|
+
max_lat: number
|
|
113
|
+
max_lon: number
|
|
114
|
+
rings: Uint8Array
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* How many delineations the reduction keeps in memory at once.
|
|
119
|
+
*
|
|
120
|
+
* Sized to bound the phase rather than to hold everything: the pilot region's median delineation encodes to roughly 1.4
|
|
121
|
+
* kB, so 200,000 of them is a few hundred megabytes — comfortable, and far below the 2.5 million a whole state holds.
|
|
122
|
+
* Memory stays flat in row count, which is the property the poi build lost when a reader materialized instead of
|
|
123
|
+
* streaming.
|
|
124
|
+
*/
|
|
125
|
+
const GEOMETRY_CACHE_ENTRIES = 200_000
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reduce the touch table into `soil_capability_cell`.
|
|
129
|
+
*
|
|
130
|
+
* Reads the touch table rather than `soil_map_unit_cell` on purpose: the stored index is compacted on the whole side,
|
|
131
|
+
* so a compacted parent no longer names the cells the reduction has to answer. The touch table is the uncompacted truth
|
|
132
|
+
* about which delineation reaches which cell.
|
|
133
|
+
*/
|
|
134
|
+
export function reduceCells(
|
|
135
|
+
database: DatabaseClient<SoilDatabase>,
|
|
136
|
+
indexResolution: number,
|
|
137
|
+
onProgress?: (message: string) => void
|
|
138
|
+
): {
|
|
139
|
+
cells: number
|
|
140
|
+
sampled: number
|
|
141
|
+
topClassUnderHalf: number
|
|
142
|
+
classless: number
|
|
143
|
+
unsampled: number
|
|
144
|
+
candidatePairs: number
|
|
145
|
+
} {
|
|
146
|
+
const profiles = readMapUnitProfiles(database)
|
|
147
|
+
|
|
148
|
+
const insert = database.prepare(
|
|
149
|
+
"INSERT OR REPLACE INTO soil_capability_cell (h3_cell, class_shares, unrated_share, notrateable_share, nodata_share, other_share, mapped_share, top_class, top_class_share, weighting, delineations) " +
|
|
150
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const selectArea = database.prepare(
|
|
154
|
+
"SELECT mukey, min_lat, min_lon, max_lat, max_lon, rings FROM soil_map_unit_area WHERE area_id = ?"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const rows = database
|
|
158
|
+
.prepare("SELECT h3_cell, resolution, area_id, is_full FROM build_cell_touch ORDER BY resolution, h3_cell, area_id")
|
|
159
|
+
.iterate() as Iterable<{ h3_cell: number; resolution: number; area_id: string; is_full: number }>
|
|
160
|
+
|
|
161
|
+
let cells = 0
|
|
162
|
+
let sampled = 0
|
|
163
|
+
let topClassUnderHalf = 0
|
|
164
|
+
let classless = 0
|
|
165
|
+
let unsampled = 0
|
|
166
|
+
let candidatePairs = 0
|
|
167
|
+
|
|
168
|
+
let currentCell: number | undefined
|
|
169
|
+
let currentResolution = indexResolution
|
|
170
|
+
let candidates: CellCandidate[] = []
|
|
171
|
+
|
|
172
|
+
// A delineation is named by every cell it reaches — 5.35 of them per cell at resolution 9 on the pilot region — so a
|
|
173
|
+
// naive read fetches each ring blob once per touch. At Iowa's scale that is millions of blob reads of ground already
|
|
174
|
+
// in memory. The cache is bounded and CLEARED WHOLE when it fills rather than evicted one at a time: h3 cell integers
|
|
175
|
+
// carry their ancestry in their high bits, so a scan in `h3_cell` order visits neighbours together and a cleared cache
|
|
176
|
+
// refills with the delineations the next run of cells actually names.
|
|
177
|
+
const geometry = new Map<string, StoredDelineation>()
|
|
178
|
+
|
|
179
|
+
const batch = beginBatched(database, { rowsPerCommit: REDUCE_PROGRESS_STRIDE })
|
|
180
|
+
|
|
181
|
+
const flush = (): void => {
|
|
182
|
+
if (currentCell === undefined || !candidates.length) return
|
|
183
|
+
|
|
184
|
+
const cell = expandShortCellInt(currentCell, currentResolution)
|
|
185
|
+
|
|
186
|
+
const reducedCell = reduceCell(cell, currentResolution, candidates, profiles, currentCell)
|
|
187
|
+
|
|
188
|
+
candidatePairs += candidates.length
|
|
189
|
+
|
|
190
|
+
if (reducedCell.row.mapped_share <= 0) {
|
|
191
|
+
unsampled++
|
|
192
|
+
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
cells++
|
|
197
|
+
|
|
198
|
+
if (reducedCell.sampled) {
|
|
199
|
+
sampled++
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (reducedCell.topClassUnderHalf) {
|
|
203
|
+
topClassUnderHalf++
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!reducedCell.row.top_class) {
|
|
207
|
+
classless++
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
insertRow(insert, reducedCell.row)
|
|
211
|
+
|
|
212
|
+
if (batch.rowWritten()) {
|
|
213
|
+
onProgress?.(`${cells.toLocaleString()} cells reduced`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
if (row.h3_cell !== currentCell || row.resolution !== currentResolution) {
|
|
219
|
+
flush()
|
|
220
|
+
|
|
221
|
+
currentCell = row.h3_cell
|
|
222
|
+
currentResolution = row.resolution
|
|
223
|
+
candidates = []
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let area = geometry.get(row.area_id)
|
|
227
|
+
|
|
228
|
+
if (!area) {
|
|
229
|
+
area = selectArea.get(row.area_id) as StoredDelineation | undefined
|
|
230
|
+
|
|
231
|
+
if (!area) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`soil build: the touch table names delineation ${row.area_id}, which soil_map_unit_area does not hold — the ingest and the reduction disagree about what was written`
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (geometry.size >= GEOMETRY_CACHE_ENTRIES) {
|
|
238
|
+
geometry.clear()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
geometry.set(row.area_id, area)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
candidates.push({
|
|
245
|
+
areaID: row.area_id,
|
|
246
|
+
mukey: area.mukey,
|
|
247
|
+
containment: row.is_full ? SoilCellContainment.Whole : SoilCellContainment.Partial,
|
|
248
|
+
minLat: area.min_lat,
|
|
249
|
+
minLon: area.min_lon,
|
|
250
|
+
maxLat: area.max_lat,
|
|
251
|
+
maxLon: area.max_lon,
|
|
252
|
+
rings: area.rings,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
flush()
|
|
257
|
+
|
|
258
|
+
batch.commit()
|
|
259
|
+
|
|
260
|
+
return { cells, sampled, topClassUnderHalf, classless, unsampled, candidatePairs }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function insertRow(statement: ReturnType<DatabaseClient["prepare"]>, row: SoilCapabilityCellTable): void {
|
|
264
|
+
statement.run(
|
|
265
|
+
row.h3_cell,
|
|
266
|
+
row.class_shares,
|
|
267
|
+
row.unrated_share,
|
|
268
|
+
row.notrateable_share,
|
|
269
|
+
row.nodata_share,
|
|
270
|
+
row.other_share,
|
|
271
|
+
row.mapped_share,
|
|
272
|
+
row.top_class,
|
|
273
|
+
row.top_class_share,
|
|
274
|
+
row.weighting,
|
|
275
|
+
row.delineations
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Every map unit's per-unit-area profile, computed once and reused for every cell it reaches.
|
|
281
|
+
*/
|
|
282
|
+
function readMapUnitProfiles(database: DatabaseClient<SoilDatabase>): Map<string, MapUnitProfile> {
|
|
283
|
+
const componentsByMukey = new Map<
|
|
284
|
+
string,
|
|
285
|
+
Array<{ comppct_r: number; compkind: string | null; nirrcapcl: string | null }>
|
|
286
|
+
>()
|
|
287
|
+
|
|
288
|
+
for (const row of database
|
|
289
|
+
.prepare("SELECT mukey, comppct_r, compkind, nirrcapcl FROM soil_component")
|
|
290
|
+
.all() as Array<{ mukey: string; comppct_r: number; compkind: string | null; nirrcapcl: string | null }>) {
|
|
291
|
+
const list = componentsByMukey.get(row.mukey)
|
|
292
|
+
|
|
293
|
+
if (list) {
|
|
294
|
+
list.push(row)
|
|
295
|
+
} else {
|
|
296
|
+
componentsByMukey.set(row.mukey, [row])
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const profiles = new Map<string, MapUnitProfile>()
|
|
301
|
+
|
|
302
|
+
for (const row of database.prepare("SELECT mukey, no_mapping FROM soil_map_unit").all() as Array<{
|
|
303
|
+
mukey: string
|
|
304
|
+
no_mapping: number
|
|
305
|
+
}>) {
|
|
306
|
+
profiles.set(row.mukey, mapUnitProfile(row, componentsByMukey.get(row.mukey) ?? []))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return profiles
|
|
310
|
+
}
|
package/lib/sdk/cells.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The delineation-keyed cell index, and the two numbers the index resolution is chosen from.
|
|
7
|
+
*
|
|
8
|
+
* THE CLASSIFIER ITSELF LIVES IN `@mailwoman/spatial`, because the traps it guards are properties of
|
|
9
|
+
* h3-js rather than of SSURGO: a centre-containment polyfill drops every polygon smaller than a cell, an
|
|
10
|
+
* exhausted WASM allocator reports success and returns zeros, and the allocator is sized from the
|
|
11
|
+
* bounding box. The layer contract states all three as requirements on every polygon builder. What is
|
|
12
|
+
* soil-shaped is the ACCUMULATOR below, which keys on the delineation rather than on a hazard class,
|
|
13
|
+
* because the reduction weights by the area a delineation covers.
|
|
14
|
+
*
|
|
15
|
+
* EXPECT THE `partial` SHARE TO INVERT AGAINST THE FLOOD LAYER, AND DO NOT READ THAT AS A DEFECT. Flood
|
|
16
|
+
* polygons are large against their cells, so most cells fall wholly inside one zone and `compactCells`
|
|
17
|
+
* collapses long uniform interiors. Soil delineations are the opposite: 85.4% of `IA153`'s 17,966 of them
|
|
18
|
+
* are smaller than one resolution-9 cell, and the median is 24,863 m² against a 105,333 m² cell. Small
|
|
19
|
+
* polygons against large cells means most cells are crossed by a boundary — so the `partial` share should
|
|
20
|
+
* be HIGH, `compactCells` should yield close to nothing, and the index alone will rarely answer a point
|
|
21
|
+
* probe. That is not an argument against storing the geometry; it is the argument for why this layer
|
|
22
|
+
* carries the reduced `soil_capability_cell` alongside the index rather than relying on the index the way
|
|
23
|
+
* the flood layer can.
|
|
24
|
+
*
|
|
25
|
+
* TWO NUMBERS GET REPORTED AT EACH CANDIDATE RESOLUTION, AND THEY MOVE IN OPPOSITE DIRECTIONS. The
|
|
26
|
+
* `partial` cell share says whether the containment index answers most probes alone. The share of cells
|
|
27
|
+
* whose top class holds less than half the cell says whether the layer is answering or hedging — the
|
|
28
|
+
* cell-grain analogue of NRCS's own `niccdcdpct` distribution, which reads 3.3% below half nationally.
|
|
29
|
+
* Going coarser improves the first and worsens the second, and picking between them is what the
|
|
30
|
+
* measurement is for.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
classifyFeatureCells,
|
|
35
|
+
compactAcrossResolutions,
|
|
36
|
+
shortCellToInt,
|
|
37
|
+
type FeatureCells,
|
|
38
|
+
type H3Cell,
|
|
39
|
+
type MultiPolygonRings,
|
|
40
|
+
} from "@mailwoman/spatial"
|
|
41
|
+
import { getResolution } from "h3-js"
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The label this layer's classifier failures carry.
|
|
45
|
+
*/
|
|
46
|
+
export const SOIL_CELL_LABEL = "soil cells"
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Classify one delineation — {@link classifyFeatureCells} with this layer's label bound.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyDelineationCells(
|
|
52
|
+
polygons: MultiPolygonRings,
|
|
53
|
+
targetResolution: number,
|
|
54
|
+
areaID: string
|
|
55
|
+
): FeatureCells {
|
|
56
|
+
return classifyFeatureCells(polygons, targetResolution, areaID, SOIL_CELL_LABEL)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What one resolution's index came out as.
|
|
61
|
+
*/
|
|
62
|
+
export interface SoilCellIndexMeasurement {
|
|
63
|
+
resolution: number
|
|
64
|
+
/**
|
|
65
|
+
* Cells the layer reaches at all.
|
|
66
|
+
*/
|
|
67
|
+
touchedCells: number
|
|
68
|
+
/**
|
|
69
|
+
* Cells lying wholly inside a single delineation, before compaction.
|
|
70
|
+
*/
|
|
71
|
+
wholeCells: number
|
|
72
|
+
/**
|
|
73
|
+
* Cells a delineation boundary crosses.
|
|
74
|
+
*/
|
|
75
|
+
partialCells: number
|
|
76
|
+
/**
|
|
77
|
+
* `partialCells / touchedCells` — the share of in-layer probes that cannot be answered from the index alone.
|
|
78
|
+
*/
|
|
79
|
+
partialShare: number
|
|
80
|
+
/**
|
|
81
|
+
* Whole cells after `compactCells`. Expected to be close to `wholeCells` here rather than far below it: compaction
|
|
82
|
+
* needs a uniform interior, and small delineations do not produce one.
|
|
83
|
+
*/
|
|
84
|
+
compactedWholeCells: number
|
|
85
|
+
/**
|
|
86
|
+
* `(cell, delineation)` pairs the index stores.
|
|
87
|
+
*/
|
|
88
|
+
cellDelineationPairs: number
|
|
89
|
+
/**
|
|
90
|
+
* The mean number of delineations reaching a cell — the direct measure of how mixed a cell is before any rating is
|
|
91
|
+
* read, and the number that rises as the resolution coarsens.
|
|
92
|
+
*/
|
|
93
|
+
meanDelineationsPerCell: number
|
|
94
|
+
/**
|
|
95
|
+
* Delineations whose bounding box forced a coarser resolution than the target.
|
|
96
|
+
*/
|
|
97
|
+
coarsenedFeatures: number
|
|
98
|
+
/**
|
|
99
|
+
* The resolutions actually present, finest last.
|
|
100
|
+
*/
|
|
101
|
+
resolutions: number[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Accumulate one resolution's cell index over a stream of delineations.
|
|
106
|
+
*
|
|
107
|
+
* Held as short-cell STRINGS rather than the integers the tables store, because `compactCells` is an h3-js function
|
|
108
|
+
* over full indexes and round-tripping through the integer form at every step would cost more than the strings do.
|
|
109
|
+
*/
|
|
110
|
+
export class SoilCellIndex {
|
|
111
|
+
readonly resolution: number
|
|
112
|
+
|
|
113
|
+
readonly #whole = new Set<string>()
|
|
114
|
+
readonly #touched = new Set<string>()
|
|
115
|
+
/**
|
|
116
|
+
* `cell → delineation ids`. Every touched cell, so the mean below is over the real population rather than over the
|
|
117
|
+
* fringe alone.
|
|
118
|
+
*/
|
|
119
|
+
readonly #byCell = new Map<string, Set<string>>()
|
|
120
|
+
|
|
121
|
+
#coarsened = 0
|
|
122
|
+
|
|
123
|
+
constructor(resolution: number) {
|
|
124
|
+
this.resolution = resolution
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fold one delineation's classification in.
|
|
129
|
+
*/
|
|
130
|
+
add(areaID: string, cells: FeatureCells): void {
|
|
131
|
+
if (cells.resolution !== this.resolution) {
|
|
132
|
+
this.#coarsened++
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const cell of cells.whole) {
|
|
136
|
+
this.#whole.add(cell)
|
|
137
|
+
this.#record(cell, areaID)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const cell of cells.partial) {
|
|
141
|
+
this.#record(cell, areaID)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#record(cell: string, areaID: string): void {
|
|
146
|
+
this.#touched.add(cell)
|
|
147
|
+
|
|
148
|
+
let areas = this.#byCell.get(cell)
|
|
149
|
+
|
|
150
|
+
if (!areas) {
|
|
151
|
+
areas = new Set()
|
|
152
|
+
|
|
153
|
+
this.#byCell.set(cell, areas)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
areas.add(areaID)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compact the whole-cell set and report the measurement.
|
|
161
|
+
*
|
|
162
|
+
* Compaction is applied to the WHOLE set only — a partial cell's parent is not partial in any useful sense, and
|
|
163
|
+
* compacting it would claim the fringe covers ground it does not.
|
|
164
|
+
*/
|
|
165
|
+
finish(): SoilCellIndexMeasurement {
|
|
166
|
+
const compacted = compactAcrossResolutions(this.#whole)
|
|
167
|
+
|
|
168
|
+
const resolutions = new Set<number>()
|
|
169
|
+
|
|
170
|
+
for (const cell of this.#touched) {
|
|
171
|
+
resolutions.add(getResolution(cell))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let pairs = 0
|
|
175
|
+
|
|
176
|
+
for (const areas of this.#byCell.values()) {
|
|
177
|
+
pairs += areas.size
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const touched = this.#touched.size
|
|
181
|
+
const partial = touched - this.#whole.size
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
resolution: this.resolution,
|
|
185
|
+
touchedCells: touched,
|
|
186
|
+
wholeCells: this.#whole.size,
|
|
187
|
+
partialCells: partial,
|
|
188
|
+
partialShare: touched ? partial / touched : 0,
|
|
189
|
+
compactedWholeCells: compacted.length,
|
|
190
|
+
cellDelineationPairs: pairs,
|
|
191
|
+
meanDelineationsPerCell: touched ? pairs / touched : 0,
|
|
192
|
+
coarsenedFeatures: this.#coarsened,
|
|
193
|
+
resolutions: [...resolutions].toSorted((left, right) => left - right),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The measurement as markdown table ROWS — what a build receipt carries, one line per element so a caller printing them
|
|
200
|
+
* never has to split a joined string back apart.
|
|
201
|
+
*/
|
|
202
|
+
export function formatSoilResolutionRows(
|
|
203
|
+
measurements: ReadonlyArray<SoilCellIndexMeasurement & { mixedCellShare?: number }>
|
|
204
|
+
): string[] {
|
|
205
|
+
return [
|
|
206
|
+
"| res | touched cells | whole | partial | partial share | whole after compaction | (cell, delineation) pairs | mean delineations/cell | top class under half |",
|
|
207
|
+
"| --- | ------------- | ----- | ------- | ------------- | ---------------------- | ------------------------- | ---------------------- | -------------------- |",
|
|
208
|
+
...measurements.map(
|
|
209
|
+
(m) =>
|
|
210
|
+
`| ${m.resolution} | ${m.touchedCells.toLocaleString()} | ${m.wholeCells.toLocaleString()} | ` +
|
|
211
|
+
`${m.partialCells.toLocaleString()} | ${(m.partialShare * 100).toFixed(1)}% | ` +
|
|
212
|
+
`${m.compactedWholeCells.toLocaleString()} | ${m.cellDelineationPairs.toLocaleString()} | ` +
|
|
213
|
+
`${m.meanDelineationsPerCell.toFixed(2)} | ` +
|
|
214
|
+
`${m.mixedCellShare === undefined ? "—" : `${(m.mixedCellShare * 100).toFixed(1)}%`} |`
|
|
215
|
+
),
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The cell integer a short-cell string stores as.
|
|
221
|
+
*/
|
|
222
|
+
export function cellToShortInt(cell: string): number {
|
|
223
|
+
return shortCellToInt(cell as H3Cell)
|
|
224
|
+
}
|