@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.
Files changed (97) hide show
  1. package/README.md +221 -0
  2. package/lib/index.ts +474 -0
  3. package/lib/schema.ts +493 -0
  4. package/lib/scripts/ingest-chunk.ts +47 -0
  5. package/lib/sdk/acquire.ts +124 -0
  6. package/lib/sdk/build-soil.ts +778 -0
  7. package/lib/sdk/cell-tiers.ts +310 -0
  8. package/lib/sdk/cells.ts +224 -0
  9. package/lib/sdk/client.ts +273 -0
  10. package/lib/sdk/download.ts +161 -0
  11. package/lib/sdk/index.ts +20 -0
  12. package/lib/sdk/ingest-chunk.ts +182 -0
  13. package/lib/sdk/ingest.ts +271 -0
  14. package/lib/sdk/measure-resolutions.ts +81 -0
  15. package/lib/sdk/reduce.ts +375 -0
  16. package/lib/sdk/survey-area.ts +467 -0
  17. package/lib/sdk/tabular.ts +296 -0
  18. package/lib/sdk/verify.ts +369 -0
  19. package/lib/test-kit.ts +255 -0
  20. package/lib/vocabulary.ts +241 -0
  21. package/out/index.d.ts +199 -0
  22. package/out/index.d.ts.map +1 -0
  23. package/out/index.js +255 -0
  24. package/out/index.js.map +1 -0
  25. package/out/schema.d.ts +369 -0
  26. package/out/schema.d.ts.map +1 -0
  27. package/out/schema.js +186 -0
  28. package/out/schema.js.map +1 -0
  29. package/out/scripts/ingest-chunk.d.ts +11 -0
  30. package/out/scripts/ingest-chunk.d.ts.map +1 -0
  31. package/out/scripts/ingest-chunk.js +42 -0
  32. package/out/scripts/ingest-chunk.js.map +1 -0
  33. package/out/sdk/acquire.d.ts +60 -0
  34. package/out/sdk/acquire.d.ts.map +1 -0
  35. package/out/sdk/acquire.js +74 -0
  36. package/out/sdk/acquire.js.map +1 -0
  37. package/out/sdk/build-soil.d.ts +197 -0
  38. package/out/sdk/build-soil.d.ts.map +1 -0
  39. package/out/sdk/build-soil.js +434 -0
  40. package/out/sdk/build-soil.js.map +1 -0
  41. package/out/sdk/cell-tiers.d.ts +47 -0
  42. package/out/sdk/cell-tiers.d.ts.map +1 -0
  43. package/out/sdk/cell-tiers.js +194 -0
  44. package/out/sdk/cell-tiers.js.map +1 -0
  45. package/out/sdk/cells.d.ts +118 -0
  46. package/out/sdk/cells.d.ts.map +1 -0
  47. package/out/sdk/cells.js +140 -0
  48. package/out/sdk/cells.js.map +1 -0
  49. package/out/sdk/client.d.ts +120 -0
  50. package/out/sdk/client.d.ts.map +1 -0
  51. package/out/sdk/client.js +214 -0
  52. package/out/sdk/client.js.map +1 -0
  53. package/out/sdk/download.d.ts +84 -0
  54. package/out/sdk/download.d.ts.map +1 -0
  55. package/out/sdk/download.js +117 -0
  56. package/out/sdk/download.js.map +1 -0
  57. package/out/sdk/index.d.ts +20 -0
  58. package/out/sdk/index.d.ts.map +1 -0
  59. package/out/sdk/index.js +20 -0
  60. package/out/sdk/index.js.map +1 -0
  61. package/out/sdk/ingest-chunk.d.ts +73 -0
  62. package/out/sdk/ingest-chunk.d.ts.map +1 -0
  63. package/out/sdk/ingest-chunk.js +98 -0
  64. package/out/sdk/ingest-chunk.js.map +1 -0
  65. package/out/sdk/ingest.d.ts +132 -0
  66. package/out/sdk/ingest.d.ts.map +1 -0
  67. package/out/sdk/ingest.js +163 -0
  68. package/out/sdk/ingest.js.map +1 -0
  69. package/out/sdk/measure-resolutions.d.ts +39 -0
  70. package/out/sdk/measure-resolutions.d.ts.map +1 -0
  71. package/out/sdk/measure-resolutions.js +52 -0
  72. package/out/sdk/measure-resolutions.js.map +1 -0
  73. package/out/sdk/reduce.d.ts +122 -0
  74. package/out/sdk/reduce.d.ts.map +1 -0
  75. package/out/sdk/reduce.js +266 -0
  76. package/out/sdk/reduce.js.map +1 -0
  77. package/out/sdk/survey-area.d.ts +105 -0
  78. package/out/sdk/survey-area.d.ts.map +1 -0
  79. package/out/sdk/survey-area.js +346 -0
  80. package/out/sdk/survey-area.js.map +1 -0
  81. package/out/sdk/tabular.d.ts +106 -0
  82. package/out/sdk/tabular.d.ts.map +1 -0
  83. package/out/sdk/tabular.js +194 -0
  84. package/out/sdk/tabular.js.map +1 -0
  85. package/out/sdk/verify.d.ts +135 -0
  86. package/out/sdk/verify.d.ts.map +1 -0
  87. package/out/sdk/verify.js +221 -0
  88. package/out/sdk/verify.js.map +1 -0
  89. package/out/test-kit.d.ts +80 -0
  90. package/out/test-kit.d.ts.map +1 -0
  91. package/out/test-kit.js +228 -0
  92. package/out/test-kit.js.map +1 -0
  93. package/out/vocabulary.d.ts +179 -0
  94. package/out/vocabulary.d.ts.map +1 -0
  95. package/out/vocabulary.js +215 -0
  96. package/out/vocabulary.js.map +1 -0
  97. 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
+ }
@@ -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
+ }