@mailwoman/corpus 10.4.0 → 10.5.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 +14 -0
- package/data/address-source-register.json +32 -4
- package/data/source-resolutions.json +35 -0
- package/data/training-manifests/v0.7.2-address-systems.effective.json +1169 -0
- package/data/training-manifests/v0.7.2-address-systems.json +550 -0
- package/lib/adapters/cordis/adapter.ts +1 -1
- package/lib/adapters.ts +2 -0
- package/lib/be/adapters/brussels/adapter.ts +1 -1
- package/lib/be/adapters/wallonie/adapter.ts +1 -1
- package/lib/build/align.ts +1 -1
- package/lib/cz/adapters/cuzk/adapter.ts +1 -1
- package/lib/cz/tools/fetch/cuzk.ts +2 -2
- package/lib/es/tools/fetch/bizkaia.ts +1 -1
- package/lib/gb/adapters/os-open/adapter.ts +453 -0
- package/lib/gb/os-open/nearest-link.ts +169 -0
- package/lib/gb/os-open/open-names.ts +180 -0
- package/lib/gb/os-open/open-roads.ts +145 -0
- package/lib/gb/tools/fetch/os-open.ts +251 -0
- package/lib/nl/tools/fetch/kadaster.ts +1 -1
- package/lib/registers.ts +7 -0
- package/lib/source-register/effective-manifest.ts +12 -6
- package/lib/source-register/training-manifest.ts +30 -2
- package/lib/source-register.ts +1 -0
- package/lib/subvenue/surfaces.ts +1 -1
- package/lib/tools/fetch/gleif.ts +1 -1
- package/lib/tools/fetch.ts +2 -0
- package/lib/tools/lint/recipe-output.ts +1 -1
- package/lib/tools/migrate/source-names.ts +1 -1
- package/lib/us/adapters/usgov/nad/adapter.ts +1 -1
- package/lib/us/adapters/usgov/nppes/adapter.ts +1 -1
- package/out/adapters/cordis/adapter.js +1 -1
- package/out/adapters.d.ts.map +1 -1
- package/out/adapters.js +2 -0
- package/out/adapters.js.map +1 -1
- package/out/be/adapters/brussels/adapter.d.ts +1 -1
- package/out/be/adapters/brussels/adapter.js +1 -1
- package/out/be/adapters/wallonie/adapter.d.ts +1 -1
- package/out/be/adapters/wallonie/adapter.js +1 -1
- package/out/build/align.d.ts +1 -1
- package/out/cz/adapters/cuzk/adapter.d.ts +1 -1
- package/out/cz/adapters/cuzk/adapter.js +1 -1
- package/out/cz/tools/fetch/cuzk.js +2 -2
- package/out/cz/tools/fetch/cuzk.js.map +1 -1
- package/out/es/tools/fetch/bizkaia.js +1 -1
- package/out/es/tools/fetch/bizkaia.js.map +1 -1
- package/out/gb/adapters/os-open/adapter.d.ts +165 -0
- package/out/gb/adapters/os-open/adapter.d.ts.map +1 -0
- package/out/gb/adapters/os-open/adapter.js +298 -0
- package/out/gb/adapters/os-open/adapter.js.map +1 -0
- package/out/gb/os-open/nearest-link.d.ts +73 -0
- package/out/gb/os-open/nearest-link.d.ts.map +1 -0
- package/out/gb/os-open/nearest-link.js +133 -0
- package/out/gb/os-open/nearest-link.js.map +1 -0
- package/out/gb/os-open/open-names.d.ts +92 -0
- package/out/gb/os-open/open-names.d.ts.map +1 -0
- package/out/gb/os-open/open-names.js +107 -0
- package/out/gb/os-open/open-names.js.map +1 -0
- package/out/gb/os-open/open-roads.d.ts +80 -0
- package/out/gb/os-open/open-roads.d.ts.map +1 -0
- package/out/gb/os-open/open-roads.js +79 -0
- package/out/gb/os-open/open-roads.js.map +1 -0
- package/out/gb/tools/fetch/os-open.d.ts +91 -0
- package/out/gb/tools/fetch/os-open.d.ts.map +1 -0
- package/out/gb/tools/fetch/os-open.js +166 -0
- package/out/gb/tools/fetch/os-open.js.map +1 -0
- package/out/nl/tools/fetch/kadaster.d.ts +1 -1
- package/out/nl/tools/fetch/kadaster.js +1 -1
- package/out/registers.d.ts +7 -0
- package/out/registers.d.ts.map +1 -1
- package/out/registers.js +7 -0
- package/out/registers.js.map +1 -1
- package/out/source-register/effective-manifest.d.ts +9 -3
- package/out/source-register/effective-manifest.d.ts.map +1 -1
- package/out/source-register/effective-manifest.js +9 -4
- package/out/source-register/effective-manifest.js.map +1 -1
- package/out/source-register/training-manifest.d.ts +27 -0
- package/out/source-register/training-manifest.d.ts.map +1 -1
- package/out/source-register/training-manifest.js +13 -1
- package/out/source-register/training-manifest.js.map +1 -1
- package/out/source-register.d.ts +1 -1
- package/out/source-register.d.ts.map +1 -1
- package/out/source-register.js +1 -1
- package/out/source-register.js.map +1 -1
- package/out/subvenue/surfaces.d.ts +1 -1
- package/out/tools/fetch/gleif.d.ts +1 -1
- package/out/tools/fetch.d.ts +2 -0
- package/out/tools/fetch.d.ts.map +1 -1
- package/out/tools/fetch.js +2 -0
- package/out/tools/fetch.js.map +1 -1
- package/out/tools/lint/recipe-output.d.ts +1 -1
- package/out/tools/migrate/source-names.d.ts +1 -1
- package/out/us/adapters/usgov/nad/adapter.d.ts +1 -1
- package/out/us/adapters/usgov/nad/adapter.js +1 -1
- package/out/us/adapters/usgov/nppes/adapter.d.ts +1 -1
- package/out/us/adapters/usgov/nppes/adapter.js +1 -1
- package/package.json +14 -13
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `gb-os-open`: Great Britain street addresses joined from three OS OpenData products, each under
|
|
7
|
+
* the Open Government Licence v3.0.
|
|
8
|
+
*
|
|
9
|
+
* - **Code-Point Open** gives every unit postcode in England, Scotland and Wales a grid point.
|
|
10
|
+
* - **OS Open Roads** gives each named road link a centreline and the roadNameTOID of its name.
|
|
11
|
+
* - **OS Open Names** gives each roadNameTOID its `NAME1`, `POSTCODE_DISTRICT` and `POPULATED_PLACE`.
|
|
12
|
+
*
|
|
13
|
+
* Each postcode is joined to the nearest named road link within 100 m, measured point-to-segment in
|
|
14
|
+
* EPSG:27700 meters. The street is the Open Names `NAME1` of that link's TOID, the locality is its
|
|
15
|
+
* `POPULATED_PLACE`. A row carries no region, because the GB layout prints none. Every
|
|
16
|
+
* component is OS data except `house_number`, which no open GB product publishes and which this
|
|
17
|
+
* adapter **draws** from local bands seeded by the postcode and TOID, so each row carries a number the
|
|
18
|
+
* source never stated.
|
|
19
|
+
*
|
|
20
|
+
* A postcode is dropped when:
|
|
21
|
+
*
|
|
22
|
+
* - its Code-Point coordinate has no position, `Positional_quality_indicator` 90;
|
|
23
|
+
* - its coordinate is shared with another postcode, so the point does not locate this postcode's street;
|
|
24
|
+
* - no named link lies within 100 m;
|
|
25
|
+
* - the joined road's `POSTCODE_DISTRICT` differs from the postcode's own outward code.
|
|
26
|
+
*
|
|
27
|
+
* `inputPath` is a directory holding the three products unzipped, each in its own subdirectory:
|
|
28
|
+
* `code-point-open/` (with `Data/CSV/*.csv`), `open-names/` (with `Data/*.csv`) and `open-roads/`
|
|
29
|
+
* (with the `.gml` tiles anywhere beneath it). The Code-Point rows are held in memory, the road
|
|
30
|
+
* tiles stream past them once, and Open Names is read once for the roadNameTOID values the join chose.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { formatAddressRow } from "@mailwoman/codex/address/format"
|
|
34
|
+
import { titleCaseGB } from "@mailwoman/codex/gb/place-name"
|
|
35
|
+
import { outwardCode } from "@mailwoman/codex/gb/postcode"
|
|
36
|
+
import { pathExists } from "@mailwoman/core/fs/readers/stat"
|
|
37
|
+
import { stringifyJSON } from "@mailwoman/core/json"
|
|
38
|
+
import { mulberry32 } from "@mailwoman/core/random"
|
|
39
|
+
import { createCodePointParseStats, PQI_NO_COORDINATE, readCodePointCSV } from "@mailwoman/spatial/gb/codepoint"
|
|
40
|
+
import { PathBuilder, type PathBuilderLike } from "path-ts"
|
|
41
|
+
import { Globerator } from "spliterator/node/fs"
|
|
42
|
+
|
|
43
|
+
import { UnsupportedCountryError } from "#adapters/errors"
|
|
44
|
+
import { stableSourceIDFromParts } from "#adapters/source-id"
|
|
45
|
+
import { NearestLinkJoin } from "#gb/os-open/nearest-link"
|
|
46
|
+
import {
|
|
47
|
+
createOpenNamesReadStats,
|
|
48
|
+
type OpenNamesRoad,
|
|
49
|
+
populatedPlaceName1,
|
|
50
|
+
readOpenNamesRoads,
|
|
51
|
+
} from "#gb/os-open/open-names"
|
|
52
|
+
import { createOpenRoadsReadStats, readOpenRoadsLinks } from "#gb/os-open/open-roads"
|
|
53
|
+
import { SourceRegister } from "#registers"
|
|
54
|
+
import { tieredNumber, type TieredNumberBand } from "#synthesizers/utils"
|
|
55
|
+
import { AddressRole, type AdapterOptions, type CanonicalRow, type CorpusAdapter, SurfaceOrigin } from "#types"
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Registry id for this adapter.
|
|
59
|
+
*
|
|
60
|
+
* Stamped into every row it emits, so a corpus record can be traced back to the dataset it came from.
|
|
61
|
+
*/
|
|
62
|
+
export const GB_OS_OPEN_ADAPTER_ID = "gb-os-open"
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The license on all three products, and on every row.
|
|
66
|
+
*/
|
|
67
|
+
export const GB_OS_OPEN_LICENSE = "OGL-UK-3.0"
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The jurisdictions the adapter emits.
|
|
71
|
+
*
|
|
72
|
+
* Code-Point Open covers Great Britain and carries no `BT` postcode.
|
|
73
|
+
*/
|
|
74
|
+
export const GB_OS_OPEN_COUNTRIES: readonly string[] = ["GB"]
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The farthest a road link may lie from a postcode's point and still name its street, in EPSG:27700 meters.
|
|
78
|
+
*/
|
|
79
|
+
export const GB_OS_OPEN_JOIN_RADIUS_M = 100
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The subdirectory of `inputPath` that holds each product.
|
|
83
|
+
*/
|
|
84
|
+
export const GB_OS_OPEN_PRODUCT_DIRECTORIES = {
|
|
85
|
+
codePointOpen: "code-point-open",
|
|
86
|
+
openNames: "open-names",
|
|
87
|
+
openRoads: "open-roads",
|
|
88
|
+
} as const
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The bands the drawn house number comes from.
|
|
92
|
+
*
|
|
93
|
+
* Seven in ten draws fall in 1–60, a quarter in 61–200, and the rest in 201–400.
|
|
94
|
+
*/
|
|
95
|
+
export const GB_OS_OPEN_HOUSE_NUMBER_BANDS: readonly TieredNumberBand[] = [
|
|
96
|
+
{ cutoff: 0.7, base: 1, span: 60 },
|
|
97
|
+
{ cutoff: 0.95, base: 61, span: 140 },
|
|
98
|
+
{ base: 201, span: 200 },
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What one run read, dropped and emitted.
|
|
103
|
+
*/
|
|
104
|
+
export interface GBOSOpenSummary {
|
|
105
|
+
/**
|
|
106
|
+
* Code-Point rows read, including rows with no coordinate.
|
|
107
|
+
*/
|
|
108
|
+
codePointRows: number
|
|
109
|
+
/**
|
|
110
|
+
* Code-Point rows that failed the parser's shape checks.
|
|
111
|
+
*/
|
|
112
|
+
codePointMalformed: number
|
|
113
|
+
/**
|
|
114
|
+
* Rows per `Positional_quality_indicator` value, including 90.
|
|
115
|
+
*/
|
|
116
|
+
pqi: Record<string, number>
|
|
117
|
+
/**
|
|
118
|
+
* Postcodes with a coordinate, the denominator of the join.
|
|
119
|
+
*/
|
|
120
|
+
postcodesWithCoordinate: number
|
|
121
|
+
/**
|
|
122
|
+
* Postcodes with a named link within the radius, counted before any drop.
|
|
123
|
+
*/
|
|
124
|
+
joined: number
|
|
125
|
+
dropped: {
|
|
126
|
+
/**
|
|
127
|
+
* Positional quality 90: the row has no coordinate.
|
|
128
|
+
*/
|
|
129
|
+
noCoordinate: number
|
|
130
|
+
/**
|
|
131
|
+
* The coordinate is shared with another postcode.
|
|
132
|
+
*/
|
|
133
|
+
sharedCoordinate: number
|
|
134
|
+
/**
|
|
135
|
+
* No named link within the radius.
|
|
136
|
+
*/
|
|
137
|
+
noLinkWithinRadius: number
|
|
138
|
+
/**
|
|
139
|
+
* The link's TOID has no road row in Open Names.
|
|
140
|
+
*/
|
|
141
|
+
noOpenNamesRow: number
|
|
142
|
+
/**
|
|
143
|
+
* The road's `POSTCODE_DISTRICT` is not the postcode's outward code.
|
|
144
|
+
*/
|
|
145
|
+
districtMismatch: number
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Rows emitted.
|
|
149
|
+
*/
|
|
150
|
+
emitted: number
|
|
151
|
+
/**
|
|
152
|
+
* Emitted streets that OS wrote with no lowercase letter and that were title-cased.
|
|
153
|
+
*/
|
|
154
|
+
titleCasedStreets: number
|
|
155
|
+
/**
|
|
156
|
+
* Emitted rows with no `POPULATED_PLACE`, so no locality.
|
|
157
|
+
*/
|
|
158
|
+
noLocality: number
|
|
159
|
+
/**
|
|
160
|
+
* Emitted rows whose `POPULATED_PLACE` was bilingual, of which the first name was kept.
|
|
161
|
+
*/
|
|
162
|
+
bilingualLocality: number
|
|
163
|
+
roads: ReturnType<typeof createOpenRoadsReadStats>
|
|
164
|
+
names: ReturnType<typeof createOpenNamesReadStats>
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Construction options.
|
|
169
|
+
*/
|
|
170
|
+
export interface GBOSOpenAdapterOptions {
|
|
171
|
+
/**
|
|
172
|
+
* Receives the run's counts once the rows are exhausted.
|
|
173
|
+
*/
|
|
174
|
+
onSummary?: (summary: GBOSOpenSummary) => void
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The three product directories under `inputPath`.
|
|
179
|
+
*
|
|
180
|
+
* @throws Naming the missing path, when a product's subdirectory is absent.
|
|
181
|
+
*/
|
|
182
|
+
export async function gbOSOpenProductPaths(
|
|
183
|
+
inputPath: PathBuilderLike
|
|
184
|
+
): Promise<Record<keyof typeof GB_OS_OPEN_PRODUCT_DIRECTORIES, PathBuilder>> {
|
|
185
|
+
const root = PathBuilder.from(inputPath)
|
|
186
|
+
|
|
187
|
+
const paths = {
|
|
188
|
+
codePointOpen: root(GB_OS_OPEN_PRODUCT_DIRECTORIES.codePointOpen),
|
|
189
|
+
openNames: root(GB_OS_OPEN_PRODUCT_DIRECTORIES.openNames),
|
|
190
|
+
openRoads: root(GB_OS_OPEN_PRODUCT_DIRECTORIES.openRoads),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const path of Object.values(paths)) {
|
|
194
|
+
if (!(await pathExists(path))) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${GB_OS_OPEN_ADAPTER_ID} adapter: ${path.toString()} is absent; inputPath must hold ${Object.values(GB_OS_OPEN_PRODUCT_DIRECTORIES).join("/, ")}/`
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return paths
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The files matching `pattern` under `directory`, sorted so a run reads them in one order.
|
|
206
|
+
*
|
|
207
|
+
* @throws When none match, so a wrong layout reports itself rather than reading as an empty product.
|
|
208
|
+
*/
|
|
209
|
+
async function productFiles(directory: PathBuilder, pattern: string): Promise<string[]> {
|
|
210
|
+
const names = await Globerator.from(pattern, { cwd: directory.toString(), absolute: false }).toSorted()
|
|
211
|
+
|
|
212
|
+
if (!names.length) {
|
|
213
|
+
throw new Error(`${GB_OS_OPEN_ADAPTER_ID} adapter: ${directory.toString()} holds no file matching ${pattern}`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return names.map((name) => directory(name).toString())
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The house number drawn for one postcode and road, from {@linkcode GB_OS_OPEN_HOUSE_NUMBER_BANDS}.
|
|
221
|
+
*
|
|
222
|
+
* The seed is the row's source id, so a rerun draws the same number.
|
|
223
|
+
*/
|
|
224
|
+
export function drawGBHouseNumber(sourceID: string): string {
|
|
225
|
+
const seed = Number.parseInt(sourceID.slice(-8), 16)
|
|
226
|
+
|
|
227
|
+
return tieredNumber(mulberry32(seed), GB_OS_OPEN_HOUSE_NUMBER_BANDS)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The street name as the row prints it.
|
|
232
|
+
*
|
|
233
|
+
* OS writes nearly every road name in mixed case.
|
|
234
|
+
* A name with no lowercase letter is title-cased.
|
|
235
|
+
*/
|
|
236
|
+
function streetName(name1: string): { street: string; titleCased: boolean } {
|
|
237
|
+
if (/\p{Ll}/u.test(name1)) return { street: name1, titleCased: false }
|
|
238
|
+
|
|
239
|
+
return { street: titleCaseGB(name1), titleCased: true }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A road link's identity, as the join keeps it.
|
|
244
|
+
*/
|
|
245
|
+
interface JoinedLink {
|
|
246
|
+
roadNameTOID: string
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Creates the OS OpenData adapter.
|
|
251
|
+
*/
|
|
252
|
+
export function createGBOSOpenAdapter(options: GBOSOpenAdapterOptions = {}): CorpusAdapter {
|
|
253
|
+
return {
|
|
254
|
+
id: GB_OS_OPEN_ADAPTER_ID,
|
|
255
|
+
defaultLicense: GB_OS_OPEN_LICENSE,
|
|
256
|
+
addressRole: AddressRole.Premise,
|
|
257
|
+
register: SourceRegister.OrdnanceSurveyOpenData,
|
|
258
|
+
surface: SurfaceOrigin.Rendered,
|
|
259
|
+
description:
|
|
260
|
+
"OS OpenData (Code-Point Open, OS Open Roads, OS Open Names): GB postcodes joined to their nearest named road, with a drawn house number.",
|
|
261
|
+
|
|
262
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
263
|
+
if (opts.country && !GB_OS_OPEN_COUNTRIES.includes(opts.country)) {
|
|
264
|
+
throw new UnsupportedCountryError(GB_OS_OPEN_ADAPTER_ID, GB_OS_OPEN_COUNTRIES, opts.country)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const paths = await gbOSOpenProductPaths(opts.inputPath)
|
|
268
|
+
const codePointFiles = await productFiles(paths.codePointOpen, "Data/CSV/*.csv")
|
|
269
|
+
const roadFiles = await productFiles(paths.openRoads, "**/*.gml")
|
|
270
|
+
const nameFiles = await productFiles(paths.openNames, "Data/*.csv")
|
|
271
|
+
|
|
272
|
+
const summary: GBOSOpenSummary = {
|
|
273
|
+
codePointRows: 0,
|
|
274
|
+
codePointMalformed: 0,
|
|
275
|
+
pqi: {},
|
|
276
|
+
postcodesWithCoordinate: 0,
|
|
277
|
+
joined: 0,
|
|
278
|
+
dropped: {
|
|
279
|
+
noCoordinate: 0,
|
|
280
|
+
sharedCoordinate: 0,
|
|
281
|
+
noLinkWithinRadius: 0,
|
|
282
|
+
noOpenNamesRow: 0,
|
|
283
|
+
districtMismatch: 0,
|
|
284
|
+
},
|
|
285
|
+
emitted: 0,
|
|
286
|
+
titleCasedStreets: 0,
|
|
287
|
+
noLocality: 0,
|
|
288
|
+
bilingualLocality: 0,
|
|
289
|
+
roads: createOpenRoadsReadStats(),
|
|
290
|
+
names: createOpenNamesReadStats(),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Code-Point: every postcode with a coordinate, and how many postcodes share each coordinate.
|
|
294
|
+
const codePointStats = createCodePointParseStats()
|
|
295
|
+
const postcodes: string[] = []
|
|
296
|
+
const xs: number[] = []
|
|
297
|
+
const ys: number[] = []
|
|
298
|
+
const pointCounts = new Map<string, number>()
|
|
299
|
+
|
|
300
|
+
for (const file of codePointFiles) {
|
|
301
|
+
for await (const record of readCodePointCSV(file, codePointStats)) {
|
|
302
|
+
postcodes.push(record.postcode)
|
|
303
|
+
xs.push(record.easting)
|
|
304
|
+
ys.push(record.northing)
|
|
305
|
+
|
|
306
|
+
const key = `${record.easting},${record.northing}`
|
|
307
|
+
|
|
308
|
+
pointCounts.set(key, (pointCounts.get(key) ?? 0) + 1)
|
|
309
|
+
summary.pqi[record.quality] = (summary.pqi[record.quality] ?? 0) + 1
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
summary.codePointRows = codePointStats.read
|
|
314
|
+
summary.codePointMalformed = codePointStats.skippedMalformed
|
|
315
|
+
summary.dropped.noCoordinate = codePointStats.skippedNoCoordinate
|
|
316
|
+
summary.postcodesWithCoordinate = postcodes.length
|
|
317
|
+
|
|
318
|
+
if (codePointStats.skippedNoCoordinate > 0) {
|
|
319
|
+
summary.pqi[PQI_NO_COORDINATE] = codePointStats.skippedNoCoordinate
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Open Roads: stream every named link past the postcodes once.
|
|
323
|
+
const join = new NearestLinkJoin<JoinedLink>(
|
|
324
|
+
{ xs: Float64Array.from(xs), ys: Float64Array.from(ys) },
|
|
325
|
+
GB_OS_OPEN_JOIN_RADIUS_M
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
for (const file of roadFiles) {
|
|
329
|
+
for await (const link of readOpenRoadsLinks(file, summary.roads, { signal: opts.signal })) {
|
|
330
|
+
join.offer({ roadNameTOID: link.roadNameTOID }, link.vertices)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const wanted = new Set<string>()
|
|
335
|
+
|
|
336
|
+
for (let i = 0; i < postcodes.length; i++) {
|
|
337
|
+
const match = join.match(i)
|
|
338
|
+
|
|
339
|
+
if (!match) continue
|
|
340
|
+
|
|
341
|
+
summary.joined++
|
|
342
|
+
wanted.add(match.link.roadNameTOID)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Open Names: only the roads the join chose.
|
|
346
|
+
const roads = new Map<string, OpenNamesRoad>()
|
|
347
|
+
|
|
348
|
+
for (const file of nameFiles) {
|
|
349
|
+
for await (const road of readOpenNamesRoads(file, summary.names, wanted)) {
|
|
350
|
+
roads.set(road.id, road)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
for (let i = 0; i < postcodes.length; i++) {
|
|
356
|
+
if (opts.signal?.aborted) break
|
|
357
|
+
|
|
358
|
+
if (opts.limit !== undefined && summary.emitted >= opts.limit) break
|
|
359
|
+
|
|
360
|
+
const postcode = postcodes[i]!
|
|
361
|
+
|
|
362
|
+
if ((pointCounts.get(`${xs[i]},${ys[i]}`) ?? 0) > 1) {
|
|
363
|
+
summary.dropped.sharedCoordinate++
|
|
364
|
+
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const match = join.match(i)
|
|
369
|
+
|
|
370
|
+
if (!match) {
|
|
371
|
+
summary.dropped.noLinkWithinRadius++
|
|
372
|
+
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const road = roads.get(match.link.roadNameTOID)
|
|
377
|
+
|
|
378
|
+
if (!road) {
|
|
379
|
+
summary.dropped.noOpenNamesRow++
|
|
380
|
+
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (road.postcodeDistrict !== outwardCode(postcode)) {
|
|
385
|
+
summary.dropped.districtMismatch++
|
|
386
|
+
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const { street, titleCased } = streetName(road.name1)
|
|
391
|
+
const locality = populatedPlaceName1(road.populatedPlace)
|
|
392
|
+
|
|
393
|
+
const sourceID = stableSourceIDFromParts(GB_OS_OPEN_ADAPTER_ID, {
|
|
394
|
+
postcode,
|
|
395
|
+
roadNameTOID: road.id,
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const components: CanonicalRow["components"] = {
|
|
399
|
+
house_number: drawGBHouseNumber(sourceID),
|
|
400
|
+
street,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (locality) {
|
|
404
|
+
components.locality = locality
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
components.postcode = postcode
|
|
408
|
+
|
|
409
|
+
const rendered = formatAddressRow(components, "GB", { singleLine: true })
|
|
410
|
+
|
|
411
|
+
if (!rendered) continue
|
|
412
|
+
|
|
413
|
+
if (titleCased) {
|
|
414
|
+
summary.titleCasedStreets++
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!locality) {
|
|
418
|
+
summary.noLocality++
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (road.populatedPlace.includes(" / ")) {
|
|
422
|
+
summary.bilingualLocality++
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
yield {
|
|
426
|
+
raw: rendered.raw,
|
|
427
|
+
components: rendered.components,
|
|
428
|
+
country: "GB",
|
|
429
|
+
locale: "en-GB",
|
|
430
|
+
source: GB_OS_OPEN_ADAPTER_ID,
|
|
431
|
+
source_id: sourceID,
|
|
432
|
+
corpus_version: "",
|
|
433
|
+
license: GB_OS_OPEN_LICENSE,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
summary.emitted++
|
|
437
|
+
}
|
|
438
|
+
} finally {
|
|
439
|
+
options.onSummary?.(summary)
|
|
440
|
+
|
|
441
|
+
process.stderr.write(
|
|
442
|
+
` ${GB_OS_OPEN_ADAPTER_ID}: ${summary.emitted} emitted of ${summary.codePointRows} Code-Point rows; ` +
|
|
443
|
+
`dropped ${stringifyJSON(summary.dropped)}\n`
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The configured adapter instance registered with the corpus builder.
|
|
452
|
+
*/
|
|
453
|
+
export const gbOSOpenAdapter = createGBOSOpenAdapter()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The nearest line feature to each of a set of points, measured point-to-segment in a planar
|
|
7
|
+
* projection.
|
|
8
|
+
*
|
|
9
|
+
* The points sit in a bucket grid and the lines stream past it. Each segment of each line asks the
|
|
10
|
+
* grid for the points inside its bounding box widened by the search radius, and each of those
|
|
11
|
+
* points keeps the nearest line it has been offered. Memory is bounded by the point count rather
|
|
12
|
+
* than by the line count, so a whole country's road network can be read once from disk.
|
|
13
|
+
*
|
|
14
|
+
* Distances are Euclidean in the projection's own units. For EPSG:27700, the British National
|
|
15
|
+
* Grid, those are meters, and the grid's scale error stays under 0.04% across Great Britain.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { planarSegmentDistanceSquared } from "@mailwoman/spatial/distance"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The multiplier that packs a cell's column and row into one numeric key.
|
|
22
|
+
*
|
|
23
|
+
* A row index must stay below it.
|
|
24
|
+
* With 100 m cells, 2^21 rows span 209,715 km, so a projected coordinate in meters cannot reach it.
|
|
25
|
+
*/
|
|
26
|
+
const CELL_ROW_SPAN = 2 ** 21
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A uniform grid of point indices, keyed by cell.
|
|
30
|
+
*/
|
|
31
|
+
export class PointBucketIndex {
|
|
32
|
+
readonly cellSize: number
|
|
33
|
+
|
|
34
|
+
readonly #cells = new Map<number, number[]>()
|
|
35
|
+
|
|
36
|
+
constructor(cellSize: number) {
|
|
37
|
+
if (!(cellSize > 0)) throw new RangeError(`PointBucketIndex: cell size must be positive, got ${cellSize}`)
|
|
38
|
+
|
|
39
|
+
this.cellSize = cellSize
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#key(column: number, row: number): number {
|
|
43
|
+
return column * CELL_ROW_SPAN + row
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Files the point `index` under the cell holding `(x, y)`.
|
|
48
|
+
*/
|
|
49
|
+
add(index: number, x: number, y: number): void {
|
|
50
|
+
const key = this.#key(Math.floor(x / this.cellSize), Math.floor(y / this.cellSize))
|
|
51
|
+
const bucket = this.#cells.get(key)
|
|
52
|
+
|
|
53
|
+
if (bucket) {
|
|
54
|
+
bucket.push(index)
|
|
55
|
+
} else {
|
|
56
|
+
this.#cells.set(key, [index])
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every point index filed in a cell that the box touches.
|
|
62
|
+
*
|
|
63
|
+
* The answer holds every point of each touched cell, including points up to one cell outside the box.
|
|
64
|
+
* A caller measures each one.
|
|
65
|
+
*/
|
|
66
|
+
*within(minX: number, minY: number, maxX: number, maxY: number): Generator<number> {
|
|
67
|
+
const firstColumn = Math.floor(minX / this.cellSize)
|
|
68
|
+
const lastColumn = Math.floor(maxX / this.cellSize)
|
|
69
|
+
const firstRow = Math.floor(minY / this.cellSize)
|
|
70
|
+
const lastRow = Math.floor(maxY / this.cellSize)
|
|
71
|
+
|
|
72
|
+
for (let column = firstColumn; column <= lastColumn; column++) {
|
|
73
|
+
for (let row = firstRow; row <= lastRow; row++) {
|
|
74
|
+
const bucket = this.#cells.get(this.#key(column, row))
|
|
75
|
+
|
|
76
|
+
if (bucket) {
|
|
77
|
+
yield* bucket
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The line a point was joined to, and how far away it lies.
|
|
86
|
+
*/
|
|
87
|
+
export interface NearestLinkMatch<T> {
|
|
88
|
+
link: T
|
|
89
|
+
/**
|
|
90
|
+
* The point-to-segment distance in projection units.
|
|
91
|
+
*/
|
|
92
|
+
distance: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Joins each point to the nearest line offered to it within `radius`.
|
|
97
|
+
*
|
|
98
|
+
* A line offered at exactly the same distance as the current nearest does not replace it,
|
|
99
|
+
* so the first-offered line wins a tie and the answer follows the order the lines are read in.
|
|
100
|
+
*/
|
|
101
|
+
export class NearestLinkJoin<T> {
|
|
102
|
+
readonly radius: number
|
|
103
|
+
|
|
104
|
+
readonly #xs: Float64Array
|
|
105
|
+
readonly #ys: Float64Array
|
|
106
|
+
readonly #bestSquared: Float64Array
|
|
107
|
+
readonly #best: (T | undefined)[]
|
|
108
|
+
readonly #index: PointBucketIndex
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param points The points to join, as parallel coordinate arrays.
|
|
112
|
+
* A point's position in the arrays is its index in every answer.
|
|
113
|
+
* @param radius The largest distance at which a line joins, in projection units.
|
|
114
|
+
*/
|
|
115
|
+
constructor(points: { xs: Float64Array; ys: Float64Array }, radius: number) {
|
|
116
|
+
if (points.xs.length !== points.ys.length) {
|
|
117
|
+
throw new RangeError(`NearestLinkJoin: ${points.xs.length} x values against ${points.ys.length} y values`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.radius = radius
|
|
121
|
+
this.#xs = points.xs
|
|
122
|
+
this.#ys = points.ys
|
|
123
|
+
this.#bestSquared = new Float64Array(points.xs.length).fill(Number.POSITIVE_INFINITY)
|
|
124
|
+
this.#best = Array.from({ length: points.xs.length })
|
|
125
|
+
this.#index = new PointBucketIndex(radius)
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < points.xs.length; i++) {
|
|
128
|
+
this.#index.add(i, points.xs[i]!, points.ys[i]!)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Offers one line, as a flat `[x0, y0, x1, y1, …]` vertex array, to every point within reach of it.
|
|
134
|
+
*/
|
|
135
|
+
offer(link: T, vertices: ArrayLike<number>): void {
|
|
136
|
+
const radius = this.radius
|
|
137
|
+
const radiusSquared = radius * radius
|
|
138
|
+
|
|
139
|
+
for (let v = 0; v + 3 < vertices.length; v += 2) {
|
|
140
|
+
const ax = vertices[v]!
|
|
141
|
+
const ay = vertices[v + 1]!
|
|
142
|
+
const bx = vertices[v + 2]!
|
|
143
|
+
const by = vertices[v + 3]!
|
|
144
|
+
|
|
145
|
+
for (const i of this.#index.within(
|
|
146
|
+
Math.min(ax, bx) - radius,
|
|
147
|
+
Math.min(ay, by) - radius,
|
|
148
|
+
Math.max(ax, bx) + radius,
|
|
149
|
+
Math.max(ay, by) + radius
|
|
150
|
+
)) {
|
|
151
|
+
const squared = planarSegmentDistanceSquared(this.#xs[i]!, this.#ys[i]!, ax, ay, bx, by)
|
|
152
|
+
|
|
153
|
+
if (squared <= radiusSquared && squared < this.#bestSquared[i]!) {
|
|
154
|
+
this.#bestSquared[i] = squared
|
|
155
|
+
this.#best[i] = link
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The nearest line offered to point `index`, or null when none came within the radius.
|
|
163
|
+
*/
|
|
164
|
+
match(index: number): NearestLinkMatch<T> | null {
|
|
165
|
+
const link = this.#best[index]
|
|
166
|
+
|
|
167
|
+
return link === undefined ? null : { link, distance: Math.sqrt(this.#bestSquared[index]!) }
|
|
168
|
+
}
|
|
169
|
+
}
|