@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.
Files changed (96) hide show
  1. package/data/PROVENANCE.md +14 -0
  2. package/data/address-source-register.json +32 -4
  3. package/data/source-resolutions.json +35 -0
  4. package/data/training-manifests/v0.7.2-address-systems.effective.json +1169 -0
  5. package/data/training-manifests/v0.7.2-address-systems.json +550 -0
  6. package/lib/adapters/cordis/adapter.ts +1 -1
  7. package/lib/adapters.ts +2 -0
  8. package/lib/be/adapters/brussels/adapter.ts +1 -1
  9. package/lib/be/adapters/wallonie/adapter.ts +1 -1
  10. package/lib/build/align.ts +1 -1
  11. package/lib/cz/adapters/cuzk/adapter.ts +1 -1
  12. package/lib/cz/tools/fetch/cuzk.ts +2 -2
  13. package/lib/es/tools/fetch/bizkaia.ts +1 -1
  14. package/lib/gb/adapters/os-open/adapter.ts +453 -0
  15. package/lib/gb/os-open/nearest-link.ts +169 -0
  16. package/lib/gb/os-open/open-names.ts +180 -0
  17. package/lib/gb/os-open/open-roads.ts +145 -0
  18. package/lib/gb/tools/fetch/os-open.ts +251 -0
  19. package/lib/nl/tools/fetch/kadaster.ts +1 -1
  20. package/lib/registers.ts +7 -0
  21. package/lib/source-register/effective-manifest.ts +12 -6
  22. package/lib/source-register/training-manifest.ts +30 -2
  23. package/lib/source-register.ts +1 -0
  24. package/lib/subvenue/surfaces.ts +1 -1
  25. package/lib/tools/fetch/gleif.ts +1 -1
  26. package/lib/tools/fetch.ts +2 -0
  27. package/lib/tools/lint/recipe-output.ts +1 -1
  28. package/lib/tools/migrate/source-names.ts +1 -1
  29. package/lib/us/adapters/usgov/nad/adapter.ts +1 -1
  30. package/lib/us/adapters/usgov/nppes/adapter.ts +1 -1
  31. package/out/adapters/cordis/adapter.js +1 -1
  32. package/out/adapters.d.ts.map +1 -1
  33. package/out/adapters.js +2 -0
  34. package/out/adapters.js.map +1 -1
  35. package/out/be/adapters/brussels/adapter.d.ts +1 -1
  36. package/out/be/adapters/brussels/adapter.js +1 -1
  37. package/out/be/adapters/wallonie/adapter.d.ts +1 -1
  38. package/out/be/adapters/wallonie/adapter.js +1 -1
  39. package/out/build/align.d.ts +1 -1
  40. package/out/cz/adapters/cuzk/adapter.d.ts +1 -1
  41. package/out/cz/adapters/cuzk/adapter.js +1 -1
  42. package/out/cz/tools/fetch/cuzk.js +2 -2
  43. package/out/cz/tools/fetch/cuzk.js.map +1 -1
  44. package/out/es/tools/fetch/bizkaia.js +1 -1
  45. package/out/es/tools/fetch/bizkaia.js.map +1 -1
  46. package/out/gb/adapters/os-open/adapter.d.ts +165 -0
  47. package/out/gb/adapters/os-open/adapter.d.ts.map +1 -0
  48. package/out/gb/adapters/os-open/adapter.js +298 -0
  49. package/out/gb/adapters/os-open/adapter.js.map +1 -0
  50. package/out/gb/os-open/nearest-link.d.ts +73 -0
  51. package/out/gb/os-open/nearest-link.d.ts.map +1 -0
  52. package/out/gb/os-open/nearest-link.js +133 -0
  53. package/out/gb/os-open/nearest-link.js.map +1 -0
  54. package/out/gb/os-open/open-names.d.ts +92 -0
  55. package/out/gb/os-open/open-names.d.ts.map +1 -0
  56. package/out/gb/os-open/open-names.js +107 -0
  57. package/out/gb/os-open/open-names.js.map +1 -0
  58. package/out/gb/os-open/open-roads.d.ts +80 -0
  59. package/out/gb/os-open/open-roads.d.ts.map +1 -0
  60. package/out/gb/os-open/open-roads.js +79 -0
  61. package/out/gb/os-open/open-roads.js.map +1 -0
  62. package/out/gb/tools/fetch/os-open.d.ts +91 -0
  63. package/out/gb/tools/fetch/os-open.d.ts.map +1 -0
  64. package/out/gb/tools/fetch/os-open.js +166 -0
  65. package/out/gb/tools/fetch/os-open.js.map +1 -0
  66. package/out/nl/tools/fetch/kadaster.d.ts +1 -1
  67. package/out/nl/tools/fetch/kadaster.js +1 -1
  68. package/out/registers.d.ts +7 -0
  69. package/out/registers.d.ts.map +1 -1
  70. package/out/registers.js +7 -0
  71. package/out/registers.js.map +1 -1
  72. package/out/source-register/effective-manifest.d.ts +9 -3
  73. package/out/source-register/effective-manifest.d.ts.map +1 -1
  74. package/out/source-register/effective-manifest.js +9 -4
  75. package/out/source-register/effective-manifest.js.map +1 -1
  76. package/out/source-register/training-manifest.d.ts +27 -0
  77. package/out/source-register/training-manifest.d.ts.map +1 -1
  78. package/out/source-register/training-manifest.js +13 -1
  79. package/out/source-register/training-manifest.js.map +1 -1
  80. package/out/source-register.d.ts +1 -1
  81. package/out/source-register.d.ts.map +1 -1
  82. package/out/source-register.js +1 -1
  83. package/out/source-register.js.map +1 -1
  84. package/out/subvenue/surfaces.d.ts +1 -1
  85. package/out/tools/fetch/gleif.d.ts +1 -1
  86. package/out/tools/fetch.d.ts +2 -0
  87. package/out/tools/fetch.d.ts.map +1 -1
  88. package/out/tools/fetch.js +2 -0
  89. package/out/tools/fetch.js.map +1 -1
  90. package/out/tools/lint/recipe-output.d.ts +1 -1
  91. package/out/tools/migrate/source-names.d.ts +1 -1
  92. package/out/us/adapters/usgov/nad/adapter.d.ts +1 -1
  93. package/out/us/adapters/usgov/nad/adapter.js +1 -1
  94. package/out/us/adapters/usgov/nppes/adapter.d.ts +1 -1
  95. package/out/us/adapters/usgov/nppes/adapter.js +1 -1
  96. package/package.json +14 -13
