@mailwoman/corpus 10.5.0 → 10.7.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/data/PROVENANCE.md +24 -0
- package/data/address-source-register.json +18948 -8757
- package/data/corpus-source-inputs.json +4756 -0
- package/data/license-decisions.json +7260 -0
- package/data/licenses/whosonfirst-licenses.md +3 -2
- package/data/reviewed-ve-postcode-tuples.json +5 -72
- package/data/source-additions.json +2450 -0
- package/data/source-resolutions.json +87 -0
- package/data/training-manifests/v0.7.10-gb-county.effective.json +2335 -0
- package/data/training-manifests/v0.7.10-gb-county.json +1132 -0
- package/data/training-manifests/v0.7.2-address-systems.effective.json +3 -2
- package/data/training-manifests/v0.7.7-no-ppd.json +550 -0
- package/data/training-manifests/v0.7.8-rights-clean.json +538 -0
- package/data/training-manifests/v0.7.9-country-gap.json +1132 -0
- package/data/us-situs-sources.json +128 -0
- package/lib/adapters/cordis/adapter.ts +1 -1
- package/lib/adapters/libpostal-dictionary.ts +23 -3
- package/lib/adapters/osm/adapter.ts +42 -11
- package/lib/adapters.ts +14 -0
- package/lib/az/adapters/opendata/adapter.ts +389 -0
- package/lib/bs/adapters/gbpa/adapter.ts +248 -0
- package/lib/build/types.ts +16 -0
- package/lib/build.ts +21 -0
- package/lib/ee/adapters/ads/adapter.ts +18 -0
- package/lib/fo/adapters/adressur/adapter.ts +242 -0
- package/lib/gb/adapters/os-open/adapter.ts +14 -2
- package/lib/gb/county.ts +59 -0
- package/lib/gb/os-open/{open-names.ts → open/names.ts} +41 -0
- package/lib/gu/adapters/bsp/adapter.ts +434 -0
- package/lib/im/adapters/land-registry/adapter.ts +340 -0
- package/lib/international/recipes/locale.ts +164 -19
- package/lib/li/adapters/llv/adapter.ts +246 -0
- package/lib/recipes/bare/postcode.ts +10 -34
- package/lib/recipes/country-balanced.ts +95 -78
- package/lib/recipes/intersection.ts +22 -30
- package/lib/recipes/po/box/cedex/recipe.ts +8 -5
- package/lib/recipes/po/box/cedex/sources.ts +2 -23
- package/lib/recipes/scaffold.ts +28 -3
- package/lib/recipes/sources.ts +112 -1
- package/lib/recipes/street/affix.ts +17 -29
- package/lib/recipes/sub/venue/context.ts +33 -9
- package/lib/recipes/sub/venue/sources.ts +54 -13
- package/lib/recipes/sub/venue.ts +68 -23
- package/lib/recipes/unit.ts +20 -7
- package/lib/registers.ts +33 -0
- package/lib/source-register/corpus-inputs.ts +528 -0
- package/lib/source-register/effective-manifest.ts +37 -5
- package/lib/source-register/types.ts +20 -0
- package/lib/south-asia/recipes/register.ts +1 -1
- package/lib/tools/overlay/manifest.ts +109 -1
- package/lib/tools/source-register/build.ts +47 -1
- package/lib/tools/variety-sample.ts +633 -0
- package/lib/us/adapters/usgov/nad/adapter.ts +39 -10
- package/lib/us/recipes/skeleton-sources.ts +156 -0
- package/lib/us/tools/nad-skeletons.ts +395 -0
- package/lib/uy/adapters/ide/adapter.ts +275 -0
- package/lib/ve/recipes/reviewed-postcode-tail.ts +29 -6
- package/out/adapters/cordis/adapter.d.ts +1 -1
- package/out/adapters/cordis/adapter.js +1 -1
- package/out/adapters/libpostal-dictionary.d.ts +9 -0
- package/out/adapters/libpostal-dictionary.d.ts.map +1 -1
- package/out/adapters/libpostal-dictionary.js +19 -3
- package/out/adapters/libpostal-dictionary.js.map +1 -1
- package/out/adapters/osm/adapter.d.ts +12 -1
- package/out/adapters/osm/adapter.d.ts.map +1 -1
- package/out/adapters/osm/adapter.js +30 -13
- package/out/adapters/osm/adapter.js.map +1 -1
- package/out/adapters.d.ts.map +1 -1
- package/out/adapters.js +14 -0
- package/out/adapters.js.map +1 -1
- package/out/az/adapters/opendata/adapter.d.ts +88 -0
- package/out/az/adapters/opendata/adapter.d.ts.map +1 -0
- package/out/az/adapters/opendata/adapter.js +282 -0
- package/out/az/adapters/opendata/adapter.js.map +1 -0
- package/out/bs/adapters/gbpa/adapter.d.ts +64 -0
- package/out/bs/adapters/gbpa/adapter.d.ts.map +1 -0
- package/out/bs/adapters/gbpa/adapter.js +187 -0
- package/out/bs/adapters/gbpa/adapter.js.map +1 -0
- package/out/build/types.d.ts +15 -0
- package/out/build/types.d.ts.map +1 -1
- package/out/build/types.js.map +1 -1
- package/out/build.d.ts.map +1 -1
- package/out/build.js +17 -0
- package/out/build.js.map +1 -1
- package/out/ee/adapters/ads/adapter.d.ts +6 -0
- package/out/ee/adapters/ads/adapter.d.ts.map +1 -1
- package/out/ee/adapters/ads/adapter.js +16 -0
- package/out/ee/adapters/ads/adapter.js.map +1 -1
- package/out/fo/adapters/adressur/adapter.d.ts +63 -0
- package/out/fo/adapters/adressur/adapter.d.ts.map +1 -0
- package/out/fo/adapters/adressur/adapter.js +177 -0
- package/out/fo/adapters/adressur/adapter.js.map +1 -0
- package/out/gb/adapters/os-open/adapter.d.ts +10 -2
- package/out/gb/adapters/os-open/adapter.d.ts.map +1 -1
- package/out/gb/adapters/os-open/adapter.js +13 -2
- package/out/gb/adapters/os-open/adapter.js.map +1 -1
- package/out/gb/county.d.ts +30 -0
- package/out/gb/county.d.ts.map +1 -0
- package/out/gb/county.js +51 -0
- package/out/gb/county.js.map +1 -0
- package/out/gb/os-open/{open-names.d.ts → open/names.d.ts} +18 -1
- package/out/gb/os-open/open/names.d.ts.map +1 -0
- package/out/gb/os-open/{open-names.js → open/names.js} +20 -1
- package/out/gb/os-open/open/names.js.map +1 -0
- package/out/gb/os-open/{open-roads.d.ts → open/roads.d.ts} +1 -1
- package/out/gb/os-open/open/roads.d.ts.map +1 -0
- package/out/gb/os-open/{open-roads.js → open/roads.js} +1 -1
- package/out/gb/os-open/open/roads.js.map +1 -0
- package/out/gu/adapters/bsp/adapter.d.ts +83 -0
- package/out/gu/adapters/bsp/adapter.d.ts.map +1 -0
- package/out/gu/adapters/bsp/adapter.js +346 -0
- package/out/gu/adapters/bsp/adapter.js.map +1 -0
- package/out/im/adapters/land-registry/adapter.d.ts +68 -0
- package/out/im/adapters/land-registry/adapter.d.ts.map +1 -0
- package/out/im/adapters/land-registry/adapter.js +261 -0
- package/out/im/adapters/land-registry/adapter.js.map +1 -0
- package/out/international/recipes/locale.d.ts +24 -4
- package/out/international/recipes/locale.d.ts.map +1 -1
- package/out/international/recipes/locale.js +107 -15
- package/out/international/recipes/locale.js.map +1 -1
- package/out/li/adapters/llv/adapter.d.ts +69 -0
- package/out/li/adapters/llv/adapter.d.ts.map +1 -0
- package/out/li/adapters/llv/adapter.js +181 -0
- package/out/li/adapters/llv/adapter.js.map +1 -0
- package/out/recipes/bare/postcode.d.ts.map +1 -1
- package/out/recipes/bare/postcode.js +9 -30
- package/out/recipes/bare/postcode.js.map +1 -1
- package/out/recipes/country-balanced.d.ts.map +1 -1
- package/out/recipes/country-balanced.js +82 -73
- package/out/recipes/country-balanced.js.map +1 -1
- package/out/recipes/intersection.d.ts +3 -2
- package/out/recipes/intersection.d.ts.map +1 -1
- package/out/recipes/intersection.js +20 -26
- package/out/recipes/intersection.js.map +1 -1
- package/out/recipes/po/box/cedex/recipe.d.ts.map +1 -1
- package/out/recipes/po/box/cedex/recipe.js +5 -4
- package/out/recipes/po/box/cedex/recipe.js.map +1 -1
- package/out/recipes/po/box/cedex/sources.d.ts +2 -45
- package/out/recipes/po/box/cedex/sources.d.ts.map +1 -1
- package/out/recipes/po/box/cedex/sources.js +0 -20
- package/out/recipes/po/box/cedex/sources.js.map +1 -1
- package/out/recipes/scaffold.d.ts +15 -2
- package/out/recipes/scaffold.d.ts.map +1 -1
- package/out/recipes/scaffold.js +21 -1
- package/out/recipes/scaffold.js.map +1 -1
- package/out/recipes/sources.d.ts.map +1 -1
- package/out/recipes/sources.js +112 -1
- package/out/recipes/sources.js.map +1 -1
- package/out/recipes/street/affix.d.ts.map +1 -1
- package/out/recipes/street/affix.js +8 -20
- package/out/recipes/street/affix.js.map +1 -1
- package/out/recipes/sub/venue/context.d.ts +14 -3
- package/out/recipes/sub/venue/context.d.ts.map +1 -1
- package/out/recipes/sub/venue/context.js +29 -9
- package/out/recipes/sub/venue/context.js.map +1 -1
- package/out/recipes/sub/venue/sources.d.ts +41 -8
- package/out/recipes/sub/venue/sources.d.ts.map +1 -1
- package/out/recipes/sub/venue/sources.js +26 -7
- package/out/recipes/sub/venue/sources.js.map +1 -1
- package/out/recipes/sub/venue.d.ts.map +1 -1
- package/out/recipes/sub/venue.js +35 -14
- package/out/recipes/sub/venue.js.map +1 -1
- package/out/recipes/unit.d.ts +4 -0
- package/out/recipes/unit.d.ts.map +1 -1
- package/out/recipes/unit.js +7 -6
- package/out/recipes/unit.js.map +1 -1
- package/out/registers.d.ts +33 -0
- package/out/registers.d.ts.map +1 -1
- package/out/registers.js +33 -0
- package/out/registers.js.map +1 -1
- package/out/source-register/corpus-inputs.d.ts +240 -0
- package/out/source-register/corpus-inputs.d.ts.map +1 -0
- package/out/source-register/corpus-inputs.js +275 -0
- package/out/source-register/corpus-inputs.js.map +1 -0
- package/out/source-register/effective-manifest.d.ts +2 -0
- package/out/source-register/effective-manifest.d.ts.map +1 -1
- package/out/source-register/effective-manifest.js +33 -4
- package/out/source-register/effective-manifest.js.map +1 -1
- package/out/source-register/types.d.ts +21 -1
- package/out/source-register/types.d.ts.map +1 -1
- package/out/source-register/types.js +20 -0
- package/out/source-register/types.js.map +1 -1
- package/out/south-asia/recipes/register.js +1 -1
- package/out/south-asia/recipes/register.js.map +1 -1
- package/out/tools/overlay/manifest.d.ts +7 -0
- package/out/tools/overlay/manifest.d.ts.map +1 -1
- package/out/tools/overlay/manifest.js +72 -1
- package/out/tools/overlay/manifest.js.map +1 -1
- package/out/tools/source-register/build.d.ts +9 -0
- package/out/tools/source-register/build.d.ts.map +1 -1
- package/out/tools/source-register/build.js +25 -3
- package/out/tools/source-register/build.js.map +1 -1
- package/out/tools/variety-sample.d.ts +196 -0
- package/out/tools/variety-sample.d.ts.map +1 -0
- package/out/tools/variety-sample.js +417 -0
- package/out/tools/variety-sample.js.map +1 -0
- package/out/us/adapters/usgov/nad/adapter.d.ts +80 -0
- package/out/us/adapters/usgov/nad/adapter.d.ts.map +1 -1
- package/out/us/adapters/usgov/nad/adapter.js +27 -8
- package/out/us/adapters/usgov/nad/adapter.js.map +1 -1
- package/out/us/recipes/skeleton-sources.d.ts +92 -0
- package/out/us/recipes/skeleton-sources.d.ts.map +1 -0
- package/out/us/recipes/skeleton-sources.js +121 -0
- package/out/us/recipes/skeleton-sources.js.map +1 -0
- package/out/us/tools/nad-skeletons.d.ts +120 -0
- package/out/us/tools/nad-skeletons.d.ts.map +1 -0
- package/out/us/tools/nad-skeletons.js +262 -0
- package/out/us/tools/nad-skeletons.js.map +1 -0
- package/out/uy/adapters/ide/adapter.d.ts +64 -0
- package/out/uy/adapters/ide/adapter.d.ts.map +1 -0
- package/out/uy/adapters/ide/adapter.js +206 -0
- package/out/uy/adapters/ide/adapter.js.map +1 -0
- package/out/ve/recipes/reviewed-postcode-tail.d.ts +6 -2
- package/out/ve/recipes/reviewed-postcode-tail.d.ts.map +1 -1
- package/out/ve/recipes/reviewed-postcode-tail.js +16 -5
- package/out/ve/recipes/reviewed-postcode-tail.js.map +1 -1
- package/package.json +14 -14
- package/lib/recipes/unit/sources.ts +0 -44
- package/lib/tools/fetch/ppd.ts +0 -121
- package/out/gb/os-open/open-names.d.ts.map +0 -1
- package/out/gb/os-open/open-names.js.map +0 -1
- package/out/gb/os-open/open-roads.d.ts.map +0 -1
- package/out/gb/os-open/open-roads.js.map +0 -1
- package/out/recipes/unit/sources.d.ts +0 -28
- package/out/recipes/unit/sources.d.ts.map +0 -1
- package/out/recipes/unit/sources.js +0 -30
- package/out/recipes/unit/sources.js.map +0 -1
- package/out/tools/fetch/ppd.d.ts +0 -15
- package/out/tools/fetch/ppd.d.ts.map +0 -1
- package/out/tools/fetch/ppd.js +0 -86
- package/out/tools/fetch/ppd.js.map +0 -1
- /package/lib/gb/os-open/{open-roads.ts → open/roads.ts} +0 -0
|
@@ -26,7 +26,10 @@ export const USGOV_NAD_ADAPTER_ID = "usgov-nad"
|
|
|
26
26
|
*/
|
|
27
27
|
export const USGOV_NAD_DEFAULT_LICENSE = "Public Domain"
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* One NAD address point as the FeatureServer pages publish it, limited to the fields a reader here uses.
|
|
31
|
+
*/
|
|
32
|
+
export interface NADRecord {
|
|
30
33
|
OBJECTID?: number
|
|
31
34
|
UUID?: string | null
|
|
32
35
|
AddNum_Pre?: string | null
|
|
@@ -94,7 +97,7 @@ function nonEmpty(...values: Array<string | null | undefined>): string | null {
|
|
|
94
97
|
* The shared `composeHouseNumber` in `#adapters/street-line` joins exactly two columns with
|
|
95
98
|
* one separator, so it fits a publisher that splits the number in two rather than in four.
|
|
96
99
|
*/
|
|
97
|
-
function nadHouseNumber(r: NADRecord): string | null {
|
|
100
|
+
export function nadHouseNumber(r: NADRecord): string | null {
|
|
98
101
|
const full = (r.AddNo_Full ?? "").toString().trim()
|
|
99
102
|
|
|
100
103
|
if (full) return full
|
|
@@ -107,16 +110,24 @@ function nadHouseNumber(r: NADRecord): string | null {
|
|
|
107
110
|
return [pre, num, suf].filter(isPresent).join(" ").trim() || null
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
|
|
113
|
+
/**
|
|
114
|
+
* A NAD street split into the prefix, name and suffix its structured columns state,
|
|
115
|
+
* with the whole street beside them.
|
|
116
|
+
*/
|
|
117
|
+
export interface DecomposedNADStreet {
|
|
111
118
|
prefix?: string
|
|
112
119
|
street?: string
|
|
113
120
|
suffix?: string
|
|
114
121
|
full: string
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The street a NAD record states.
|
|
126
|
+
*
|
|
127
|
+
* Structured street fields take precedence.
|
|
128
|
+
* If they are empty, `StNam_Full` supplies the whole street.
|
|
129
|
+
*/
|
|
130
|
+
export function decomposeNADStreet(r: NADRecord): DecomposedNADStreet | null {
|
|
120
131
|
const name = (r.St_Name ?? "").toString().trim()
|
|
121
132
|
|
|
122
133
|
if (name) {
|
|
@@ -139,9 +150,27 @@ function decomposeNADStreet(r: NADRecord): DecomposedNADStreet | null {
|
|
|
139
150
|
return null
|
|
140
151
|
}
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
/**
|
|
154
|
+
* The NAD domain values that fill a place-name column without naming a place, lower-cased.
|
|
155
|
+
*/
|
|
156
|
+
const NAD_PLACEHOLDER_PLACE_NAMES: ReadonlySet<string> = new Set(["not stated", "unincorporated", "0"])
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The locality a NAD record states.
|
|
160
|
+
*
|
|
161
|
+
* The postal city comes first because it is the name that people write on mail.
|
|
162
|
+
* NAD fills an unknown `Post_City` with the domain value `Not stated`
|
|
163
|
+
* (99,998 rows of a 100,000-row Massachusetts sample and 11,713 of a Texas one), and an address
|
|
164
|
+
* outside any municipality has `Inc_Muni` `Unincorporated` (the same 11,713 Texas rows).
|
|
165
|
+
*
|
|
166
|
+
* Another 23 rows of that Texas sample read `0`.
|
|
167
|
+
* Each of these values counts as absent, and the next column supplies the place name.
|
|
168
|
+
*/
|
|
169
|
+
export function composeNADLocality(r: NADRecord): string | null {
|
|
170
|
+
const stated = (value: string | null | undefined) =>
|
|
171
|
+
NAD_PLACEHOLDER_PLACE_NAMES.has(value?.trim().toLowerCase() ?? "") ? null : value
|
|
172
|
+
|
|
173
|
+
return nonEmpty(stated(r.Post_City), stated(r.Inc_Muni), stated(r.Census_Plc), stated(r.Uninc_Comm))
|
|
145
174
|
}
|
|
146
175
|
|
|
147
176
|
function composePostcode(r: NADRecord): string | null {
|
|
@@ -202,7 +231,7 @@ export function createUsgovNADAdapter(): CorpusAdapter {
|
|
|
202
231
|
|
|
203
232
|
if (!US_STATES_SET.has(state)) continue
|
|
204
233
|
|
|
205
|
-
const locality =
|
|
234
|
+
const locality = composeNADLocality(record)
|
|
206
235
|
|
|
207
236
|
if (!locality) continue
|
|
208
237
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
* @file The US address skeletons the OA-shaped recipes read: two elected OpenAddresses extracts and one
|
|
6
|
+
* National Address Database sample per chosen state, with Vermont held out for the golden sets.
|
|
7
|
+
*
|
|
8
|
+
* `unit`, `street-affix`, `suffix-boundary`, `po-box-cedex` and `country-balanced` read these files
|
|
9
|
+
* through the same zipped-CSV reader, so a NAD sample is written in OpenAddresses' column layout by
|
|
10
|
+
* `packages/mailwoman/tools/dev-tools/corpus/nad-skeletons.run.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { dataRootPath } from "@mailwoman/core/data-root"
|
|
14
|
+
import type { PathBuilderLike } from "path-ts"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The register a skeleton file's rows were published in.
|
|
18
|
+
*
|
|
19
|
+
* It prefixes the `base_source_id` a recipe derives from the file.
|
|
20
|
+
*/
|
|
21
|
+
export const USSkeletonRegister = {
|
|
22
|
+
OpenAddresses: "openaddresses",
|
|
23
|
+
NationalAddressDatabase: "usgov-nad",
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export type USSkeletonRegister = (typeof USSkeletonRegister)[keyof typeof USSkeletonRegister]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One cached US skeleton file: the zip, its CSV member and the region every row of it lies in.
|
|
30
|
+
*/
|
|
31
|
+
export interface USSkeletonSource {
|
|
32
|
+
zip: PathBuilderLike
|
|
33
|
+
csv: string
|
|
34
|
+
region: string
|
|
35
|
+
register: USSkeletonRegister
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The data-root directory holding the NAD samples and ZIP-to-city tables the NAD skeleton tool writes.
|
|
40
|
+
*/
|
|
41
|
+
export const NAD_SKELETON_DIRECTORY = "nad-skeletons"
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The states sampled from NAD, chosen for address shapes the OpenAddresses files lack
|
|
45
|
+
* rather than for row count.
|
|
46
|
+
*
|
|
47
|
+
* Distinct tuples of those six files carry a unit on 0.6% of rows, and none has
|
|
48
|
+
* a hyphenated or direction-prefixed house number.
|
|
49
|
+
*
|
|
50
|
+
* Each comment states the shape measured over the usable rows of the FeatureServer
|
|
51
|
+
* pages fetched on 2026-05-18.
|
|
52
|
+
* `nad-skeletons.run.ts` prints the table for every state.
|
|
53
|
+
*
|
|
54
|
+
* Vermont is absent because it is the eval holdout.
|
|
55
|
+
* No state's rows match a rural-route street pattern, so NAD adds no rural-route form.
|
|
56
|
+
*/
|
|
57
|
+
export const NAD_SKELETON_STATES = [
|
|
58
|
+
// Chicago's city grid, which `us/il/cook` supplied before it was withdrawn: 8.9% unit, 1,268 ZIPs.
|
|
59
|
+
"IL",
|
|
60
|
+
// 36.6% unit, the most of any state with over a million rows, and 154 street types.
|
|
61
|
+
"MA",
|
|
62
|
+
// The numbered grid: 43.1% numeric street names and 42.6% post-directionals, as in `500 E`.
|
|
63
|
+
"UT",
|
|
64
|
+
// Direction-prefixed rural grid house numbers such as `S9878` on 1.3% of rows, and lettered county roads.
|
|
65
|
+
"WI",
|
|
66
|
+
// Hyphenated Queens house numbers such as `244-32` on 5.3% of rows, and 15.8% unit.
|
|
67
|
+
"NY",
|
|
68
|
+
// Farm to Market, Ranch Road and County Road prefix types on 5.4% of rows, and 161 street types.
|
|
69
|
+
"TX",
|
|
70
|
+
] as const
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The zip and CSV member the NAD skeleton tool writes for one state.
|
|
74
|
+
*/
|
|
75
|
+
export function nadSkeletonFile(region: string): { zip: PathBuilderLike; csv: string } {
|
|
76
|
+
const state = region.toLowerCase()
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
zip: dataRootPath(NAD_SKELETON_DIRECTORY, `us__${state}__nad.zip`),
|
|
80
|
+
csv: `us/${state}/nad.csv`,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The NAD skeleton file for every state in {@link NAD_SKELETON_STATES}.
|
|
86
|
+
*/
|
|
87
|
+
export const NAD_SKELETON_SOURCES: readonly USSkeletonSource[] = NAD_SKELETON_STATES.map((region) => ({
|
|
88
|
+
...nadSkeletonFile(region),
|
|
89
|
+
region,
|
|
90
|
+
register: USSkeletonRegister.NationalAddressDatabase,
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The cached OpenAddresses US extracts used for training.
|
|
95
|
+
*
|
|
96
|
+
* `us/il/cook` is absent because Cook County's terms restrict redistribution.
|
|
97
|
+
* NAD's Illinois sample stands in for its city grid.
|
|
98
|
+
*/
|
|
99
|
+
export const OA_SKELETON_TRAIN_SOURCES: readonly USSkeletonSource[] = (
|
|
100
|
+
[
|
|
101
|
+
["us__ca__berkeley.zip", "us/ca/berkeley.csv", "CA"],
|
|
102
|
+
["us__ca__marin.zip", "us/ca/marin.csv", "CA"],
|
|
103
|
+
["us__dc__statewide.zip", "us/dc/statewide.csv", "DC"],
|
|
104
|
+
["us__ia__statewide.zip", "us/ia/statewide.csv", "IA"],
|
|
105
|
+
["us__mt__statewide.zip", "us/mt/statewide.csv", "MT"],
|
|
106
|
+
["us__sd__statewide.zip", "us/sd/statewide.csv", "SD"],
|
|
107
|
+
] as const
|
|
108
|
+
).map(([zip, csv, region]) => ({
|
|
109
|
+
zip: dataRootPath("oa-cache", zip),
|
|
110
|
+
csv,
|
|
111
|
+
region,
|
|
112
|
+
register: USSkeletonRegister.OpenAddresses,
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every US training skeleton: the OpenAddresses extracts and the NAD samples.
|
|
117
|
+
*/
|
|
118
|
+
export const US_SKELETON_TRAIN_SOURCES: readonly USSkeletonSource[] = [
|
|
119
|
+
...OA_SKELETON_TRAIN_SOURCES,
|
|
120
|
+
...NAD_SKELETON_SOURCES,
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Vermont, the corpus holdout — `--golden` reads this and no other US source.
|
|
125
|
+
*/
|
|
126
|
+
export const US_SKELETON_EVAL_SOURCE: USSkeletonSource = {
|
|
127
|
+
zip: dataRootPath("oa-cache", "us__vt__statewide.zip"),
|
|
128
|
+
csv: "us/vt/statewide.csv",
|
|
129
|
+
region: "VT",
|
|
130
|
+
register: USSkeletonRegister.OpenAddresses,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A county whose NAD rows give the ZIP-to-city table the `intersection` recipe renders city tails from.
|
|
135
|
+
*/
|
|
136
|
+
export interface NADZipCityCounty {
|
|
137
|
+
region: string
|
|
138
|
+
county: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The counties of the `intersection` recipe's TIGER training crossings, Cook IL and Morris NJ.
|
|
143
|
+
*/
|
|
144
|
+
export const NAD_ZIP_CITY_COUNTIES: readonly NADZipCityCounty[] = [
|
|
145
|
+
{ region: "IL", county: "Cook" },
|
|
146
|
+
{ region: "NJ", county: "Morris" },
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The ZIP-to-city CSV the NAD skeleton tool writes for one county.
|
|
151
|
+
*/
|
|
152
|
+
export function nadZipCityPath(entry: NADZipCityCounty): PathBuilderLike {
|
|
153
|
+
const county = entry.county.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")
|
|
154
|
+
|
|
155
|
+
return dataRootPath(NAD_SKELETON_DIRECTORY, `us__${entry.region.toLowerCase()}__${county}__zip-city.csv`)
|
|
156
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
* @file Samples National Address Database rows into OpenAddresses-layout CSV skeletons, measures the address
|
|
6
|
+
* shapes each state's rows carry, and tallies county ZIP-to-city tables.
|
|
7
|
+
*
|
|
8
|
+
* The OA-skeleton recipes read a zipped CSV with OpenAddresses' columns, so a NAD sample written in that
|
|
9
|
+
* layout reaches them through the same reader as the OpenAddresses extracts they also read.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { matchLeadingDesignator } from "@mailwoman/codex/us"
|
|
13
|
+
import { pathExists } from "@mailwoman/core/fs/readers/stat"
|
|
14
|
+
import { writeLocalBuffer, writeLocalJSONFile, writeLocalTextFile } from "@mailwoman/core/fs/writers"
|
|
15
|
+
import { sha256Hex } from "@mailwoman/core/hash"
|
|
16
|
+
import { tryParsingJSON } from "@mailwoman/core/json"
|
|
17
|
+
import { mulberry32 } from "@mailwoman/core/random"
|
|
18
|
+
import ADMZip from "adm-zip"
|
|
19
|
+
import { resolvePathBuilder, type PathBuilderLike } from "path-ts"
|
|
20
|
+
import { TextSpliterator } from "spliterator"
|
|
21
|
+
import { Globerator } from "spliterator/node/fs"
|
|
22
|
+
|
|
23
|
+
import { composeNADLocality, decomposeNADStreet, nadHouseNumber, type NADRecord } from "#us/adapters/usgov/nad/adapter"
|
|
24
|
+
import { nadSkeletonFile, nadZipCityPath, type NADZipCityCounty } from "#us/recipes/skeleton-sources"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The OpenAddresses columns a skeleton CSV carries, in OpenAddresses' order.
|
|
28
|
+
*/
|
|
29
|
+
export const NAD_SKELETON_COLUMNS = [
|
|
30
|
+
"NUMBER",
|
|
31
|
+
"STREET",
|
|
32
|
+
"UNIT",
|
|
33
|
+
"CITY",
|
|
34
|
+
"DISTRICT",
|
|
35
|
+
"REGION",
|
|
36
|
+
"POSTCODE",
|
|
37
|
+
"ID",
|
|
38
|
+
] as const
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One NAD row projected onto {@link NAD_SKELETON_COLUMNS}.
|
|
42
|
+
*/
|
|
43
|
+
export type NADSkeletonRow = Record<(typeof NAD_SKELETON_COLUMNS)[number], string>
|
|
44
|
+
|
|
45
|
+
const RURAL_ROUTE = /^(RR|R R|RURAL ROUTE|HC|HCR|HIGHWAY CONTRACT)\b/i
|
|
46
|
+
const text = (value: unknown): string => (value == null ? "" : String(value).trim())
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The unit identifier of a NAD row without its designator, as OpenAddresses' `UNIT` column mostly states it.
|
|
50
|
+
*
|
|
51
|
+
* NAD writes `APT 6`, `UNIT D` or a bare `200`; the recipes add their own designator in
|
|
52
|
+
* front of the identifier, so the designator NAD wrote is dropped rather than doubled.
|
|
53
|
+
*/
|
|
54
|
+
export function nadUnitIdentifier(record: NADRecord): string {
|
|
55
|
+
const unit = text(record.Unit) || text(record.Room)
|
|
56
|
+
|
|
57
|
+
if (!unit) return ""
|
|
58
|
+
|
|
59
|
+
const designator = matchLeadingDesignator(unit)
|
|
60
|
+
const identifier = designator ? unit.slice(unit.indexOf(designator.matched) + designator.matched.length) : unit
|
|
61
|
+
|
|
62
|
+
return identifier.replace(/^\s*#\s*/, "").trim()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Projects one NAD record onto the OpenAddresses columns, or returns null
|
|
67
|
+
* when it lacks a house number, street, locality, state or five-digit ZIP.
|
|
68
|
+
*
|
|
69
|
+
* A street whose name NAD publishes in capitals is written wholly in capitals,
|
|
70
|
+
* because NAD spells its structured types in title case (`East MAIN Street`)
|
|
71
|
+
* and a recipe would otherwise teach that mixed case.
|
|
72
|
+
*/
|
|
73
|
+
export function nadSkeletonRow(record: NADRecord): NADSkeletonRow | null {
|
|
74
|
+
const number = nadHouseNumber(record)
|
|
75
|
+
const street = decomposeNADStreet(record)
|
|
76
|
+
const city = composeNADLocality(record)
|
|
77
|
+
const region = text(record.State).toUpperCase()
|
|
78
|
+
const postcode = text(record.Zip_Code)
|
|
79
|
+
|
|
80
|
+
if (!number || !street?.full || !city || !region || !/^\d{5}$/.test(postcode)) return null
|
|
81
|
+
|
|
82
|
+
const name = street.street ?? street.full
|
|
83
|
+
const capitalized = /[A-Z]/.test(name) && name === name.toUpperCase()
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
NUMBER: number,
|
|
87
|
+
STREET: capitalized ? street.full.toUpperCase() : street.full,
|
|
88
|
+
UNIT: nadUnitIdentifier(record),
|
|
89
|
+
CITY: city,
|
|
90
|
+
DISTRICT: text(record.County),
|
|
91
|
+
REGION: region,
|
|
92
|
+
POSTCODE: postcode,
|
|
93
|
+
ID: text(record.UUID) || text(record.OBJECTID),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function csvCell(value: string): string {
|
|
98
|
+
return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The address-shape measures for one state's usable NAD rows.
|
|
103
|
+
*/
|
|
104
|
+
export interface NADStateShape {
|
|
105
|
+
region: string
|
|
106
|
+
/**
|
|
107
|
+
* Rows with a house number, street, locality and five-digit ZIP.
|
|
108
|
+
*/
|
|
109
|
+
usable: number
|
|
110
|
+
unitShare: number
|
|
111
|
+
/**
|
|
112
|
+
* Distinct `St_PosTyp` and `St_PreTyp` values.
|
|
113
|
+
*/
|
|
114
|
+
streetTypes: number
|
|
115
|
+
zips: number
|
|
116
|
+
/**
|
|
117
|
+
* Share of rows whose street opens with a type, such as `County Road 12` or `Farm to Market 1960`.
|
|
118
|
+
*/
|
|
119
|
+
prefixTypeShare: number
|
|
120
|
+
numericNameShare: number
|
|
121
|
+
postDirectionalShare: number
|
|
122
|
+
ruralRouteShare: number
|
|
123
|
+
hyphenatedNumberShare: number
|
|
124
|
+
/**
|
|
125
|
+
* Share of house numbers that open with a direction letter, as Wisconsin's `E2755` does.
|
|
126
|
+
*/
|
|
127
|
+
gridNumberShare: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ShapeTally {
|
|
131
|
+
usable: number
|
|
132
|
+
unit: number
|
|
133
|
+
types: Set<string>
|
|
134
|
+
zips: Set<string>
|
|
135
|
+
prefixType: number
|
|
136
|
+
numericName: number
|
|
137
|
+
postDirectional: number
|
|
138
|
+
ruralRoute: number
|
|
139
|
+
hyphenated: number
|
|
140
|
+
grid: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const newTally = (): ShapeTally => ({
|
|
144
|
+
usable: 0,
|
|
145
|
+
unit: 0,
|
|
146
|
+
types: new Set(),
|
|
147
|
+
zips: new Set(),
|
|
148
|
+
prefixType: 0,
|
|
149
|
+
numericName: 0,
|
|
150
|
+
postDirectional: 0,
|
|
151
|
+
ruralRoute: 0,
|
|
152
|
+
hyphenated: 0,
|
|
153
|
+
grid: 0,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
function tallyShape(tally: ShapeTally, record: NADRecord, row: NADSkeletonRow): void {
|
|
157
|
+
tally.usable++
|
|
158
|
+
|
|
159
|
+
if (row.UNIT) {
|
|
160
|
+
tally.unit++
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const postType = text(record.St_PosTyp).toUpperCase()
|
|
164
|
+
const preType = text(record.St_PreTyp).toUpperCase()
|
|
165
|
+
|
|
166
|
+
if (postType) {
|
|
167
|
+
tally.types.add(postType)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (preType) {
|
|
171
|
+
tally.types.add(preType)
|
|
172
|
+
|
|
173
|
+
tally.prefixType++
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
tally.zips.add(row.POSTCODE)
|
|
177
|
+
|
|
178
|
+
if (/^\d+$/.test(text(record.St_Name))) {
|
|
179
|
+
tally.numericName++
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (text(record.St_PosDir)) {
|
|
183
|
+
tally.postDirectional++
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (RURAL_ROUTE.test(row.STREET)) {
|
|
187
|
+
tally.ruralRoute++
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (/^\d+-\d+/.test(row.NUMBER)) {
|
|
191
|
+
tally.hyphenated++
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (/^[NSEW]\d/i.test(row.NUMBER)) {
|
|
195
|
+
tally.grid++
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function shapeOf(region: string, t: ShapeTally): NADStateShape {
|
|
200
|
+
const share = (n: number) => (t.usable ? n / t.usable : 0)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
region,
|
|
204
|
+
usable: t.usable,
|
|
205
|
+
unitShare: share(t.unit),
|
|
206
|
+
streetTypes: t.types.size,
|
|
207
|
+
zips: t.zips.size,
|
|
208
|
+
prefixTypeShare: share(t.prefixType),
|
|
209
|
+
numericNameShare: share(t.numericName),
|
|
210
|
+
postDirectionalShare: share(t.postDirectional),
|
|
211
|
+
ruralRouteShare: share(t.ruralRoute),
|
|
212
|
+
hyphenatedNumberShare: share(t.hyphenated),
|
|
213
|
+
gridNumberShare: share(t.grid),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface BuildNADSkeletonsOptions {
|
|
218
|
+
/**
|
|
219
|
+
* The directory of FeatureServer NDJSON pages the NAD fetcher writes.
|
|
220
|
+
*/
|
|
221
|
+
inputDirectory: PathBuilderLike
|
|
222
|
+
/**
|
|
223
|
+
* The states to sample, as USPS abbreviations.
|
|
224
|
+
*/
|
|
225
|
+
states: readonly string[]
|
|
226
|
+
/**
|
|
227
|
+
* The most rows any one state's sample keeps.
|
|
228
|
+
*/
|
|
229
|
+
cap: number
|
|
230
|
+
seed: number
|
|
231
|
+
zipCityCounties: readonly NADZipCityCounty[]
|
|
232
|
+
/**
|
|
233
|
+
* Called after each page with the count read so far.
|
|
234
|
+
*/
|
|
235
|
+
onPage?: (pagesRead: number, pages: number) => void
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface NADSkeletonSummary {
|
|
239
|
+
pages: number
|
|
240
|
+
cap: number
|
|
241
|
+
seed: number
|
|
242
|
+
shapes: NADStateShape[]
|
|
243
|
+
samples: Array<{ region: string; rows: number; zip: string; csv: string; csvSHA256: string }>
|
|
244
|
+
zipCity: Array<{ region: string; county: string; zips: number; path: string; sha256: string }>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Reads every NAD page once, measures every state's address shapes,
|
|
249
|
+
* keeps a uniform reservoir sample of at most `cap` rows for each requested state,
|
|
250
|
+
* and tallies the majority city of each ZIP in each requested county.
|
|
251
|
+
*
|
|
252
|
+
* The sample is written in reservoir order rather than page order, so a reader that
|
|
253
|
+
* takes the first N rows still draws across the whole state.
|
|
254
|
+
* It refuses to replace a file that already exists.
|
|
255
|
+
*/
|
|
256
|
+
export async function buildNADSkeletons(options: BuildNADSkeletonsOptions): Promise<NADSkeletonSummary> {
|
|
257
|
+
const { inputDirectory, cap, seed } = options
|
|
258
|
+
const states = new Set(options.states.map((s) => s.toUpperCase()))
|
|
259
|
+
|
|
260
|
+
const outputs = [
|
|
261
|
+
...[...states].map((region) => nadSkeletonFile(region).zip),
|
|
262
|
+
...options.zipCityCounties.map(nadZipCityPath),
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
for (const output of outputs) {
|
|
266
|
+
if (await pathExists(output)) throw new Error(`${output} already exists; a skeleton artifact is never replaced`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const random = mulberry32(seed)
|
|
270
|
+
const tallies = new Map<string, ShapeTally>()
|
|
271
|
+
const reservoirs = new Map<string, { seen: number; rows: NADSkeletonRow[] }>()
|
|
272
|
+
const zipCity = new Map<string, Map<string, Map<string, number>>>()
|
|
273
|
+
const countyKey = (region: string, county: string) => `${region}|${county.toLowerCase()}`
|
|
274
|
+
const wantedCounties = new Set(options.zipCityCounties.map((c) => countyKey(c.region, c.county)))
|
|
275
|
+
|
|
276
|
+
const pages = await Globerator.files("ndjson", { cwd: inputDirectory, absolute: false, recursive: false }).toSorted()
|
|
277
|
+
|
|
278
|
+
let pagesRead = 0
|
|
279
|
+
|
|
280
|
+
for (const page of pages) {
|
|
281
|
+
for await (const line of TextSpliterator.fromAsync(resolvePathBuilder(inputDirectory, page))) {
|
|
282
|
+
if (!line) continue
|
|
283
|
+
const record = tryParsingJSON<NADRecord>(line)
|
|
284
|
+
|
|
285
|
+
if (!record) continue
|
|
286
|
+
const row = nadSkeletonRow(record)
|
|
287
|
+
|
|
288
|
+
if (!row) continue
|
|
289
|
+
|
|
290
|
+
let tally = tallies.get(row.REGION)
|
|
291
|
+
|
|
292
|
+
if (!tally) {
|
|
293
|
+
tallies.set(row.REGION, (tally = newTally()))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
tallyShape(tally, record, row)
|
|
297
|
+
|
|
298
|
+
if (states.has(row.REGION)) {
|
|
299
|
+
let reservoir = reservoirs.get(row.REGION)
|
|
300
|
+
|
|
301
|
+
if (!reservoir) {
|
|
302
|
+
reservoirs.set(row.REGION, (reservoir = { seen: 0, rows: [] }))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
reservoir.seen++
|
|
306
|
+
|
|
307
|
+
if (reservoir.rows.length < cap) {
|
|
308
|
+
reservoir.rows.push(row)
|
|
309
|
+
} else {
|
|
310
|
+
const slot = Math.floor(random() * reservoir.seen)
|
|
311
|
+
|
|
312
|
+
if (slot < cap) {
|
|
313
|
+
reservoir.rows[slot] = row
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const key = countyKey(row.REGION, row.DISTRICT)
|
|
319
|
+
|
|
320
|
+
if (wantedCounties.has(key)) {
|
|
321
|
+
let byZip = zipCity.get(key)
|
|
322
|
+
|
|
323
|
+
if (!byZip) {
|
|
324
|
+
zipCity.set(key, (byZip = new Map()))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let byCity = byZip.get(row.POSTCODE)
|
|
328
|
+
|
|
329
|
+
if (!byCity) {
|
|
330
|
+
byZip.set(row.POSTCODE, (byCity = new Map()))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
byCity.set(row.CITY, (byCity.get(row.CITY) ?? 0) + 1)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
options.onPage?.(++pagesRead, pages.length)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const summary: NADSkeletonSummary = {
|
|
341
|
+
pages: pages.length,
|
|
342
|
+
cap,
|
|
343
|
+
seed,
|
|
344
|
+
shapes: [...tallies].map(([region, t]) => shapeOf(region, t)).toSorted((a, b) => b.usable - a.usable),
|
|
345
|
+
samples: [],
|
|
346
|
+
zipCity: [],
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const region of states) {
|
|
350
|
+
const reservoir = reservoirs.get(region)
|
|
351
|
+
|
|
352
|
+
if (!reservoir?.rows.length) throw new Error(`NAD holds no usable row for ${region}`)
|
|
353
|
+
const { zip, csv } = nadSkeletonFile(region)
|
|
354
|
+
|
|
355
|
+
const body = [
|
|
356
|
+
NAD_SKELETON_COLUMNS.join(","),
|
|
357
|
+
...reservoir.rows.map((row) => NAD_SKELETON_COLUMNS.map((column) => csvCell(row[column])).join(",")),
|
|
358
|
+
"",
|
|
359
|
+
].join("\n")
|
|
360
|
+
|
|
361
|
+
const archive = new ADMZip()
|
|
362
|
+
|
|
363
|
+
archive.addFile(csv, Buffer.from(body, "utf8"))
|
|
364
|
+
await writeLocalBuffer(archive.toBuffer(), zip)
|
|
365
|
+
summary.samples.push({ region, rows: reservoir.rows.length, zip: String(zip), csv, csvSHA256: sha256Hex(body) })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
for (const county of options.zipCityCounties) {
|
|
369
|
+
const byZip = zipCity.get(countyKey(county.region, county.county))
|
|
370
|
+
|
|
371
|
+
if (!byZip?.size) throw new Error(`NAD holds no usable row for ${county.county} County, ${county.region}`)
|
|
372
|
+
const lines = ["ZIP,CITY,ROWS"]
|
|
373
|
+
|
|
374
|
+
for (const [zip, byCity] of [...byZip].toSorted(([a], [b]) => a.localeCompare(b))) {
|
|
375
|
+
const [city, rows] = [...byCity].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]!
|
|
376
|
+
|
|
377
|
+
lines.push([zip, csvCell(city), String(rows)].join(","))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const body = `${lines.join("\n")}\n`
|
|
381
|
+
const path = nadZipCityPath(county)
|
|
382
|
+
|
|
383
|
+
await writeLocalTextFile(body, path)
|
|
384
|
+
summary.zipCity.push({ ...county, zips: byZip.size, path: String(path), sha256: sha256Hex(body) })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return summary
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Writes the summary beside the skeletons it describes.
|
|
392
|
+
*/
|
|
393
|
+
export function writeNADSkeletonSummary(summary: NADSkeletonSummary, path: PathBuilderLike): Promise<void> {
|
|
394
|
+
return writeLocalJSONFile(summary, path)
|
|
395
|
+
}
|