@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
package/lib/index.ts
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The `soil.db` reader — what the soil survey assigns at a coordinate, and on what basis.
|
|
7
|
+
*
|
|
8
|
+
* THREE ANSWERS, AND KEEPING THEM APART IS THE WHOLE JOB.
|
|
9
|
+
*
|
|
10
|
+
* 1. `designated` — the survey mapped this location and the cell's class distribution is the answer.
|
|
11
|
+
* 2. `designated_no_rating` — the survey mapped this location and rated nothing there. A cell that is
|
|
12
|
+
* 100% `unrated_share` or `notrateable_share` is `designated`-complete and carries no capability
|
|
13
|
+
* reading whatsoever, and that is not a corner case: 17.1% of national components carry no capability
|
|
14
|
+
* rating.
|
|
15
|
+
* 3. `unknown` — no coverage row. Outside any published survey area, or inside one where the polygon
|
|
16
|
+
* exists and the soil mapping behind it does not.
|
|
17
|
+
*
|
|
18
|
+
* READINGS 2 AND 3 LOOK THE SAME FROM A CLASS CODE AND ARE OPPOSITE ANSWERS FROM THE READER. A layer that
|
|
19
|
+
* could not tell them apart would report unmapped ground as unrated ground, which is one of the four
|
|
20
|
+
* absences this whole layer exists to keep separate.
|
|
21
|
+
*
|
|
22
|
+
* THE ANSWER IS A DISTRIBUTION, AND THE TOP CLASS ALWAYS ARRIVES WITH THE SHARE IT RESTS ON. NRCS's own
|
|
23
|
+
* `muaggatt` ships `niccdcd` beside `niccdcdpct` for exactly this reason, with an observed minimum of 2%.
|
|
24
|
+
* A caller that wants one class may take `topClass`; it cannot take it without also being handed
|
|
25
|
+
* `topClassShare`, because a 2% plurality and an 85% majority are different claims.
|
|
26
|
+
*
|
|
27
|
+
* NEITHER READING IS A STATEMENT ABOUT WHETHER THE LAND CAN BE FARMED. The layer reports what the soil
|
|
28
|
+
* survey assigns to the map unit covering a location, which is a fact about the map. NRCS states that its
|
|
29
|
+
* data "do not eliminate the need for onsite sampling, testing, and detailed study of specific sites for
|
|
30
|
+
* intensive uses" and are "intended for planning purposes only" — so `limits` carries the authority's own
|
|
31
|
+
* exclusions on every answer.
|
|
32
|
+
*
|
|
33
|
+
* THE PROBE IS ONE PRIMARY-KEY READ. The reduction is single-resolution and one row per cell, which is
|
|
34
|
+
* what makes it the spine key: a coordinate becomes a cell, the cell becomes a row, and the geometry tier
|
|
35
|
+
* underneath is never touched at read time. The unsimplified rings are there for a caller that wants to
|
|
36
|
+
* re-derive the claim, not for the probe.
|
|
37
|
+
*
|
|
38
|
+
* THE READER IS SYNCHRONOUS AND USES RAW PREPARED STATEMENTS, matching the resolution ladder's existing
|
|
39
|
+
* shape. The DDL that created these tables IS Kysely — see `schema.ts`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
assertCoverageNotEmpty,
|
|
44
|
+
singleManifestRow,
|
|
45
|
+
toLayerManifest,
|
|
46
|
+
type CoverageCell,
|
|
47
|
+
type LayerManifest,
|
|
48
|
+
} from "@mailwoman/core/layers"
|
|
49
|
+
import { parseJSONStrict } from "@mailwoman/core/objects"
|
|
50
|
+
import { shortCellToInt, type H3Cell } from "@mailwoman/spatial"
|
|
51
|
+
import { readCoverageAt } from "@mailwoman/spatial/h3/coverage"
|
|
52
|
+
import { DatabaseClient } from "@mailwoman/sqlite/client"
|
|
53
|
+
import { latLngToCell } from "h3-js"
|
|
54
|
+
|
|
55
|
+
import type { SoilDatabase } from "#schema"
|
|
56
|
+
import { SOIL_LAYER_NAME_PREFIX, SSURGO_PRODUCT_LIMITS } from "#vocabulary"
|
|
57
|
+
|
|
58
|
+
export { FarmlandScope, farmlandScope, SSURGO_PRODUCT_LIMITS } from "#vocabulary"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What the layer can say about a coordinate.
|
|
62
|
+
*/
|
|
63
|
+
export const SoilReadingKind = {
|
|
64
|
+
/**
|
|
65
|
+
* The survey mapped this location and assigns at least one capability class here.
|
|
66
|
+
*/
|
|
67
|
+
Designated: "designated",
|
|
68
|
+
/**
|
|
69
|
+
* The survey mapped this location and rated nothing here — every share is an absence share.
|
|
70
|
+
*/
|
|
71
|
+
DesignatedNoRating: "designated_no_rating",
|
|
72
|
+
/**
|
|
73
|
+
* No coverage row. Unmapped by this authority, and never a low-capability reading.
|
|
74
|
+
*/
|
|
75
|
+
Unknown: "unknown",
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
export type SoilReadingKind = (typeof SoilReadingKind)[keyof typeof SoilReadingKind]
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The per-cell distribution, as a caller reads it.
|
|
82
|
+
*/
|
|
83
|
+
export interface SoilCapabilityDistribution {
|
|
84
|
+
/**
|
|
85
|
+
* The authority's class codes mapped to their area-weighted share, largest first.
|
|
86
|
+
*/
|
|
87
|
+
classShares: Record<string, number>
|
|
88
|
+
/**
|
|
89
|
+
* Mapped soil components carrying a NULL rating — the survey did not rate them.
|
|
90
|
+
*/
|
|
91
|
+
unratedShare: number
|
|
92
|
+
/**
|
|
93
|
+
* Miscellaneous areas the rating does not apply to.
|
|
94
|
+
*/
|
|
95
|
+
notRateableShare: number
|
|
96
|
+
/**
|
|
97
|
+
* Polygons with no soil mapping behind them.
|
|
98
|
+
*/
|
|
99
|
+
noDataShare: number
|
|
100
|
+
/**
|
|
101
|
+
* The truncated minority tail. The five shares sum to 1.
|
|
102
|
+
*/
|
|
103
|
+
otherShare: number
|
|
104
|
+
/**
|
|
105
|
+
* How much of the cell any delineation covers. Below 1 at a survey-area edge.
|
|
106
|
+
*/
|
|
107
|
+
mappedShare: number
|
|
108
|
+
/**
|
|
109
|
+
* The largest class share, and the share it rests on. Absent when the cell carries no class at all.
|
|
110
|
+
*/
|
|
111
|
+
topClass?: string
|
|
112
|
+
topClassShare?: number
|
|
113
|
+
/**
|
|
114
|
+
* Which weighting produced these shares.
|
|
115
|
+
*/
|
|
116
|
+
weighting: string
|
|
117
|
+
/**
|
|
118
|
+
* How many delineations reached the cell.
|
|
119
|
+
*/
|
|
120
|
+
delineations: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One survey area, as the layer holds it.
|
|
125
|
+
*/
|
|
126
|
+
export interface SoilSurveyAreaRecord {
|
|
127
|
+
areaSymbol: string
|
|
128
|
+
areaName: string
|
|
129
|
+
/**
|
|
130
|
+
* The refresh — when this version of the data was established.
|
|
131
|
+
*/
|
|
132
|
+
saverest: string
|
|
133
|
+
/**
|
|
134
|
+
* The FIELD survey date, which is a different fact and is usually much older.
|
|
135
|
+
*/
|
|
136
|
+
surveySourceDate: string | null
|
|
137
|
+
surveySourceTitle: string | null
|
|
138
|
+
sourceScale: number | null
|
|
139
|
+
mappingScale: number | null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One reading, carrying everything a caller needs to re-derive it rather than take it.
|
|
144
|
+
*/
|
|
145
|
+
export interface SoilCapabilityReading {
|
|
146
|
+
kind: SoilReadingKind
|
|
147
|
+
/**
|
|
148
|
+
* The cell's distribution. Present on both designated readings; absent on `unknown`.
|
|
149
|
+
*/
|
|
150
|
+
distribution?: SoilCapabilityDistribution
|
|
151
|
+
/**
|
|
152
|
+
* The authority's own definition of the top class, from the domain it shipped.
|
|
153
|
+
*/
|
|
154
|
+
topClassDefinition?: string
|
|
155
|
+
/**
|
|
156
|
+
* The survey area covering the location, with both its dates.
|
|
157
|
+
*/
|
|
158
|
+
surveyArea?: SoilSurveyAreaRecord
|
|
159
|
+
/**
|
|
160
|
+
* The coverage row that licenses the reading, when there is one. Absent on `unknown`, which IS the absence.
|
|
161
|
+
*/
|
|
162
|
+
coverage?: CoverageCell & { h3CellIndex: string; resolution: number }
|
|
163
|
+
/**
|
|
164
|
+
* The index cell probed, for a receipt.
|
|
165
|
+
*/
|
|
166
|
+
indexCellIndex: string
|
|
167
|
+
/**
|
|
168
|
+
* What the product does not cover, in the authority's own words. Carried on every reading.
|
|
169
|
+
*/
|
|
170
|
+
limits: ReadonlyArray<string>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The layer's identity, read once at open time.
|
|
175
|
+
*/
|
|
176
|
+
export interface SoilLayerIdentity {
|
|
177
|
+
manifest: LayerManifest
|
|
178
|
+
indexResolution: number
|
|
179
|
+
coverageResolution: number
|
|
180
|
+
/**
|
|
181
|
+
* The survey areas the layer covers, in symbol order.
|
|
182
|
+
*/
|
|
183
|
+
surveyAreas: SoilSurveyAreaRecord[]
|
|
184
|
+
/**
|
|
185
|
+
* The class codes the layer's own vocabulary declares.
|
|
186
|
+
*/
|
|
187
|
+
classCodes: string[]
|
|
188
|
+
/**
|
|
189
|
+
* The weighting every stored share was produced under, and the sentence that says what it means.
|
|
190
|
+
*/
|
|
191
|
+
weighting: { code: string; description: string }
|
|
192
|
+
databasePath: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface SoilCapabilityLookupOptions {
|
|
196
|
+
databasePath: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read a sealed `soil.db`.
|
|
201
|
+
*
|
|
202
|
+
* Everything that would make the reader answer a well-formed wrong thing is refused at CONSTRUCTION rather than at
|
|
203
|
+
* query time: a manifest naming a different product, a coverage table with no rows, a vocabulary with no classes. Each
|
|
204
|
+
* of those would otherwise present as a reader that simply always answers `unknown`, which on a receipt is
|
|
205
|
+
* indistinguishable from a region the authority genuinely has not surveyed.
|
|
206
|
+
*/
|
|
207
|
+
export class SoilCapabilityLookup implements Disposable {
|
|
208
|
+
readonly identity: SoilLayerIdentity
|
|
209
|
+
|
|
210
|
+
readonly #database: DatabaseClient<SoilDatabase>
|
|
211
|
+
readonly #selectCell: ReturnType<DatabaseClient["prepare"]>
|
|
212
|
+
readonly #selectCoverage: ReturnType<DatabaseClient["prepare"]>
|
|
213
|
+
readonly #definitions: Map<string, string>
|
|
214
|
+
readonly #surveyAreaByBounds: SoilSurveyAreaRecord[]
|
|
215
|
+
readonly #bounds: Array<{ minLat: number; minLon: number; maxLat: number; maxLon: number }>
|
|
216
|
+
|
|
217
|
+
constructor(options: SoilCapabilityLookupOptions) {
|
|
218
|
+
this.#database = new DatabaseClient<SoilDatabase>(options.databasePath, { readOnly: true })
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const identity = readIdentity(this.#database, options.databasePath)
|
|
222
|
+
|
|
223
|
+
this.identity = identity.identity
|
|
224
|
+
this.#definitions = identity.definitions
|
|
225
|
+
this.#surveyAreaByBounds = identity.identity.surveyAreas
|
|
226
|
+
this.#bounds = identity.bounds
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.#database.destroy()
|
|
229
|
+
|
|
230
|
+
throw error
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.#selectCell = this.#database.prepare(
|
|
234
|
+
"SELECT class_shares, unrated_share, notrateable_share, nodata_share, other_share, mapped_share, top_class, top_class_share, weighting, delineations FROM soil_capability_cell WHERE h3_cell = ?"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
this.#selectCoverage = this.#database.prepare(
|
|
238
|
+
"SELECT h3_cell, completeness, basis, observed_rows FROM layer_coverage WHERE h3_cell = ?"
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What the soil survey assigns at this coordinate.
|
|
244
|
+
*/
|
|
245
|
+
public lookup(latitude: number, longitude: number): SoilCapabilityReading {
|
|
246
|
+
const indexCell = latLngToCell(latitude, longitude, this.identity.indexResolution) as H3Cell
|
|
247
|
+
const coverage = this.#readCoverage(indexCell)
|
|
248
|
+
|
|
249
|
+
// COVERAGE QUALIFIES THE READING, and without it there is nothing to report. Unlike a polygon hit — which is a
|
|
250
|
+
// determination at a location and needs no coverage row to be true — every answer this layer gives is a per-cell
|
|
251
|
+
// summary, so a summary row without a coverage row would state a determination outside the authority's footprint.
|
|
252
|
+
if (!coverage) {
|
|
253
|
+
return {
|
|
254
|
+
kind: SoilReadingKind.Unknown,
|
|
255
|
+
indexCellIndex: indexCell,
|
|
256
|
+
limits: SSURGO_PRODUCT_LIMITS,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const row = this.#selectCell.get(shortCellToInt(indexCell)) as
|
|
261
|
+
| {
|
|
262
|
+
class_shares: string
|
|
263
|
+
unrated_share: number
|
|
264
|
+
notrateable_share: number
|
|
265
|
+
nodata_share: number
|
|
266
|
+
other_share: number
|
|
267
|
+
mapped_share: number
|
|
268
|
+
top_class: string | null
|
|
269
|
+
top_class_share: number | null
|
|
270
|
+
weighting: string
|
|
271
|
+
delineations: number
|
|
272
|
+
}
|
|
273
|
+
| undefined
|
|
274
|
+
|
|
275
|
+
if (!row) {
|
|
276
|
+
// A coverage row without a summary row means the coverage cell is designated and this finer cell holds nothing —
|
|
277
|
+
// the survey-area edge. Unknown rather than "no rating": the authority's statement covers the coverage cell, and
|
|
278
|
+
// this location may be outside the delineations it covers.
|
|
279
|
+
return {
|
|
280
|
+
kind: SoilReadingKind.Unknown,
|
|
281
|
+
coverage,
|
|
282
|
+
indexCellIndex: indexCell,
|
|
283
|
+
limits: SSURGO_PRODUCT_LIMITS,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const distribution: SoilCapabilityDistribution = {
|
|
288
|
+
classShares: parseJSONStrict<Record<string, number>>(row.class_shares),
|
|
289
|
+
unratedShare: row.unrated_share,
|
|
290
|
+
notRateableShare: row.notrateable_share,
|
|
291
|
+
noDataShare: row.nodata_share,
|
|
292
|
+
otherShare: row.other_share,
|
|
293
|
+
mappedShare: row.mapped_share,
|
|
294
|
+
...(row.top_class ? { topClass: row.top_class } : {}),
|
|
295
|
+
...(row.top_class_share === null ? {} : { topClassShare: row.top_class_share }),
|
|
296
|
+
weighting: row.weighting,
|
|
297
|
+
delineations: row.delineations,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const surveyArea = this.#surveyAreaAt(latitude, longitude)
|
|
301
|
+
const definition = row.top_class ? this.#definitions.get(row.top_class) : undefined
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
kind: row.top_class ? SoilReadingKind.Designated : SoilReadingKind.DesignatedNoRating,
|
|
305
|
+
distribution,
|
|
306
|
+
...(definition ? { topClassDefinition: definition } : {}),
|
|
307
|
+
...(surveyArea ? { surveyArea } : {}),
|
|
308
|
+
coverage,
|
|
309
|
+
indexCellIndex: indexCell,
|
|
310
|
+
limits: SSURGO_PRODUCT_LIMITS,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
public [Symbol.dispose](): void {
|
|
315
|
+
this.#database.destroy()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The coverage row for the index cell's parent at the coverage resolution.
|
|
320
|
+
*/
|
|
321
|
+
#readCoverage(indexCell: H3Cell): (CoverageCell & { h3CellIndex: string; resolution: number }) | undefined {
|
|
322
|
+
return readCoverageAt(this.#selectCoverage, indexCell, this.identity.coverageResolution)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Which survey area a coordinate falls in, by the delineation bounds each area's row carries.
|
|
327
|
+
*
|
|
328
|
+
* A rectangle rather than the outline, and that is honest about what it is: the answer names WHICH published survey
|
|
329
|
+
* the reading came from, and two neighbouring counties' rectangles overlap at their corners. The reading itself does
|
|
330
|
+
* not depend on it — the cell row is the answer — so a corner ambiguity costs a label rather than a determination.
|
|
331
|
+
*
|
|
332
|
+
* A LINEAR SCAN, WHICH THE PILOT'S 99 SURVEY AREAS MAKE FREE AND A NATIONAL BUILD WOULD NOT. It returns on the first
|
|
333
|
+
* containing rectangle, so the pilot costs a few dozen comparisons per geocode. At the 3,380 survey areas the country
|
|
334
|
+
* holds this wants a bounding-box index; it is left as a scan because a structure sized for a set this build does not
|
|
335
|
+
* hold would be untested at the size it was built for.
|
|
336
|
+
*/
|
|
337
|
+
#surveyAreaAt(latitude: number, longitude: number): SoilSurveyAreaRecord | undefined {
|
|
338
|
+
for (const [index, bounds] of this.#bounds.entries()) {
|
|
339
|
+
if (
|
|
340
|
+
longitude >= bounds.minLon &&
|
|
341
|
+
longitude <= bounds.maxLon &&
|
|
342
|
+
latitude >= bounds.minLat &&
|
|
343
|
+
latitude <= bounds.maxLat
|
|
344
|
+
) {
|
|
345
|
+
return this.#surveyAreaByBounds[index]
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Read and check the layer's identity.
|
|
355
|
+
*/
|
|
356
|
+
function readIdentity(
|
|
357
|
+
database: DatabaseClient<SoilDatabase>,
|
|
358
|
+
databasePath: string
|
|
359
|
+
): {
|
|
360
|
+
identity: SoilLayerIdentity
|
|
361
|
+
definitions: Map<string, string>
|
|
362
|
+
bounds: Array<{ minLat: number; minLon: number; maxLat: number; maxLon: number }>
|
|
363
|
+
} {
|
|
364
|
+
const manifestRows = database.prepare("SELECT * FROM layer_manifest").all() as Array<
|
|
365
|
+
Record<string, string | number | null>
|
|
366
|
+
>
|
|
367
|
+
|
|
368
|
+
// The name's SUFFIX names the region a build covers, so the reader checks the prefix rather than a whole name — which
|
|
369
|
+
// is why it asserts its own identity instead of taking `parseManifestRows`: one authority, one product, one rating
|
|
370
|
+
// vocabulary per artifact, over whichever survey areas were built.
|
|
371
|
+
const row = singleManifestRow(manifestRows, `soil reader: ${databasePath}`)
|
|
372
|
+
const name = String(row.name)
|
|
373
|
+
|
|
374
|
+
if (!name.startsWith(SOIL_LAYER_NAME_PREFIX)) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`soil reader: ${databasePath} is layer ${JSON.stringify(name)}, which is not a ${JSON.stringify(SOIL_LAYER_NAME_PREFIX)} layer — one authority, one product, one rating vocabulary per artifact`
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const manifest = toLayerManifest(row)
|
|
381
|
+
const spineKeys = manifest.spineKeys
|
|
382
|
+
|
|
383
|
+
if (!spineKeys.h3) {
|
|
384
|
+
throw new Error(`soil reader: ${databasePath} declares no h3 spine key`)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const coverageCount = (database.prepare("SELECT count(*) AS n FROM layer_coverage").get() as { n: number }).n
|
|
388
|
+
|
|
389
|
+
assertCoverageNotEmpty(coverageCount, `soil reader: ${databasePath}`, "a region the authority has not surveyed")
|
|
390
|
+
|
|
391
|
+
const areaRows = database
|
|
392
|
+
.prepare(
|
|
393
|
+
"SELECT areasymbol, areaname, saverest, survey_source_date, survey_source_title, source_scale, mapping_scale, min_lat, min_lon, max_lat, max_lon, coverage_resolution FROM soil_survey_area ORDER BY areasymbol"
|
|
394
|
+
)
|
|
395
|
+
.all() as Array<{
|
|
396
|
+
areasymbol: string
|
|
397
|
+
areaname: string
|
|
398
|
+
saverest: string
|
|
399
|
+
survey_source_date: string | null
|
|
400
|
+
survey_source_title: string | null
|
|
401
|
+
source_scale: number | null
|
|
402
|
+
mapping_scale: number | null
|
|
403
|
+
min_lat: number
|
|
404
|
+
min_lon: number
|
|
405
|
+
max_lat: number
|
|
406
|
+
max_lon: number
|
|
407
|
+
coverage_resolution: number
|
|
408
|
+
}>
|
|
409
|
+
|
|
410
|
+
if (!areaRows.length) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`soil reader: ${databasePath} names no survey area — the footprint is the claim, and an artifact that cannot name which surveys it holds cannot support one`
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const coverageResolutions = new Set(areaRows.map((area) => area.coverage_resolution))
|
|
417
|
+
|
|
418
|
+
if (coverageResolutions.size !== 1) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`soil reader: ${databasePath}'s survey areas declare coverage resolutions ${[...coverageResolutions].join(", ")} — a probe derives its coverage cell from one resolution, and a mixed table would answer some locations from the wrong parent`
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const definitions = new Map<string, string>()
|
|
425
|
+
|
|
426
|
+
for (const vocabularyRow of database
|
|
427
|
+
.prepare("SELECT code, definition FROM soil_vocabulary WHERE domain = 'capability_class' ORDER BY sequence")
|
|
428
|
+
.all() as Array<{ code: string; definition: string }>) {
|
|
429
|
+
definitions.set(vocabularyRow.code, vocabularyRow.definition)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!definitions.size) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`soil reader: ${databasePath} declares no capability-class vocabulary — an answer could not be checked against the authority's own domain`
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const weighting = database
|
|
439
|
+
.prepare("SELECT code, definition FROM soil_vocabulary WHERE domain = 'share_weighting'")
|
|
440
|
+
.get() as { code: string; definition: string } | undefined
|
|
441
|
+
|
|
442
|
+
if (!weighting) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`soil reader: ${databasePath} records no share weighting — the shares would arrive without the one fact needed to know what they are shares OF`
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
identity: {
|
|
450
|
+
manifest,
|
|
451
|
+
indexResolution: spineKeys.h3.resolution,
|
|
452
|
+
coverageResolution: [...coverageResolutions][0]!,
|
|
453
|
+
surveyAreas: areaRows.map((area) => ({
|
|
454
|
+
areaSymbol: area.areasymbol,
|
|
455
|
+
areaName: area.areaname,
|
|
456
|
+
saverest: area.saverest,
|
|
457
|
+
surveySourceDate: area.survey_source_date,
|
|
458
|
+
surveySourceTitle: area.survey_source_title,
|
|
459
|
+
sourceScale: area.source_scale,
|
|
460
|
+
mappingScale: area.mapping_scale,
|
|
461
|
+
})),
|
|
462
|
+
classCodes: [...definitions.keys()],
|
|
463
|
+
weighting: { code: weighting.code, description: weighting.definition },
|
|
464
|
+
databasePath,
|
|
465
|
+
},
|
|
466
|
+
definitions,
|
|
467
|
+
bounds: areaRows.map((area) => ({
|
|
468
|
+
minLat: area.min_lat,
|
|
469
|
+
minLon: area.min_lon,
|
|
470
|
+
maxLat: area.max_lat,
|
|
471
|
+
maxLon: area.max_lon,
|
|
472
|
+
})),
|
|
473
|
+
}
|
|
474
|
+
}
|