@@ -0,0 +1,180 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Reads the road rows of OS Open Names.
7
+ *
8
+ * The CSV product is 819 files, one per 10 km National Grid square, with no header row. OS ships
9
+ * the 34 column names in `Doc/OS_Open_Names_Header.csv`, and the first data row of each file opens
10
+ * with a byte-order mark, which this reader strips from the `ID` value.
11
+ *
12
+ * A road row's `ID` is the roadNameTOID that OS Open Roads links reference, such as
13
+ * `osgb4000000010927776`. Two `LOCAL_TYPE` values are roads: `Named Road`, and
14
+ * `Section Of Named Road` for a road that OS splits where it crosses a postcode district or a
15
+ * settlement.
16
+ */
17
+
18
+ import { CSVSpliterator } from "spliterator"
19
+
20
+ /**
21
+ * The 34 columns of an OS Open Names CSV, in file order, as `Doc/OS_Open_Names_Header.csv` names them.
22
+ */
23
+ export const OPEN_NAMES_COLUMNS = [
24
+ "ID",
25
+ "NAMES_URI",
26
+ "NAME1",
27
+ "NAME1_LANG",
28
+ "NAME2",
29
+ "NAME2_LANG",
30
+ "TYPE",
31
+ "LOCAL_TYPE",
32
+ "GEOMETRY_X",
33
+ "GEOMETRY_Y",
34
+ "MOST_DETAIL_VIEW_RES",
35
+ "LEAST_DETAIL_VIEW_RES",
36
+ "MBR_XMIN",
37
+ "MBR_YMIN",
38
+ "MBR_XMAX",
39
+ "MBR_YMAX",
40
+ "POSTCODE_DISTRICT",
41
+ "POSTCODE_DISTRICT_URI",
42
+ "POPULATED_PLACE",
43
+ "POPULATED_PLACE_URI",
44
+ "POPULATED_PLACE_TYPE",
45
+ "DISTRICT_BOROUGH",
46
+ "DISTRICT_BOROUGH_URI",
47
+ "DISTRICT_BOROUGH_TYPE",
48
+ "COUNTY_UNITARY",
49
+ "COUNTY_UNITARY_URI",
50
+ "COUNTY_UNITARY_TYPE",
51
+ "REGION",
52
+ "REGION_URI",
53
+ "COUNTRY",
54
+ "COUNTRY_URI",
55
+ "RELATED_SPATIAL_OBJECT",
56
+ "SAME_AS_DBPEDIA",
57
+ "SAME_AS_GEONAMES",
58
+ ] as const
59
+
60
+ const column = Object.fromEntries(OPEN_NAMES_COLUMNS.map((name, index) => [name, index])) as Record<
61
+ (typeof OPEN_NAMES_COLUMNS)[number],
62
+ number
63
+ >
64
+
65
+ /**
66
+ * The `LOCAL_TYPE` values that mark a road row.
67
+ */
68
+ export const OPEN_NAMES_ROAD_TYPES: ReadonlySet<string> = new Set(["Named Road", "Section Of Named Road"])
69
+
70
+ /**
71
+ * One road row of OS Open Names, with the columns the corpus adapter reads.
72
+ */
73
+ export interface OpenNamesRoad {
74
+ /**
75
+ * The roadNameTOID, such as `osgb4000000010927776`.
76
+ */
77
+ id: string
78
+ /**
79
+ * The road's name, in the language OS lists first.
80
+ */
81
+ name1: string
82
+ /**
83
+ * The road's other-language name, Welsh or Gaelic, or an empty string.
84
+ */
85
+ name2: string
86
+ /**
87
+ * The outward code of the postcode district holding the road, such as `LS6`.
88
+ */
89
+ postcodeDistrict: string
90
+ /**
91
+ * The settlement holding the road, or an empty string.
92
+ *
93
+ * A bilingual settlement is written `<NAME1> / <NAME2>` of that settlement,
94
+ * such as `Stornoway / Steòrnabhagh`.
95
+ */
96
+ populatedPlace: string
97
+ districtBorough: string
98
+ countyUnitary: string
99
+ }
100
+
101
+ /**
102
+ * Counts from a read, with a reason for each row that did not yield.
103
+ */
104
+ export interface OpenNamesReadStats {
105
+ /**
106
+ * Rows read across every file.
107
+ */
108
+ read: number
109
+ /**
110
+ * Road rows yielded.
111
+ */
112
+ roads: number
113
+ /**
114
+ * Road rows skipped because their `ID` was not among the requested ids.
115
+ */
116
+ unrequested: number
117
+ /**
118
+ * Rows with a column count other than 34.
119
+ */
120
+ malformed: number
121
+ }
122
+
123
+ /**
124
+ * A stats object with every count at zero.
125
+ */
126
+ export function createOpenNamesReadStats(): OpenNamesReadStats {
127
+ return { read: 0, roads: 0, unrequested: 0, malformed: 0 }
128
+ }
129
+
130
+ /**
131
+ * Streams the road rows of one Open Names CSV.
132
+ *
133
+ * @param ids When given, only roads whose `ID` is in the set are yielded.
134
+ */
135
+ export async function* readOpenNamesRoads(
136
+ csvPath: string,
137
+ stats: OpenNamesReadStats,
138
+ ids?: ReadonlySet<string>
139
+ ): AsyncGenerator<OpenNamesRoad> {
140
+ for await (const row of CSVSpliterator.fromAsync<string[]>(csvPath, { header: false })) {
141
+ stats.read++
142
+
143
+ if (row.length !== OPEN_NAMES_COLUMNS.length) {
144
+ stats.malformed++
145
+
146
+ continue
147
+ }
148
+
149
+ if (!OPEN_NAMES_ROAD_TYPES.has(row[column.LOCAL_TYPE]!)) continue
150
+
151
+ const id = row[column.ID]!.replace(/^/u, "")
152
+
153
+ if (ids && !ids.has(id)) {
154
+ stats.unrequested++
155
+
156
+ continue
157
+ }
158
+
159
+ stats.roads++
160
+
161
+ yield {
162
+ id,
163
+ name1: row[column.NAME1]!.trim(),
164
+ name2: row[column.NAME2]!.trim(),
165
+ postcodeDistrict: row[column.POSTCODE_DISTRICT]!.trim(),
166
+ populatedPlace: row[column.POPULATED_PLACE]!.trim(),
167
+ districtBorough: row[column.DISTRICT_BOROUGH]!.trim(),
168
+ countyUnitary: row[column.COUNTY_UNITARY]!.trim(),
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The settlement's first-listed name from a `POPULATED_PLACE` value.
175
+ *
176
+ * OS writes a bilingual settlement as `<NAME1> / <NAME2>`, and NAME1 is the form the adapter emits.
177
+ */
178
+ export function populatedPlaceName1(populatedPlace: string): string {
179
+ return populatedPlace.split(" / ")[0]!.trim()
180
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Reads the named road links of an OS Open Roads GML tile.
7
+ *
8
+ * The GML product is one file per 100 km National Grid square. A tile holds `road:RoadLink`,
9
+ * `road:RoadNode` and `road:MotorwayJunction` features. Only the links hold a centreline and a
10
+ * name. A named link has a `road:name1` and a `road:roadNameTOID` whose `xlink:href` is `#` plus
11
+ * the roadNameTOID that OS Open Names uses as its `ID`. A link with only a road number carries a
12
+ * `road:roadNumberTOID` instead, and an unnamed link carries neither.
13
+ *
14
+ * The centreline is a `gml:LineString` in EPSG:27700, written as a `gml:posList` of easting and
15
+ * northing pairs. The reader honors the line's `srsDimension` so a 3D tile would still read its
16
+ * first two ordinates.
17
+ */
18
+
19
+ import { openReadStream } from "@mailwoman/core/fs/streams"
20
+ import { elementAtPath, streamMarkupElements, textAtPath, type MarkupElement } from "@mailwoman/core/html/elements"
21
+ import type { PathBuilderLike } from "path-ts"
22
+
23
+ /**
24
+ * The feature element that carries a centreline.
25
+ */
26
+ export const OPEN_ROADS_LINK_ELEMENT = "road:RoadLink"
27
+
28
+ /**
29
+ * One named road link.
30
+ */
31
+ export interface OpenRoadsLink {
32
+ /**
33
+ * The link's `gml:id`.
34
+ */
35
+ id: string
36
+ /**
37
+ * `road:name1` as the tile spells it.
38
+ */
39
+ name1: string
40
+ /**
41
+ * The roadNameTOID, without the `#` of the reference, such as `osgb4000000010927776`.
42
+ */
43
+ roadNameTOID: string
44
+ /**
45
+ * The centreline as flat `[easting0, northing0, easting1, northing1, …]` in EPSG:27700 meters.
46
+ */
47
+ vertices: Float64Array
48
+ }
49
+
50
+ /**
51
+ * Counts from a read.
52
+ */
53
+ export interface OpenRoadsReadStats {
54
+ /**
55
+ * Road links read.
56
+ */
57
+ links: number
58
+ /**
59
+ * Named links yielded.
60
+ */
61
+ named: number
62
+ /**
63
+ * Links with no `road:name1` or no `road:roadNameTOID`.
64
+ */
65
+ unnamed: number
66
+ /**
67
+ * Named links whose centreline held fewer than two vertices or a non-numeric ordinate.
68
+ */
69
+ malformedGeometry: number
70
+ }
71
+
72
+ /**
73
+ * A stats object with every count at zero.
74
+ */
75
+ export function createOpenRoadsReadStats(): OpenRoadsReadStats {
76
+ return { links: 0, named: 0, unnamed: 0, malformedGeometry: 0 }
77
+ }
78
+
79
+ const LINE_PATH = ["net:centrelineGeometry", "gml:LineString"] as const
80
+
81
+ /**
82
+ * The named link a `road:RoadLink` element describes, or a reason it describes none.
83
+ */
84
+ export function readOpenRoadsLink(element: MarkupElement): OpenRoadsLink | "unnamed" | "malformed-geometry" {
85
+ const name1 = (textAtPath(element, "road:name1") ?? "").trim()
86
+ const href = elementAtPath(element, "road:roadNameTOID")?.attributes["xlink:href"] ?? ""
87
+ const roadNameTOID = href.replace(/^#/u, "").trim()
88
+
89
+ if (!name1 || !roadNameTOID) return "unnamed"
90
+
91
+ const dimension = Number(elementAtPath(element, ...LINE_PATH)?.attributes.srsDimension ?? "2") || 2
92
+ const posList = (textAtPath(element, ...LINE_PATH, "gml:posList") ?? "").trim()
93
+ const ordinates = posList ? posList.split(/\s+/u) : []
94
+ const vertexCount = Math.floor(ordinates.length / dimension)
95
+
96
+ if (vertexCount < 2 || ordinates.length % dimension !== 0) return "malformed-geometry"
97
+
98
+ const vertices = new Float64Array(vertexCount * 2)
99
+
100
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
101
+ const easting = Number(ordinates[vertex * dimension])
102
+ const northing = Number(ordinates[vertex * dimension + 1])
103
+
104
+ if (!Number.isFinite(easting) || !Number.isFinite(northing)) return "malformed-geometry"
105
+
106
+ vertices[vertex * 2] = easting
107
+ vertices[vertex * 2 + 1] = northing
108
+ }
109
+
110
+ return { id: element.attributes["gml:id"] ?? "", name1, roadNameTOID, vertices }
111
+ }
112
+
113
+ /**
114
+ * Streams the named links of one Open Roads GML tile.
115
+ */
116
+ export async function* readOpenRoadsLinks(
117
+ gmlPath: PathBuilderLike,
118
+ stats: OpenRoadsReadStats,
119
+ options: { signal?: AbortSignal } = {}
120
+ ): AsyncGenerator<OpenRoadsLink> {
121
+ for await (const element of streamMarkupElements(openReadStream(gmlPath), OPEN_ROADS_LINK_ELEMENT, {
122
+ xml: true,
123
+ signal: options.signal,
124
+ })) {
125
+ stats.links++
126
+
127
+ const link = readOpenRoadsLink(element)
128
+
129
+ if (link === "unnamed") {
130
+ stats.unnamed++
131
+
132
+ continue
133
+ }
134
+
135
+ if (link === "malformed-geometry") {
136
+ stats.malformedGeometry++
137
+
138
+ continue
139
+ }
140
+
141
+ stats.named++
142
+
143
+ yield link
144
+ }
145
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Downloads the three OS OpenData products the `gb-os-open` adapter joins, from the OS Downloads API.
7
+ *
8
+ * The API serves OpenData without a key. `GET /products/<id>/downloads` lists one archive per
9
+ * format and area with its OS-published MD5, and each archive is checked against that MD5 after
10
+ * transfer. A present archive whose MD5 already matches is kept rather than downloaded again.
11
+ *
12
+ * Each archive is unpacked into the subdirectory the adapter reads, `code-point-open/`,
13
+ * `open-names/` or `open-roads/`, so the fetch root is the adapter's `inputPath`. The archive
14
+ * stays beside its members, because its digest is the value the address-source register records.
15
+ */
16
+
17
+ import { APIClient } from "@mailwoman/core/api"
18
+ import { ByteFormatter } from "@mailwoman/core/fs/formatters"
19
+ import { makeDirectories } from "@mailwoman/core/fs/writers"
20
+ import { extractZipEntries } from "@mailwoman/core/fs/zip"
21
+ import { md5File, sha256File } from "@mailwoman/core/hash"
22
+ import { OS_DOWNLOADS_API_BASE, type OSDownload } from "@mailwoman/spatial/gb/os-downloads"
23
+ import { PathBuilder, type PathBuilderLike } from "path-ts"
24
+
25
+ import { GB_OS_OPEN_ADAPTER_ID, GB_OS_OPEN_PRODUCT_DIRECTORIES } from "#gb/adapters/os-open/adapter"
26
+ import type { BaseFetchOptions, FetchSummary, SourceCollectionManifest, SourceManifest } from "#tools/fetch/download"
27
+ import { downloadToFile, writeManifest } from "#tools/fetch/download"
28
+
29
+ /**
30
+ * The license all three products are published under.
31
+ */
32
+ export const GB_OS_OPEN_LICENSE_NAME = "Open Government Licence v3.0"
33
+
34
+ /**
35
+ * The attribution OGL v3 requires, in the wording OS asks for on Code-Point Open,
36
+ * which also carries Royal Mail and ONS data.
37
+ */
38
+ export const GB_OS_OPEN_ATTRIBUTION =
39
+ "Contains OS data © Crown copyright and database right. Contains Royal Mail data © Royal Mail copyright and database right. Contains National Statistics data © Crown copyright and database right."
40
+
41
+ /**
42
+ * One product the adapter reads: its OS Downloads API id, the archive format,
43
+ * and the subdirectory it unpacks into.
44
+ */
45
+ export interface OSOpenProduct {
46
+ productID: string
47
+ format: string
48
+ directory: string
49
+ }
50
+
51
+ /**
52
+ * The three products, in the order they are fetched.
53
+ */
54
+ export const GB_OS_OPEN_PRODUCTS: readonly OSOpenProduct[] = [
55
+ { productID: "CodePointOpen", format: "CSV", directory: GB_OS_OPEN_PRODUCT_DIRECTORIES.codePointOpen },
56
+ { productID: "OpenNames", format: "CSV", directory: GB_OS_OPEN_PRODUCT_DIRECTORIES.openNames },
57
+ { productID: "OpenRoads", format: "GML", directory: GB_OS_OPEN_PRODUCT_DIRECTORIES.openRoads },
58
+ ]
59
+
60
+ /**
61
+ * The manifest entry for one product's archive.
62
+ */
63
+ export interface OSOpenFileManifest extends SourceManifest {
64
+ product_id: string
65
+ format: string
66
+ md5: string
67
+ members: number
68
+ }
69
+
70
+ /**
71
+ * The archive of `product` among the listed downloads, the Great Britain one where an area is stated.
72
+ *
73
+ * @throws Naming the formats offered, when none matches.
74
+ */
75
+ export function selectOSDownload(product: OSOpenProduct, downloads: readonly OSDownload[]): OSDownload {
76
+ const matches = downloads.filter((download) => download.format === product.format)
77
+ const chosen = matches.find((download) => download.area === "GB") ?? (matches.length === 1 ? matches[0] : undefined)
78
+
79
+ if (!chosen) {
80
+ throw new Error(
81
+ `${GB_OS_OPEN_ADAPTER_ID}: OS Downloads API lists no single ${product.format} archive for ${product.productID} ` +
82
+ `(listed: ${downloads.map((download) => `${download.format}/${download.area}`).join(", ") || "nothing"})`
83
+ )
84
+ }
85
+
86
+ return chosen
87
+ }
88
+
89
+ /**
90
+ * The archives the OS Downloads API lists for one product.
91
+ */
92
+ export async function readOSDownloads(
93
+ client: Pick<APIClient, "fetch">,
94
+ product: OSOpenProduct,
95
+ options: { signal?: AbortSignal } = {}
96
+ ): Promise<OSDownload[]> {
97
+ const { data } = await client.fetch<OSDownload[]>({
98
+ method: "GET",
99
+ url: `${OS_DOWNLOADS_API_BASE}/products/${product.productID}/downloads`,
100
+ timeout: 60_000,
101
+ signal: options.signal,
102
+ })
103
+
104
+ if (!Array.isArray(data)) {
105
+ throw new TypeError(
106
+ `${GB_OS_OPEN_ADAPTER_ID}: OS Downloads API answered ${product.productID} with no download list`
107
+ )
108
+ }
109
+
110
+ return data
111
+ }
112
+
113
+ /**
114
+ * Downloads and unpacks one product.
115
+ */
116
+ export async function downloadOSOpenProduct(
117
+ client: Pick<APIClient, "fetch">,
118
+ product: OSOpenProduct,
119
+ options: {
120
+ outputDir: PathBuilderLike
121
+ force?: boolean
122
+ retries?: number
123
+ retryDelayMs?: number
124
+ signal?: AbortSignal
125
+ report?: (line: string) => void
126
+ }
127
+ ): Promise<{ entry: OSOpenFileManifest; downloaded: boolean }> {
128
+ const { report } = options
129
+ const download = selectOSDownload(product, await readOSDownloads(client, product, { signal: options.signal }))
130
+ const productDir = PathBuilder.from(options.outputDir)(product.directory)
131
+ const archivePath = productDir(download.fileName)
132
+
133
+ report?.(
134
+ `=== ${GB_OS_OPEN_ADAPTER_ID} / ${product.productID} ${download.fileName} (${ByteFormatter.formatIEC(download.size)})`
135
+ )
136
+
137
+ await makeDirectories(productDir)
138
+
139
+ const present = options.force ? null : await md5File(archivePath).catch(() => null)
140
+ const downloaded = present !== download.md5
141
+
142
+ if (downloaded) {
143
+ await downloadToFile({
144
+ url: download.url,
145
+ dest: archivePath,
146
+ retries: options.retries,
147
+ retryDelayMs: options.retryDelayMs,
148
+ report,
149
+ })
150
+
151
+ const md5 = await md5File(archivePath)
152
+
153
+ if (md5 !== download.md5) {
154
+ throw new Error(
155
+ `${GB_OS_OPEN_ADAPTER_ID}: ${download.fileName} has md5 ${md5}, and OS publishes ${download.md5} for it`
156
+ )
157
+ }
158
+ } else {
159
+ report?.(` present, and its md5 matches the published ${download.md5}`)
160
+ }
161
+
162
+ const members = await extractZipEntries(archivePath, productDir, { skipExisting: true })
163
+ const sha256 = await sha256File(archivePath)
164
+
165
+ report?.(` ${members.length} members unpacked sha256=${sha256}`)
166
+
167
+ return {
168
+ entry: {
169
+ source_url: download.url,
170
+ filename: download.fileName,
171
+ product_id: product.productID,
172
+ format: product.format,
173
+ bytes: download.size,
174
+ md5: download.md5,
175
+ sha256,
176
+ members: members.length,
177
+ downloaded_at: new Date().toISOString(),
178
+ },
179
+ downloaded,
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Per-invocation options for the registry entry.
185
+ */
186
+ export interface FetchGBOSOpenOptions extends BaseFetchOptions {
187
+ force?: boolean
188
+ retries?: number
189
+ signal?: AbortSignal
190
+ }
191
+
192
+ /**
193
+ * The registry entry: fetches all three products and writes `MANIFEST.json` at the adapter's input root.
194
+ *
195
+ * A product that fails is counted and the others are still taken.
196
+ */
197
+ export async function fetchGBOSOpen(
198
+ options: FetchGBOSOpenOptions,
199
+ report?: (line: string) => void
200
+ ): Promise<FetchSummary> {
201
+ const outputDir = options.outRoot(GB_OS_OPEN_ADAPTER_ID)
202
+ const files: OSOpenFileManifest[] = []
203
+ const failedCodes: string[] = []
204
+
205
+ let fetched = 0
206
+ let skipped = 0
207
+
208
+ await using client = new APIClient({ displayName: GB_OS_OPEN_ADAPTER_ID, minRequestIntervalMs: 250, retry: true })
209
+
210
+ for (const product of GB_OS_OPEN_PRODUCTS) {
211
+ if (options.signal?.aborted) break
212
+
213
+ try {
214
+ const { entry, downloaded } = await downloadOSOpenProduct(client, product, {
215
+ outputDir,
216
+ force: options.force,
217
+ retries: options.retries,
218
+ retryDelayMs: options.retryDelayMs,
219
+ signal: options.signal,
220
+ report,
221
+ })
222
+
223
+ files.push(entry)
224
+
225
+ if (downloaded) {
226
+ fetched += 1
227
+ } else {
228
+ skipped += 1
229
+ }
230
+ } catch (error) {
231
+ report?.(` ✗ ${product.productID}: ${error instanceof Error ? error.message : String(error)}`)
232
+
233
+ failedCodes.push(product.productID)
234
+ }
235
+ }
236
+
237
+ const manifest: SourceCollectionManifest = {
238
+ source: GB_OS_OPEN_ADAPTER_ID,
239
+ source_url: OS_DOWNLOADS_API_BASE,
240
+ license: GB_OS_OPEN_LICENSE_NAME,
241
+ attribution: GB_OS_OPEN_ATTRIBUTION,
242
+ downloaded_at: new Date().toISOString(),
243
+ files,
244
+ }
245
+
246
+ await writeManifest(outputDir("MANIFEST.json"), manifest)
247
+
248
+ report?.(` MANIFEST written to ${outputDir("MANIFEST.json")}`)
249
+
250
+ return { fetched, skipped, failed: failedCodes.length, failedCodes }
251
+ }
@@ -59,7 +59,7 @@ export const NL_KADASTER_FEED_URL = "https://service.pdok.nl/kadaster/ad/atom/ad
59
59
  export const NL_KADASTER_DOWNLOAD_FILENAME = "addresses.gml.gz"
60
60
 
61
61
  /**
62
- * The attribution recorded in the manifest.
62
+ * The manifest records this attribution.
63
63
  *
64
64
  * CC0 1.0 reserves no act and owes no attribution clause.
65
65
  * This attribution is a credit the repository chooses, rather than a condition of the grant.
package/lib/registers.ts CHANGED
@@ -204,6 +204,13 @@ export const SourceRegister = {
204
204
  * HM Land Registry Price Paid Data, England and Wales.
205
205
  */
206
206
  LandRegistryPricePaid: "gb-hm-land-registry-ppd",
207
+ /**
208
+ * Ordnance Survey's OpenData products under the Open Government Licence v3.0.
209
+ *
210
+ * The `gb-os-open` adapter joins three of them per row.
211
+ * Code-Point Open, OS Open Names and OS Open Roads share one publisher and one license.
212
+ */
213
+ OrdnanceSurveyOpenData: "gb-os-opendata",
207
214
  /**
208
215
  * Australia's Geocoded National Address File.
209
216
  */
@@ -54,7 +54,8 @@ export function epochMixtureAuditPath(configPath: string): PathBuilder {
54
54
  */
55
55
  export const ExclusionReason = {
56
56
  /**
57
- * The config's `source_weights` map does not name the source, so the sampler never considers it.
57
+ * Neither the config's `source_weights` map nor its `source_reps` map lists the source,
58
+ * so the sampler never considers it.
58
59
  */
59
60
  UnweightedSource: "unweighted-source",
60
61
 
@@ -87,7 +88,8 @@ export interface EffectiveSourceRecord {
87
88
  license: string
88
89
 
89
90
  /**
90
- * The weight `source_weights` gives it, or `null` where the map omits it.
91
+ * The weight `source_weights` gives it, the repetition count `source_reps` gives it,
92
+ * or `null` where both maps omit it.
91
93
  */
92
94
  weight: number | null
93
95
 
@@ -348,7 +350,11 @@ export interface EffectiveConfigView {
348
350
  }
349
351
 
350
352
  /**
351
- * Read `data.source_weights` and `data.augment_exclude_sources` from a training config.
353
+ * Read `data.source_weights`, `data.source_reps` and `data.augment_exclude_sources` from a training config.
354
+ *
355
+ * `source_weights` and `source_reps` both admit a source to the sampler, one as a share
356
+ * and one as a repetition count, so an entry in either block counts as the source's weight here.
357
+ * A source in neither block is one the sampler never considers.
352
358
  *
353
359
  * A line scanner instead of a YAML parse.
354
360
  * `corpus audit` already reads these blocks this way.
@@ -368,7 +374,7 @@ export function readConfigView(text: string): EffectiveConfigView {
368
374
  const sourceWeights: Record<string, number> = {}
369
375
  const augmentExcludeSources: string[] = []
370
376
 
371
- type ConfigField = "source_weights" | "augment_exclude_sources"
377
+ type ConfigField = "source_weights" | "source_reps" | "augment_exclude_sources"
372
378
 
373
379
  let field: ConfigField | null = null
374
380
  let fieldIndent = -1
@@ -385,7 +391,7 @@ export function readConfigView(text: string): EffectiveConfigView {
385
391
  }
386
392
 
387
393
  if (!field) {
388
- const opener = /^([\t ]*)(source_weights|augment_exclude_sources):\s*(#.*)?$/.exec(raw)
394
+ const opener = /^([\t ]*)(source_weights|source_reps|augment_exclude_sources):\s*(#.*)?$/.exec(raw)
389
395
 
390
396
  if (opener) {
391
397
  field = opener[2] as ConfigField
@@ -399,7 +405,7 @@ export function readConfigView(text: string): EffectiveConfigView {
399
405
  continue
400
406
  }
401
407
 
402
- if (field === "source_weights") {
408
+ if (field === "source_weights" || field === "source_reps") {
403
409
  const entry = /^[\t ]+([\w-]+):\s*([\d.]+)/.exec(raw)
404
410
 
405
411
  if (entry) {