@mailwoman/bdc 8.3.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 (87) hide show
  1. package/README.md +7 -0
  2. package/index.ts +2 -0
  3. package/out/index.d.ts +3 -0
  4. package/out/index.d.ts.map +1 -0
  5. package/out/index.js +3 -0
  6. package/out/index.js.map +1 -0
  7. package/out/schema.d.ts +138 -0
  8. package/out/schema.d.ts.map +1 -0
  9. package/out/schema.js +88 -0
  10. package/out/schema.js.map +1 -0
  11. package/out/sdk/build-bdc.d.ts +214 -0
  12. package/out/sdk/build-bdc.d.ts.map +1 -0
  13. package/out/sdk/build-bdc.js +500 -0
  14. package/out/sdk/build-bdc.js.map +1 -0
  15. package/out/sdk/buildings.d.ts +84 -0
  16. package/out/sdk/buildings.d.ts.map +1 -0
  17. package/out/sdk/buildings.js +75 -0
  18. package/out/sdk/buildings.js.map +1 -0
  19. package/out/sdk/client.d.ts +237 -0
  20. package/out/sdk/client.d.ts.map +1 -0
  21. package/out/sdk/client.js +430 -0
  22. package/out/sdk/client.js.map +1 -0
  23. package/out/sdk/common.d.ts +279 -0
  24. package/out/sdk/common.d.ts.map +1 -0
  25. package/out/sdk/common.js +162 -0
  26. package/out/sdk/common.js.map +1 -0
  27. package/out/sdk/data-collection.d.ts +244 -0
  28. package/out/sdk/data-collection.d.ts.map +1 -0
  29. package/out/sdk/data-collection.js +50 -0
  30. package/out/sdk/data-collection.js.map +1 -0
  31. package/out/sdk/download.d.ts +36 -0
  32. package/out/sdk/download.d.ts.map +1 -0
  33. package/out/sdk/download.js +76 -0
  34. package/out/sdk/download.js.map +1 -0
  35. package/out/sdk/filing-dates.d.ts +57 -0
  36. package/out/sdk/filing-dates.d.ts.map +1 -0
  37. package/out/sdk/filing-dates.js +50 -0
  38. package/out/sdk/filing-dates.js.map +1 -0
  39. package/out/sdk/filing-landscape.d.ts +124 -0
  40. package/out/sdk/filing-landscape.d.ts.map +1 -0
  41. package/out/sdk/filing-landscape.js +216 -0
  42. package/out/sdk/filing-landscape.js.map +1 -0
  43. package/out/sdk/index.d.ts +15 -0
  44. package/out/sdk/index.d.ts.map +1 -0
  45. package/out/sdk/index.js +15 -0
  46. package/out/sdk/index.js.map +1 -0
  47. package/out/sdk/list-files.d.ts +39 -0
  48. package/out/sdk/list-files.d.ts.map +1 -0
  49. package/out/sdk/list-files.js +26 -0
  50. package/out/sdk/list-files.js.map +1 -0
  51. package/out/sdk/location.d.ts +40 -0
  52. package/out/sdk/location.d.ts.map +1 -0
  53. package/out/sdk/location.js +23 -0
  54. package/out/sdk/location.js.map +1 -0
  55. package/out/sdk/nearest-infrastructure.d.ts +84 -0
  56. package/out/sdk/nearest-infrastructure.d.ts.map +1 -0
  57. package/out/sdk/nearest-infrastructure.js +84 -0
  58. package/out/sdk/nearest-infrastructure.js.map +1 -0
  59. package/out/sdk/parsing.d.ts +57 -0
  60. package/out/sdk/parsing.d.ts.map +1 -0
  61. package/out/sdk/parsing.js +87 -0
  62. package/out/sdk/parsing.js.map +1 -0
  63. package/out/sdk/plausibility.d.ts +256 -0
  64. package/out/sdk/plausibility.d.ts.map +1 -0
  65. package/out/sdk/plausibility.js +334 -0
  66. package/out/sdk/plausibility.js.map +1 -0
  67. package/out/sdk/technologies.d.ts +229 -0
  68. package/out/sdk/technologies.d.ts.map +1 -0
  69. package/out/sdk/technologies.js +217 -0
  70. package/out/sdk/technologies.js.map +1 -0
  71. package/package.json +97 -0
  72. package/schema.ts +172 -0
  73. package/sdk/build-bdc.ts +810 -0
  74. package/sdk/buildings.ts +87 -0
  75. package/sdk/client.ts +643 -0
  76. package/sdk/common.ts +363 -0
  77. package/sdk/data-collection.ts +268 -0
  78. package/sdk/download.ts +91 -0
  79. package/sdk/filing-dates.ts +88 -0
  80. package/sdk/filing-landscape.ts +287 -0
  81. package/sdk/index.ts +14 -0
  82. package/sdk/list-files.ts +72 -0
  83. package/sdk/location.ts +45 -0
  84. package/sdk/nearest-infrastructure.ts +129 -0
  85. package/sdk/parsing.ts +124 -0
  86. package/sdk/plausibility.ts +512 -0
  87. package/sdk/technologies.ts +258 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file FCC BDC filing-date discovery + vintage resolution.
