@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.
- package/README.md +7 -0
- package/index.ts +2 -0
- package/out/index.d.ts +3 -0
- package/out/index.d.ts.map +1 -0
- package/out/index.js +3 -0
- package/out/index.js.map +1 -0
- package/out/schema.d.ts +138 -0
- package/out/schema.d.ts.map +1 -0
- package/out/schema.js +88 -0
- package/out/schema.js.map +1 -0
- package/out/sdk/build-bdc.d.ts +214 -0
- package/out/sdk/build-bdc.d.ts.map +1 -0
- package/out/sdk/build-bdc.js +500 -0
- package/out/sdk/build-bdc.js.map +1 -0
- package/out/sdk/buildings.d.ts +84 -0
- package/out/sdk/buildings.d.ts.map +1 -0
- package/out/sdk/buildings.js +75 -0
- package/out/sdk/buildings.js.map +1 -0
- package/out/sdk/client.d.ts +237 -0
- package/out/sdk/client.d.ts.map +1 -0
- package/out/sdk/client.js +430 -0
- package/out/sdk/client.js.map +1 -0
- package/out/sdk/common.d.ts +279 -0
- package/out/sdk/common.d.ts.map +1 -0
- package/out/sdk/common.js +162 -0
- package/out/sdk/common.js.map +1 -0
- package/out/sdk/data-collection.d.ts +244 -0
- package/out/sdk/data-collection.d.ts.map +1 -0
- package/out/sdk/data-collection.js +50 -0
- package/out/sdk/data-collection.js.map +1 -0
- package/out/sdk/download.d.ts +36 -0
- package/out/sdk/download.d.ts.map +1 -0
- package/out/sdk/download.js +76 -0
- package/out/sdk/download.js.map +1 -0
- package/out/sdk/filing-dates.d.ts +57 -0
- package/out/sdk/filing-dates.d.ts.map +1 -0
- package/out/sdk/filing-dates.js +50 -0
- package/out/sdk/filing-dates.js.map +1 -0
- package/out/sdk/filing-landscape.d.ts +124 -0
- package/out/sdk/filing-landscape.d.ts.map +1 -0
- package/out/sdk/filing-landscape.js +216 -0
- package/out/sdk/filing-landscape.js.map +1 -0
- package/out/sdk/index.d.ts +15 -0
- package/out/sdk/index.d.ts.map +1 -0
- package/out/sdk/index.js +15 -0
- package/out/sdk/index.js.map +1 -0
- package/out/sdk/list-files.d.ts +39 -0
- package/out/sdk/list-files.d.ts.map +1 -0
- package/out/sdk/list-files.js +26 -0
- package/out/sdk/list-files.js.map +1 -0
- package/out/sdk/location.d.ts +40 -0
- package/out/sdk/location.d.ts.map +1 -0
- package/out/sdk/location.js +23 -0
- package/out/sdk/location.js.map +1 -0
- package/out/sdk/nearest-infrastructure.d.ts +84 -0
- package/out/sdk/nearest-infrastructure.d.ts.map +1 -0
- package/out/sdk/nearest-infrastructure.js +84 -0
- package/out/sdk/nearest-infrastructure.js.map +1 -0
- package/out/sdk/parsing.d.ts +57 -0
- package/out/sdk/parsing.d.ts.map +1 -0
- package/out/sdk/parsing.js +87 -0
- package/out/sdk/parsing.js.map +1 -0
- package/out/sdk/plausibility.d.ts +256 -0
- package/out/sdk/plausibility.d.ts.map +1 -0
- package/out/sdk/plausibility.js +334 -0
- package/out/sdk/plausibility.js.map +1 -0
- package/out/sdk/technologies.d.ts +229 -0
- package/out/sdk/technologies.d.ts.map +1 -0
- package/out/sdk/technologies.js +217 -0
- package/out/sdk/technologies.js.map +1 -0
- package/package.json +97 -0
- package/schema.ts +172 -0
- package/sdk/build-bdc.ts +810 -0
- package/sdk/buildings.ts +87 -0
- package/sdk/client.ts +643 -0
- package/sdk/common.ts +363 -0
- package/sdk/data-collection.ts +268 -0
- package/sdk/download.ts +91 -0
- package/sdk/filing-dates.ts +88 -0
- package/sdk/filing-landscape.ts +287 -0
- package/sdk/index.ts +14 -0
- package/sdk/list-files.ts +72 -0
- package/sdk/location.ts +45 -0
- package/sdk/nearest-infrastructure.ts +129 -0
- package/sdk/parsing.ts +124 -0
- package/sdk/plausibility.ts +512 -0
- 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
|
+
}
|
package/sdk/location.ts
ADDED
|
@@ -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
|
+
}
|