6
+ *
7
+ * Re-homed from Nexus's `sync/fcc/bdc/filing-dates.ts` (relicense-by-copy, no provenance
8
+ * headers): the `$BCDClient`-bound `retrieveFilingDates()` → a plain function taking a
9
+ * {@linkcode BDCClient}.
10
+ *
11
+ * CACHING MOVED TO THE CLIENT. Both the Nexus original (one `<filingType>-dates.json` per filing
12
+ * type) and this port's first cut (`dataRootPath("bdc", "cache", "filing-dates.json")`, unfiltered)
13
+ * hand-rolled a JSON file cache here. `BDCClient` is built on `APIClient` and now carries an on-disk
14
+ * response cache of its own, so the hand-rolled one was the exact duplication that migration exists to
15
+ * remove — and it was worse than what replaced it: it had NO expiry, so a machine that resolved a
16
+ * vintage once would never see the next one FCC published without an explicit `skipCache`. The
17
+ * client's cache has a TTL chosen against the filing cadence, and `skipCache` now maps onto a
18
+ * per-request cache bypass with the same meaning it always had.
19
+ */
20
+
21
+ import type { BDCClient } from "./client.ts"
22
+ import type { BDCFilingDataType } from "./common.ts"
23
+
24
+ /**
25
+ * One entry from the FCC BDC's `/map/listAsOfDates` endpoint.
26
+ */
27
+ export interface FCCAsOfDateEntry {
28
+ data_type: BDCFilingDataType
29
+ /**
30
+ * @format date
31
+ */
32
+ as_of_date: string
33
+ }
34
+
35
+ interface ListAsOfDatesResponseBody {
36
+ data: FCCAsOfDateEntry[]
37
+ }
38
+
39
+ export interface RetrieveFilingDatesParams {
40
+ /**
41
+ * Which filing type's dates to return — the full cached/fetched set is filtered down to this type.
42
+ */
43
+ filingType: BDCFilingDataType
44
+ /**
45
+ * Bypass the client's response cache and always fetch fresh from the API. Defaults to `false`.
46
+ */
47
+ skipCache?: boolean
48
+ }
49
+
50
+ /**
51
+ * Retrieve the FCC BDC's available filing `as_of_date`s for a given filing type.
52
+ *
53
+ * One `listAsOfDates` call answers every filing type — the full unfiltered response is what the client caches, and this
54
+ * filters it down on read — so asking for a second filing type inside the TTL costs no request at all. At ten requests
55
+ * per minute that is worth six seconds each time.
56
+ */
57
+ export async function retrieveFilingDates(
58
+ client: BDCClient,
59
+ { filingType, skipCache = false }: RetrieveFilingDatesParams
60
+ ): Promise<FCCAsOfDateEntry[]> {
61
+ const body = await client.get<ListAsOfDatesResponseBody>("/map/listAsOfDates", undefined, { skipCache })
62
+
63
+ return body.data.filter((entry) => entry.data_type === filingType)
64
+ }
65
+
66
+ /**
67
+ * Pick the latest (most recent) `as_of_date` among `entries` for the given `dataType`.
68
+ *
69
+ * Comparison is by parsed `Date` value, not string ordering — the FCC's `as_of_date` values are `date`-formatted
70
+ * (`YYYY-MM-DD`), which happens to sort correctly as strings too, but comparing as dates is the honest contract.
71
+ */
72
+ export function resolveLatestVintage(entries: readonly FCCAsOfDateEntry[], dataType: BDCFilingDataType): string {
73
+ const matching = entries.filter((entry) => entry.data_type === dataType)
74
+
75
+ if (!matching.length) {
76
+ throw new Error(`resolveLatestVintage: no filing-date entries found for data_type "${dataType}"`)
77
+ }
78
+
79
+ let latest = matching[0]!
80
+
81
+ for (const entry of matching) {
82
+ if (new Date(entry.as_of_date).getTime() > new Date(latest.as_of_date).getTime()) {
83
+ latest = entry
84
+ }
85
+ }
86
+
87
+ return latest.as_of_date
88
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `filing_landscape` reader (2a Task 9) — the FOUR PRE-REGISTERED ACCEPTANCE GATES this whole phase
7
+ * is judged by. See `filing-landscape.test.ts` for the gate tests; this module is only the reader.
8
+ *
9
+ * Coverage check (the meaning-of-zero rule): a queried block counts as SURVEYED only when its res-6
10
+ * coverage cell is present in `layer_coverage` (via `readLayerCoverage`) — `undefined` means the area
11
+ * was never surveyed, and the block is reported in `unknown_block_count`, NEVER folded into a
12
+ * zero-filing claim.
13
+ *
14
+ * - For a `geoids` query, the candidate res-9 cell is read off the block's OWN `bdc_availability`
15
+ * rows — a geoid with zero rows has no derivable cell at all (never guessed, matching the builder's
16
+ * "unknown geoid" discipline in `build-bdc.ts`), so it falls straight to unknown.
17
+ * - For an `h3Cells` query, the caller supplies the res-9 cell directly, so coverage can be checked
18
+ * even for a cell with no filing rows of its own — a genuine "surveyed, zero providers here" result,
19
+ * the meaning-of-zero rule's POSITIVE case (covered but empty is not the same as never surveyed).
20
+ *
21
+ * Res-9 → res-6 parent reconstruction is deliberately NOT `@mailwoman/spatial`'s `expandH3Cell` — that
22
+ * helper's left-shift reconstruction only round-trips a short cell that was shortened AT resolution
23
+ * 15 (the address-id spine); fed a resolution-9 short cell it silently produces a full index
24
+ * `cellToParent` rejects (`Cell arguments had incompatible resolutions`), verified empirically while
25
+ * building this reader. The correct reconstruction for a short cell captured at a KNOWN resolution R
26
+ * is a straight concatenation, not a shift: a full 64-bit H3 cell index is always
27
+ * `"8" + <resolution nibble> + <52 bits of base-cell + digit path, trailing padding included>`, and
28
+ * the 48-bit "short" form (`shortCellToInt`/`shortenH3Cell`) already carries exactly those low 52 bits
29
+ * verbatim — so `"8" + R.toString(16) + shortHex.padStart(13, "0")` reassembles the identical full
30
+ * index `latLngToCell` would have produced at resolution R. See {@link res9ShortCellToRes6Parent}.
31
+ *
32
+ * This same formula is exactly what `build-bdc.ts` MUST use (and does, after fix round 1) to derive the
33
+ * coverage cell it writes at build time — H3's cell hierarchy is not geometrically exact, so a
34
+ * `latLngToCell(centroid, 6)` computed independently of the stored res-9 cell disagrees with
35
+ * `cellToParent(res9Cell, 6)` for a real fraction of points (verified ~6% over CONUS). Builder and
36
+ * reader deriving the res-6 parent differently was a real bug (fix round 1): a genuinely-surveyed block
37
+ * (real rows, real `layer_coverage` entry) could read back as `unknown_block_count` while its own rows
38
+ * still populated `filings` — a self-contradiction. `filings` is now scoped to units that PASS the
39
+ * coverage check (see the `surveyedUnits` accumulator below) precisely so that can't happen again: a
40
+ * block excluded from `surveyed_block_count` never contributes to `filings` either.
41
+ */
42
+
43
+ import type { DatabaseClient } from "@mailwoman/core/kysley/client"
44
+ import { readLayerCoverage, readLayerManifest, type LayerContractDatabase } from "@mailwoman/core/layers"
45
+ import { shortCellToInt, type H3Cell } from "@mailwoman/spatial"
46
+ import { cellToParent } from "h3-js"
47
+ import { sql, type Kysely } from "kysely"
48
+
49
+ import { BDC_COVERAGE_H3_RESOLUTION, BDC_H3_RESOLUTION, type BDCDatabase } from "../schema.ts"
50
+
51
+ /**
52
+ * Exactly one of `geoids` or `h3Cells` is required — `filingLandscape` throws otherwise.
53
+ */
54
+ export interface FilingLandscapeQuery {
55
+ geoids?: string[]
56
+ h3Cells?: number[]
57
+ }
58
+
59
+ /**
60
+ * One provider/technology/speed-bucket group's block count within the query — `block_count` is the number of DISTINCT
61
+ * queried blocks carrying this exact combination, never a raw row count. A block can carry multiple `bdc_availability`
62
+ * rows for the SAME (provider_id, technology_code) pair even in the DEFAULT (non-`includeLocationIDs`) build mode:
63
+ * `build-bdc.ts`'s materialize-time collapse merges to one row per distinct (geoid, provider_id, technology_code,
64
+ * speeds, low_latency, business_residential_code) tuple, not one row per (geoid, provider_id, technology_code) triple —
65
+ * so Broadband Serviceable Locations at the same triple with DIFFERING speeds/flags survive as separate rows and can
66
+ * land in different `speed_bucket`s here (see that file's docstring). This `block_count`'s DISTINCT is exactly what
67
+ * keeps that from double-counting the block itself when it does.
68
+ */
69
+ export interface ProviderFilingSummary {
70
+ provider_id: number
71
+ technology_code: number
72
+ speed_bucket: string
73
+ block_count: number
74
+ }
75
+
76
+ /**
77
+ * The queried landscape: ALWAYS vintage-stamped (from `layer_manifest.sourceVintage`) and ALWAYS reports its unknown
78
+ * blocks — `unknown_block_count` is reported, never zeroed, and never evidence of "no providers file here."
79
+ */
80
+ export interface FilingLandscape {
81
+ vintage: string
82
+ surveyed_block_count: number
83
+ unknown_block_count: number
84
+ filings: ProviderFilingSummary[]
85
+ }
86
+
87
+ /**
88
+ * `speed_bucket` label for a block whose `max_advertised_download_speed` is below
89
+ * {@link BDC_SPEED_BUCKET_THRESHOLD_25_MBPS} Mbps.
90
+ */
91
+ export const BDC_SPEED_BUCKET_UNDER_25 = "under-25"
92
+
93
+ /**
94
+ * `speed_bucket` label for `BDC_SPEED_BUCKET_THRESHOLD_25_MBPS <= speed < BDC_SPEED_BUCKET_THRESHOLD_100_MBPS`.
95
+ */
96
+ export const BDC_SPEED_BUCKET_25_100 = "25-100"
97
+
98
+ /**
99
+ * `speed_bucket` label for `BDC_SPEED_BUCKET_THRESHOLD_100_MBPS <= speed < BDC_SPEED_BUCKET_THRESHOLD_GIGABIT_MBPS`.
100
+ */
101
+ export const BDC_SPEED_BUCKET_100_1000 = "100-1000"
102
+
103
+ /**
104
+ * `speed_bucket` label for a block whose `max_advertised_download_speed` is at or above
105
+ * {@link BDC_SPEED_BUCKET_THRESHOLD_GIGABIT_MBPS} Mbps.
106
+ */
107
+ export const BDC_SPEED_BUCKET_GIGABIT = "gigabit"
108
+
109
+ /**
110
+ * Upper-exclusive Mbps boundary between {@link BDC_SPEED_BUCKET_UNDER_25} and {@link BDC_SPEED_BUCKET_25_100}.
111
+ */
112
+ export const BDC_SPEED_BUCKET_THRESHOLD_25_MBPS = 25
113
+
114
+ /**
115
+ * Upper-exclusive Mbps boundary between {@link BDC_SPEED_BUCKET_25_100} and {@link BDC_SPEED_BUCKET_100_1000}.
116
+ */
117
+ export const BDC_SPEED_BUCKET_THRESHOLD_100_MBPS = 100
118
+
119
+ /**
120
+ * Mbps boundary at/above which a block is bucketed {@link BDC_SPEED_BUCKET_GIGABIT}.
121
+ */
122
+ export const BDC_SPEED_BUCKET_THRESHOLD_GIGABIT_MBPS = 1000
123
+
124
+ /**
125
+ * Pure mirror of the SQL `CASE` expression below ({@link speedBucketCaseSQL}) — same thresholds, same labels, exported
126
+ * so the boundary logic can be asserted directly without a database round trip.
127
+ */
128
+ export function speedBucketForDownloadSpeed(maxAdvertisedDownloadSpeed: number): string {
129
+ if (maxAdvertisedDownloadSpeed < BDC_SPEED_BUCKET_THRESHOLD_25_MBPS) return BDC_SPEED_BUCKET_UNDER_25
130
+
131
+ if (maxAdvertisedDownloadSpeed < BDC_SPEED_BUCKET_THRESHOLD_100_MBPS) return BDC_SPEED_BUCKET_25_100
132
+
133
+ if (maxAdvertisedDownloadSpeed < BDC_SPEED_BUCKET_THRESHOLD_GIGABIT_MBPS) return BDC_SPEED_BUCKET_100_1000
134
+
135
+ return BDC_SPEED_BUCKET_GIGABIT
136
+ }
137
+
138
+ /**
139
+ * The same bucketing as {@link speedBucketForDownloadSpeed}, expressed as a `CASE` over `max_advertised_download_speed`
140
+ * so the GROUP BY below can group directly on the bucket.
141
+ */
142
+ const speedBucketCaseSQL = sql<string>`CASE
143
+ WHEN max_advertised_download_speed < ${BDC_SPEED_BUCKET_THRESHOLD_25_MBPS} THEN ${BDC_SPEED_BUCKET_UNDER_25}
144
+ WHEN max_advertised_download_speed < ${BDC_SPEED_BUCKET_THRESHOLD_100_MBPS} THEN ${BDC_SPEED_BUCKET_25_100}
145
+ WHEN max_advertised_download_speed < ${BDC_SPEED_BUCKET_THRESHOLD_GIGABIT_MBPS} THEN ${BDC_SPEED_BUCKET_100_1000}
146
+ ELSE ${BDC_SPEED_BUCKET_GIGABIT}
147
+ END`
148
+
149
+ /**
150
+ * `BDCDatabase extends LayerContractDatabase` structurally, but Kysely's `transaction()` makes `Kysely<DB>` INVARIANT
151
+ * in `DB` — same narrowing cast as `build-bdc.ts`'s `asContractDB`.
152
+ */
153
+ function asContractDB(kdb: DatabaseClient<BDCDatabase>): Kysely<LayerContractDatabase> {
154
+ return kdb as unknown as Kysely<LayerContractDatabase>
155
+ }
156
+
157
+ /**
158
+ * Reconstruct the res-6 ancestor of a res-9 short-cell int WITHOUT a centroid — see the module docstring for why this
159
+ * isn't `@mailwoman/spatial`'s `expandH3Cell`. Exported so tests can assert this agrees, cell-for-cell, with
160
+ * `build-bdc.ts`'s own coverage-cell derivation (the two MUST share this exact formula — see that file's docstring).
161
+ */
162
+ export function res9ShortCellToRes6Parent(h3CellShortInt: number): number {
163
+ const shortHex = h3CellShortInt.toString(16).padStart(13, "0")
164
+ const fullCell = `8${BDC_H3_RESOLUTION.toString(16)}${shortHex}` as H3Cell
165
+ const parentCell = cellToParent(fullCell, BDC_COVERAGE_H3_RESOLUTION) as H3Cell
166
+
167
+ return shortCellToInt(parentCell)
168
+ }
169
+
170
+ /**
171
+ * Read the provider/technology/speed-bucket filing census over a set of queried blocks (by `geoid` or by `h3Cell`,
172
+ * never both). Always vintage-stamped; always throws on a broken manifest rather than answering unstamped; a queried
173
+ * block with no coverage evidence is reported in `unknown_block_count` and never folded into a zero-filing claim.
174
+ */
175
+ export async function filingLandscape(
176
+ db: DatabaseClient<BDCDatabase>,
177
+ query: FilingLandscapeQuery
178
+ ): Promise<FilingLandscape> {
179
+ const queryModeCount = (query.geoids ? 1 : 0) + (query.h3Cells ? 1 : 0)
180
+
181
+ if (queryModeCount !== 1) {
182
+ throw new Error("filingLandscape: exactly one of `geoids` or `h3Cells` is required")
183
+ }
184
+
185
+ // `[]` is truthy, so it passes the XOR check above undetected — without this guard an empty array sails
186
+ // straight through to a vacuous all-zero landscape (surveyed_block_count: 0, unknown_block_count: 0, no
187
+ // filings), which reads exactly like a real "nothing queried" answer instead of the malformed-query error
188
+ // it should be. Checked before the manifest read so a bad query fails fast without even opening the db further.
189
+ if (!(query.geoids ?? query.h3Cells)!.length) {
190
+ throw new Error("filingLandscape: `geoids`/`h3Cells` must not be an empty array")
191
+ }
192
+
193
+ // Read (and validate) the manifest FIRST — a broken/missing manifest must throw before any block is
194
+ // classified, never fall through to an "unstamped" answer (gate 4).
195
+ const manifest = await readLayerManifest(asContractDB(db))
196
+
197
+ const requestedUnits: ReadonlyArray<string | number> = query.geoids ?? query.h3Cells!
198
+ const unitColumn = query.geoids ? ("geoid" as const) : ("h3_cell" as const)
199
+
200
+ // Candidate res-9 cell per requested unit. `h3Cells` queries already carry the cell directly;
201
+ // `geoids` queries can only derive one from the block's OWN rows — a geoid with none has no
202
+ // candidate at all (never guessed), so it falls straight to unknown below.
203
+ const candidateCellByUnit = new Map<string | number, number>()
204
+
205
+ if (query.geoids) {
206
+ const rows = await db
207
+ .selectFrom("bdc_availability")
208
+ .select(["geoid", "h3_cell"])
209
+ .where("geoid", "in", query.geoids)
210
+ .groupBy(["geoid", "h3_cell"])
211
+ .execute()
212
+
213
+ for (const row of rows) {
214
+ candidateCellByUnit.set(row.geoid, row.h3_cell)
215
+ }
216
+ } else {
217
+ for (const cell of query.h3Cells!) {
218
+ candidateCellByUnit.set(cell, cell)
219
+ }
220
+ }
221
+
222
+ let surveyedBlockCount = 0
223
+ let unknownBlockCount = 0
224
+ // Only units that PASS the coverage check feed the census below — a unit with rows but no coverage evidence
225
+ // (a corrupted/inconsistent db — see filing-landscape.test.ts's "coverage row deleted" gate) is `unknown`, and
226
+ // its rows must not leak into `filings` either: `surveyed_block_count` and the blocks backing `filings` must
227
+ // always agree, or a caller cross-referencing the two gets a contradiction (an "unknown" block whose filings
228
+ // still show up looks exactly like the false-negative bug this reader exists to prevent).
229
+ const surveyedUnits: Array<string | number> = []
230
+
231
+ for (const unit of requestedUnits) {
232
+ const candidateCell = candidateCellByUnit.get(unit)
233
+
234
+ if (candidateCell === undefined) {
235
+ unknownBlockCount++
236
+
237
+ continue
238
+ }
239
+
240
+ const res6Parent = res9ShortCellToRes6Parent(candidateCell)
241
+ const coverage = await readLayerCoverage(asContractDB(db), res6Parent)
242
+
243
+ if (coverage === undefined) {
244
+ unknownBlockCount++
245
+ } else {
246
+ surveyedBlockCount++
247
+ surveyedUnits.push(unit)
248
+ }
249
+ }
250
+
251
+ let filings: ProviderFilingSummary[] = []
252
+
253
+ if (surveyedUnits.length) {
254
+ let filingsQuery = db
255
+ .selectFrom("bdc_availability")
256
+ .select([
257
+ "provider_id",
258
+ "technology_code",
259
+ speedBucketCaseSQL.as("speed_bucket"),
260
+ (eb) => eb.fn.count<number>(unitColumn).distinct().as("block_count"),
261
+ ])
262
+ .groupBy(["provider_id", "technology_code", speedBucketCaseSQL])
263
+ .orderBy("provider_id")
264
+ .orderBy("technology_code")
265
+ .orderBy(speedBucketCaseSQL)
266
+
267
+ filingsQuery = query.geoids
268
+ ? filingsQuery.where("geoid", "in", surveyedUnits as string[])
269
+ : filingsQuery.where("h3_cell", "in", surveyedUnits as number[])
270
+
271
+ const filingsRows = await filingsQuery.execute()
272
+
273
+ filings = filingsRows.map((row) => ({
274
+ provider_id: row.provider_id,
275
+ technology_code: row.technology_code,
276
+ speed_bucket: row.speed_bucket,
277
+ block_count: row.block_count,
278
+ }))
279
+ }
280
+
281
+ return {
282
+ vintage: manifest.sourceVintage,
283
+ surveyed_block_count: surveyedBlockCount,
284
+ unknown_block_count: unknownBlockCount,
285
+ filings,
286
+ }
287
+ }
package/sdk/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from "./build-bdc.ts"
2
+ export * from "./buildings.ts"
3
+ export * from "./client.ts"
4
+ export * from "./common.ts"
5
+ export * from "./data-collection.ts"
6
+ export * from "./download.ts"
7
+ export * from "./filing-dates.ts"
8
+ export * from "./filing-landscape.ts"
9
+ export * from "./list-files.ts"
10
+ export * from "./location.ts"
11
+ export * from "./nearest-infrastructure.ts"
12
+ export * from "./parsing.ts"
13
+ export * from "./plausibility.ts"
14
+ export * from "./technologies.ts"
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file FCC BDC availability-file listing.
6
+ *
7
+ * Re-homed from Nexus's `sync/fcc/bdc/list-files.ts` (relicense-by-copy, no provenance headers):
8
+ * the `URLRoutePattern`-compiled route → a plain template literal (this port has no routing
9
+ * dependency), the `$BCDClient`-bound `retrieveAvailabilityFiles()` → a plain function taking a
10
+ * {@linkcode BDCClient}. Raw entries are parsed via Task 4's `parseRawBDCFile` and returned sorted
11
+ * ascending by revision (`compareRevisionAsc`) rather than in API order.
12
+ */
13
+
14
+ import type { BDCClient } from "./client.ts"
15
+ import {
16
+ compareRevisionAsc,
17
+ parseRawBDCFile,
18
+ type BDCFile,
19
+ type BDCFileCategory,
20
+ type BDCProviderSubCategory,
21
+ type BDCStateSubCategory,
22
+ type BDCSummarySubCategory,
23
+ type RawBDCFile,
24
+ } from "./common.ts"
25
+
26
+ export interface RetrieveProviderAvailabilityFilesParams {
27
+ /**
28
+ * The filing's `as_of_date`, e.g. from {@linkcode file://./filing-dates.ts#resolveLatestVintage}.
29
+ */
30
+ asOfDate: string
31
+ category: typeof BDCFileCategory.Provider
32
+ subcategory: BDCProviderSubCategory
33
+ }
34
+
35
+ export interface RetrieveStateAvailabilityFilesParams {
36
+ asOfDate: string
37
+ category: typeof BDCFileCategory.State
38
+ subcategory: BDCStateSubCategory
39
+ }
40
+
41
+ export interface RetrieveSummaryAvailabilityFilesParams {
42
+ asOfDate: string
43
+ category: typeof BDCFileCategory.Summary
44
+ subcategory: BDCSummarySubCategory
45
+ }
46
+
47
+ export type RetrieveAvailabilityFilesParams =
48
+ | RetrieveProviderAvailabilityFilesParams
49
+ | RetrieveStateAvailabilityFilesParams
50
+ | RetrieveSummaryAvailabilityFilesParams
51
+
52
+ interface ListAvailabilityDataResponseBody {
53
+ data: RawBDCFile[]
54
+ }
55
+
56
+ /**
57
+ * List the BDC availability files for a given `as_of_date`/category/subcategory, parsed into {@linkcode BDCFile}
58
+ * records and sorted ascending by revision date ({@linkcode compareRevisionAsc}).
59
+ */
60
+ export async function retrieveAvailabilityFiles(
61
+ client: BDCClient,
62
+ { asOfDate, category, subcategory }: RetrieveAvailabilityFilesParams
63
+ ): Promise<BDCFile[]> {
64
+ const pathname = `/map/downloads/listAvailabilityData/${encodeURIComponent(asOfDate)}`
65
+
66
+ const body = await client.get<ListAvailabilityDataResponseBody>(pathname, {
67
+ category,
68
+ subcategory,
69
+ })
70
+
71
+ return body.data.map(parseRawBDCFile).toSorted(compareRevisionAsc)
72
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ import type { Tagged } from "type-fest"
8
+
9
+ /**
10
+ * Matches a 10-digit FCC Broadband Serviceable Location ID, leading zeros included.
11
+ *
12
+ * @see {@linkcode isBroadbandServicableLocationID} for the type-predicate that uses this.
13
+ */
14
+ export const BROADBAND_SERVICABLE_LOCATION_INPUT_PATTERN = /^\d{10}$/
15
+
16
+ /**
17
+ * Unique ID for the Fabric location.
18
+ *
19
+ * The BSL ID remains persistent version to version when newer evidence indicates the position of the serviceable
20
+ * location or the presence of a serviceable location on a single location parcel is not significantly changed as
21
+ * compared to the prior version.
22
+ *
23
+ * - While IDs persist across Fabric versions does not mean that the latitude and longitude are unchanged.
24
+ * - An ID will remain consistent across versions when a different building is selected on a single parcel.
25
+ *
26
+ * Typed as a `string`, not a `number` — the FCC's own IDs are 10-digit zero-padded strings, and leading zeros make
27
+ * integer storage lossy.
28
+ *
29
+ * @type string
30
+ * @title Broadband Servicable Location ID
31
+ * @pattern ^\d{10}$
32
+ */
33
+ export type BroadbandServicableLocationID = Tagged<string, "BroadbandServicableLocationID">
34
+
35
+ /**
36
+ * Type-predicate for checking if a value appears to be a valid Broadband Servicable Location ID.
37
+ *
38
+ * Accepts only a 10-digit string. A `number` input is always rejected, even if its digits would otherwise match —
39
+ * numeric storage would silently drop meaningful leading zeros.
40
+ *
41
+ * @internal
42
+ */
43
+ export function isBroadbandServicableLocationID(input: unknown): input is BroadbandServicableLocationID {
44
+ return typeof input === "string" && BROADBAND_SERVICABLE_LOCATION_INPUT_PATTERN.test(input)
45
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `nearestInfrastructure` — a coverage-paired k-nearest read over the telecom-infrastructure POI
7
+ * categories (`telecom_exchange`/`tower_comms`, poi-taxonomy task 1 + the `--source osm` extractor,
8
+ * 2b tasks 1–2) that a caller can join against ANY layer's own `layer_coverage` survey-completeness
9
+ * table (2b task 4, decision 7). Typical caller: a BDC filing scorer that wants "what's the nearest
10
+ * real infrastructure to this claimed Broadband Serviceable Location, and does OUR layer even have
11
+ * survey evidence for that area" in one call.
12
+ *
13
+ * Two deviations from the task brief's literal sketch, both forced by what `poi-lookup.ts` and
14
+ * `@mailwoman/core/layers` actually expose (documented per the brief's "implementer picks and
15
+ * documents" instruction):
16
+ *
17
+ * - **`poiLookup` is an already-open {@link POILookup}, not a `POILookupOpts` this function
18
+ * constructs itself.** `POILookup`'s constructor eagerly loads the poi-taxonomy category dictionary
19
+ * and prepares three statements (see `poi-lookup.ts`) — reconstructing that per call would mean
20
+ * re-opening the SQLite handle and re-running the dictionary `SELECT` on every single
21
+ * `nearestInfrastructure` invocation. A scorer calling this once per filing candidate wants to open
22
+ * `poi.db` ONCE and reuse the same `POILookup` — this wrapper takes that shape: the caller owns
23
+ * `POILookup`'s open/dispose lifecycle (`using poiLookup = new POILookup(...)`), we just call
24
+ * `.search()` on it.
25
+ * - **`nearestInfrastructure` is `async`, not the brief's sync sketch.** `readLayerCoverage`
26
+ * (`@mailwoman/core/layers`) is `Promise`-returning — every layer-contract read in this codebase is
27
+ * (`readLayerManifest`, `filingLandscape` itself) — so pairing each POI hit with its coverage cell
28
+ * means awaiting one `readLayerCoverage` call per hit. A sync signature can't await that.
29
+ *
30
+ * {@link res9ShortCellToRes6Parent} (exported by `filing-landscape.ts`; see that file's docstring for
31
+ * why the res-9 → res-6 reconstruction is a straight concatenation, not `@mailwoman/spatial`'s
32
+ * `expandH3Cell`) turns each hit's res-9 cell into the res-6 cell every layer in this repo aggregates
33
+ * coverage at (poi.db's own convention; `bdc.db`'s `BDC_COVERAGE_H3_RESOLUTION` matches it
34
+ * deliberately — see `schema.ts`). `POI_H3_RESOLUTION` (`poi-lookup.ts`) is ALSO 9, so the two layers'
35
+ * spines agree without this module hardcoding a resolution of its own.
36
+ */
37
+
38
+ import type { DatabaseClient } from "@mailwoman/core/kysley/client"
39
+ import { readLayerCoverage, type CoverageCell, type LayerContractDatabase } from "@mailwoman/core/layers"
40
+ import { POI_H3_RESOLUTION, type POILookup } from "@mailwoman/resolver-wof-sqlite/poi-lookup"
41
+ import { shortCellToInt, type H3Cell, type PointLiteral } from "@mailwoman/spatial"
42
+ import { latLngToCell } from "h3-js"
43
+
44
+ import { res9ShortCellToRes6Parent } from "./filing-landscape.ts"
45
+
46
+ /**
47
+ * Ring budget default for {@link nearestInfrastructure} — wider than `POILookup`'s own internal `DEFAULT_MAX_RINGS`
48
+ * (16, ≈5.4 km) because telecom infrastructure (central offices, comm towers) is sparser per-area than poi.db's dense
49
+ * categories (cafes, etc.): 32 res-9 rings ≈ 11 km, a BDC-block-scale search radius that gives real infrastructure room
50
+ * to be found without over-restricting callers who DO want a tighter budget via `options.maxRings`.
51
+ */
52
+ export const NEAREST_INFRASTRUCTURE_DEFAULT_MAX_RINGS = 32
53
+
54
+ /**
55
+ * One k-nearest telecom-infrastructure hit, paired with the res-6 coverage cell it falls in. `coverage: undefined`
56
+ * means `contractDB`'s layer has never surveyed that area (the meaning-of-zero rule; see `@mailwoman/core/layers`) —
57
+ * never conflate it with a covered-but-empty cell.
58
+ */
59
+ export interface InfrastructureHit {
60
+ categoryID: string
61
+ name: string | null
62
+ distanceM: number
63
+ /**
64
+ * Res-9 short H3 cell of the hit ITSELF — not the (coarser) coverage cell; see `coverage.h3Cell` for that.
65
+ */
66
+ h3Cell: number
67
+ coverage: CoverageCell | undefined
68
+ }
69
+
70
+ export interface NearestInfrastructureOptions {
71
+ center: PointLiteral
72
+ /**
73
+ * Poi-taxonomy category ids to search — fans out to `POILookup.search`'s `categoryIDs` (union across every resolved
74
+ * leaf, nearest-first). Typically `["telecom_exchange", "tower_comms"]`.
75
+ */
76
+ categoryIDs: string[]
77
+ limit?: number
78
+ /**
79
+ * Ring budget. Default {@link NEAREST_INFRASTRUCTURE_DEFAULT_MAX_RINGS} (32) — NOT `POILookup`'s own internal default
80
+ * (16); see this module's docstring.
81
+ */
82
+ maxRings?: number
83
+ }
84
+
85
+ /**
86
+ * K-nearest telecom-infrastructure POIs from `options.center`, each paired with the coverage cell it falls in per
87
+ * `contractDB`'s own `layer_coverage` table. Never throws on a sparse result — no infrastructure within `maxRings`, or
88
+ * every `categoryIDs` entry unresolvable against `poiLookup`'s dictionary — returns `[]`, the same discipline
89
+ * `POILookup.search` itself follows.
90
+ */
91
+ export async function nearestInfrastructure(
92
+ poiLookup: POILookup,
93
+ contractDB: DatabaseClient<LayerContractDatabase>,
94
+ options: NearestInfrastructureOptions
95
+ ): Promise<InfrastructureHit[]> {
96
+ const [longitude, latitude] = options.center.coordinates
97
+
98
+ const hits = poiLookup.search({
99
+ categoryIDs: options.categoryIDs,
100
+ center: { latitude, longitude },
101
+ limit: options.limit,
102
+ maxRings: options.maxRings ?? NEAREST_INFRASTRUCTURE_DEFAULT_MAX_RINGS,
103
+ })
104
+
105
+ const infrastructureHits: InfrastructureHit[] = []
106
+
107
+ for (const hit of hits) {
108
+ if (hit.categoryID === null) {
109
+ // `categoryIDs` above always constrains the k-ring probe to real (non-zero) category ids (see
110
+ // POILookup#searchKRing), so a hit here always carries the category it was found under — this
111
+ // can't happen without a corrupted poi.db. Guard rather than silently coerce to "".
112
+ throw new Error(`nearestInfrastructure: hit ${JSON.stringify(hit.name)} has no categoryID`)
113
+ }
114
+
115
+ const h3Cell = shortCellToInt(latLngToCell(hit.latitude, hit.longitude, POI_H3_RESOLUTION) as H3Cell)
116
+ const coverage = await readLayerCoverage(contractDB, res9ShortCellToRes6Parent(h3Cell))
117
+
118
+ infrastructureHits.push({
119
+ categoryID: hit.categoryID,
120
+ name: hit.name,
121
+ // `center` is always supplied above, so POILookup.search always attaches distanceM.
122
+ distanceM: hit.distanceM!,
123
+ h3Cell,
124
+ coverage,
125
+ })
126
+ }
127
+
128
+ return infrastructureHits
129
+ }