@motionstudies/data 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
@@ -0,0 +1,130 @@
1
+ import { readFile, readdir, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { gunzipSync } from 'node:zlib'
4
+ import { createHash } from 'node:crypto'
5
+ import { parseCsvLine } from './gtfs.mjs'
6
+ import { decodeAdsbHeatmap } from './adsb-heatmap.mjs'
7
+
8
+ export function parseAirports(csv) {
9
+ const [header, ...lines] = csv.trim().split(/\r?\n/)
10
+ const keys = parseCsvLine(header)
11
+ return lines.map(line => Object.fromEntries(parseCsvLine(line).map((value, index) => [keys[index], value])))
12
+ .filter(row => ['large_airport', 'medium_airport'].includes(row.type) && row.ident && row.elevation_ft)
13
+ .map(row => ({ icao: row.ident, iata: row.iata_code, name: row.name, city: row.municipality,
14
+ longitude: Number(row.longitude_deg), latitude: Number(row.latitude_deg), elevation: Number(row.elevation_ft) }))
15
+ .filter(row => [row.longitude, row.latitude, row.elevation].every(Number.isFinite))
16
+ }
17
+
18
+ function distance(sample, airport) {
19
+ const longitude = ((sample[1] - airport.longitude + 540) % 360) - 180
20
+ return Math.hypot(longitude * 111.32 * Math.cos((sample[2] + airport.latitude) * Math.PI / 360), (sample[2] - airport.latitude) * 111.32)
21
+ }
22
+
23
+ function atAirport(sample, airport) {
24
+ return sample[3] >= airport.elevation - 500 && sample[3] <= airport.elevation + 1500 && sample[4] <= 250 && distance(sample, airport) <= 6
25
+ }
26
+
27
+ /** Conservative endpoint association; cruise-only traces and ambiguous neighbouring airports stay unknown. */
28
+ export function inferAirEndpoints(samples, airports) {
29
+ if (samples.length < 4) return {}
30
+ const maximumAltitude = Math.max(...samples.map(sample => sample[3]))
31
+ const endpoint = (arrival) => {
32
+ const ordered = arrival ? [...samples].reverse() : samples
33
+ const candidates = airports.filter(airport => atAirport(ordered[0], airport) && maximumAltitude >= airport.elevation + 3000)
34
+ if (candidates.length !== 1) return undefined
35
+ const airport = candidates[0]
36
+ let boundary = ordered[0]
37
+ for (const sample of ordered.slice(1)) {
38
+ if (!atAirport(sample, airport)) break
39
+ boundary = sample
40
+ }
41
+ return { icao: airport.icao, iata: airport.iata, name: airport.name, city: airport.city, time: boundary[0], evidence: 'observed-endpoint' }
42
+ }
43
+ const origin = endpoint(false), destination = endpoint(true)
44
+ if (origin && destination && (origin.icao === destination.icao || origin.time >= destination.time)) return {}
45
+ return { ...(origin ? { origin } : {}), ...(destination ? { destination } : {}) }
46
+ }
47
+
48
+ function segmentsFor(samples) {
49
+ const segments = []
50
+ let current = []
51
+ for (const sample of samples.sort((a, b) => a[0] - b[0])) {
52
+ const previous = current.at(-1)
53
+ if (previous && previous[0] === sample[0]) continue
54
+ if (previous && (sample[0] - previous[0] > 1800 || sample[5] !== previous[5])) {
55
+ segments.push(current)
56
+ current = []
57
+ }
58
+ current.push(sample)
59
+ }
60
+ if (current.length) segments.push(current)
61
+ return segments
62
+ }
63
+
64
+ /** Enrich an existing study and its chunks using cached, same-date global heatmaps. No network requests. */
65
+ export async function enrichAirEndpoints({ manifestPath, snapshotPaths = [], heatmapDirectory, airportCsvPath, utcOffsetHours }) {
66
+ const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
67
+ const manifest = await readJson(manifestPath)
68
+ const snapshots = await Promise.all(snapshotPaths.map(readJson))
69
+ const date = manifest.metadata.serviceDate
70
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(utcOffsetHours)) throw new Error('A service date and UTC offset are required')
71
+ if (snapshots.some(snapshot => snapshot.metadata.serviceDate !== date)) throw new Error('Air snapshots must share the manifest service date')
72
+ const tracks = [...manifest.aircraft, ...snapshots.flatMap(snapshot => snapshot.tracks)]
73
+ const wanted = new Set(tracks.map(track => parseInt(track.icaoAddress ?? track.id.split('-')[0], 16)))
74
+ const records = new Map()
75
+ const files = (await readdir(heatmapDirectory)).filter(file => file.endsWith('.bin.ttf')).sort()
76
+ if (!files.length) throw new Error('No cached heatmap slices found')
77
+ const sources = []
78
+ for (const file of files) {
79
+ const raw = await readFile(join(heatmapDirectory, file))
80
+ const used = decodeAdsbHeatmap(gunzipSync(raw), {
81
+ serviceDate: date, utcOffsetHours, windowStart: 0, windowEnd: 86400, addresses: wanted,
82
+ }, (id, sample) => {
83
+ const samples = records.get(id) ?? []
84
+ sample[5] ??= samples.at(-1)?.[5] ?? ''
85
+ samples.push(sample)
86
+ records.set(id, samples)
87
+ })
88
+ if (used) sources.push({ file, sha256: createHash('sha256').update(raw).digest('hex') })
89
+ }
90
+ const csv = await readFile(airportCsvPath, 'utf8')
91
+ if (!sources.length) throw new Error('No same-date observations matched the study aircraft')
92
+ const airports = parseAirports(csv)
93
+ if (!airports.length) throw new Error('No usable airports in the reference CSV')
94
+ const segments = new Map([...records].map(([id, samples]) => [id, segmentsFor(samples).map(samples => ({
95
+ start: samples[0][0], end: samples.at(-1)[0], callsign: samples[0][5], ...inferAirEndpoints(samples, airports),
96
+ }))]))
97
+ const enrich = (track) => {
98
+ const candidates = (segments.get(parseInt(track.icaoAddress ?? track.id.split('-')[0], 16)) ?? [])
99
+ .filter(segment => segment.callsign && segment.callsign === track.callsign && segment.start <= track.start + 30 && segment.end >= track.end - 30)
100
+ const { origin: _origin, destination: _destination, ...rest } = track
101
+ if (candidates.length !== 1) return rest
102
+ const { origin, destination } = candidates[0]
103
+ return { ...rest, ...(origin ? { origin } : {}), ...(destination ? { destination } : {}) }
104
+ }
105
+ const metadata = {
106
+ serviceDate: date, method: 'observed-endpoint', note: 'Origin/destination inferred from full-trace endpoints within 6 km and 1500 ft above an airport; observed times, not schedules or confirmed flight plans.',
107
+ airportSource: 'https://ourairports.com/data/', airportLicense: 'Public Domain', airportSha256: createHash('sha256').update(csv).digest('hex'),
108
+ traceSource: manifest.metadata.sourceUrl, traceLicense: 'ODbL 1.0', utcOffsetHours, sourceFiles: sources,
109
+ }
110
+ const aircraft = manifest.aircraft.map(enrich)
111
+ const byId = new Map(aircraft.map(track => [track.id, track]))
112
+ const writes = []
113
+ for (const descriptor of manifest.chunks) {
114
+ const path = join(dirname(manifestPath), descriptor.path)
115
+ const chunk = await readJson(path)
116
+ chunk.tracks = chunk.tracks.map(track => {
117
+ const { origin: _origin, destination: _destination, ...rest } = track
118
+ const full = byId.get(track.id)
119
+ return { ...rest, ...(full?.origin ? { origin: full.origin } : {}), ...(full?.destination ? { destination: full.destination } : {}) }
120
+ })
121
+ const bytes = Buffer.from(`${JSON.stringify(chunk)}\n`)
122
+ if ('bytes' in descriptor) descriptor.bytes = bytes.length
123
+ if ('sha256' in descriptor) descriptor.sha256 = createHash('sha256').update(bytes).digest('hex')
124
+ writes.push([path, chunk])
125
+ }
126
+ writes.push([manifestPath, { ...manifest, metadata: { ...manifest.metadata, routeEnrichment: metadata }, aircraft }])
127
+ snapshots.forEach((snapshot, index) => writes.push([snapshotPaths[index], { ...snapshot, metadata: { ...snapshot.metadata, routeEnrichment: metadata }, tracks: snapshot.tracks.map(enrich) }]))
128
+ for (const [path, value] of writes) await writeFile(path, `${JSON.stringify(value)}\n`)
129
+ return { tracks: aircraft.length, origins: aircraft.filter(track => track.origin).length, destinations: aircraft.filter(track => track.destination).length }
130
+ }
@@ -0,0 +1 @@
1
+ export function runNetworkChunkCli(argv?: readonly string[]): Promise<void>
@@ -0,0 +1,65 @@
1
+ import { chunkNetworkSnapshot, extractNetworkWindow } from './network-chunks.mjs'
2
+
3
+ import { createWriteStream } from 'node:fs'
4
+ import { mkdir, readFile } from 'node:fs/promises'
5
+ import { basename, dirname, extname, join, resolve } from 'node:path'
6
+
7
+ function argument(argv, name, fallback) {
8
+ const index = argv.indexOf(`--${name}`)
9
+ return index === -1 ? fallback : argv[index + 1]
10
+ }
11
+
12
+ function parseClock(value) {
13
+ const [hours, minutes = 0] = value.split(':').map(Number)
14
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) {
15
+ throw new Error(`Invalid clock value ${value}`)
16
+ }
17
+ return hours * 3600 + minutes * 60
18
+ }
19
+
20
+ async function writeJson(filePath, value) {
21
+ await mkdir(dirname(filePath), { recursive: true })
22
+ await new Promise((resolvePromise, rejectPromise) => {
23
+ const output = createWriteStream(filePath)
24
+ output.once('error', rejectPromise)
25
+ output.once('finish', resolvePromise)
26
+ output.end(JSON.stringify(value))
27
+ })
28
+ }
29
+
30
+ export async function runNetworkChunkCli(argv = process.argv.slice(2)) {
31
+ const input = argument(argv, 'input')
32
+ const manifestOutput = argument(argv, 'manifest')
33
+ const openingOutput = argument(argv, 'opening')
34
+ if (!input || !manifestOutput || !openingOutput) {
35
+ throw new Error(
36
+ 'Usage: node scripts/chunk-network-snapshot.mjs --input day.json --manifest day-manifest.json --opening morning.json [--chunk-hours 2] [--opening-start 06:45] [--opening-end 08:45] [--focus 07:45]',
37
+ )
38
+ }
39
+ const snapshot = JSON.parse(await readFile(resolve(input), 'utf8'))
40
+ const manifestPath = resolve(manifestOutput)
41
+ const outputDirectory = dirname(manifestPath)
42
+ const outputStem = basename(manifestPath, extname(manifestPath)).replace(/-manifest$/, '')
43
+ const chunkDirectoryName = `${outputStem}-chunks`
44
+ const { manifest, chunks } = chunkNetworkSnapshot(
45
+ snapshot,
46
+ Number(argument(argv, 'chunk-hours', '2')) * 3600,
47
+ chunkDirectoryName,
48
+ )
49
+ const opening = extractNetworkWindow(
50
+ snapshot,
51
+ parseClock(argument(argv, 'opening-start', '06:45')),
52
+ parseClock(argument(argv, 'opening-end', '08:45')),
53
+ parseClock(argument(argv, 'focus', '07:45')),
54
+ )
55
+ await Promise.all([
56
+ writeJson(manifestPath, manifest),
57
+ writeJson(resolve(openingOutput), opening),
58
+ ...chunks.map(({ descriptor, payload }) =>
59
+ writeJson(join(outputDirectory, descriptor.path), payload),
60
+ ),
61
+ ])
62
+ console.log(
63
+ `Wrote ${manifestOutput}, ${chunks.length} progressive chunks and ${opening.trains.length} opening journeys.`,
64
+ )
65
+ }
@@ -0,0 +1,38 @@
1
+ export interface ObservationRecord<T> {
2
+ readonly id: string
3
+ readonly timeUtc: string
4
+ readonly receivedAt?: string
5
+ readonly sourceRecordedAt?: string
6
+ readonly sourceRefs?: readonly string[]
7
+ readonly value: T
8
+ }
9
+ export interface ObservationWindowOptions {
10
+ readonly startUtc: string
11
+ readonly endUtc: string
12
+ readonly sourceId: string
13
+ readonly evidenceKind: string
14
+ readonly timeBasis: 'capture-schedule' | 'receipt' | 'measurement'
15
+ readonly windowSeconds?: number
16
+ readonly cadenceSeconds?: number
17
+ readonly minimumRecords?: number
18
+ readonly duplicatePolicy?: 'reject' | 'omit' | 'last'
19
+ readonly pathPrefix?: string
20
+ readonly maxRecords?: number
21
+ readonly maxBytes?: number
22
+ }
23
+ export interface ObservationCoverage {
24
+ inputCount: number; retainedRecords: number; duplicates: number; conflicts: number; outsideWindow: number;
25
+ occupiedSlots: number; expectedSlots: number; missingSlots: number[]; longestGapSeconds: number;
26
+ leadingGapSeconds: number; trailingGapSeconds: number;
27
+ }
28
+ export interface ObservationWindowDescriptor {
29
+ id: string; windowStart: number; windowEnd: number; path: string; frameCount: number; bytes: number; sha256: string;
30
+ }
31
+ export function jsonArtifact(value: unknown): { json: string; bytes: number; sha256: string }
32
+ export function compileObservationWindows<T>(input: Iterable<ObservationRecord<T>>, options: ObservationWindowOptions): {
33
+ manifest: { schemaVersion: number; sourceId: string; evidenceKind: string; timeBasis: ObservationWindowOptions['timeBasis'];
34
+ startUtc: string; endUtc: string; durationSeconds: number; cadenceSeconds: number; duplicatePolicy: string;
35
+ coverage: ObservationCoverage; chunks: ObservationWindowDescriptor[] };
36
+ chunks: { descriptor: ObservationWindowDescriptor; artifact: { windowStart: number; windowEnd: number;
37
+ frames: (ObservationRecord<T> & { time: number })[] }; json: string }[];
38
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ function instant(value) {
4
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)) ||
5
+ new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) !== value.slice(0, 10) || Number(value.slice(11, 13)) > 23) throw new Error('An explicit valid observation timestamp with timezone is required')
6
+ return Date.parse(value)
7
+ }
8
+ function positive(value, name) {
9
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error(`Invalid ${name}`)
10
+ }
11
+
12
+ /** Hash the exact JSON bytes that are written and subsequently verified by consumers. */
13
+ export function jsonArtifact(value) {
14
+ const json = `${JSON.stringify(value)}\n`
15
+ return { json, bytes: Buffer.byteLength(json), sha256: createHash('sha256').update(json).digest('hex') }
16
+ }
17
+
18
+ /** Bounded offline compilation. Clock semantics and evidence kind must be named by the caller. */
19
+ export function compileObservationWindows(input, options) {
20
+ const { startUtc, endUtc, sourceId, evidenceKind, timeBasis, windowSeconds = 7200, cadenceSeconds = 60,
21
+ minimumRecords = 0, duplicatePolicy = 'reject', pathPrefix = 'observation-chunks',
22
+ maxRecords = 100000, maxBytes = 256 * 1024 * 1024 } = options
23
+ const start = instant(startUtc), end = instant(endUtc), durationSeconds = (end - start) / 1000
24
+ positive(windowSeconds, 'window duration'); positive(cadenceSeconds, 'cadence'); positive(maxRecords, 'record limit'); positive(maxBytes, 'byte limit')
25
+ if (typeof sourceId !== 'string' || !sourceId || typeof evidenceKind !== 'string' || !evidenceKind || !['capture-schedule', 'receipt', 'measurement'].includes(timeBasis) || end <= start ||
26
+ !Number.isSafeInteger(minimumRecords) || minimumRecords < 0 || !['reject', 'omit', 'last'].includes(duplicatePolicy) ||
27
+ typeof pathPrefix !== 'string' || !pathPrefix || pathPrefix.startsWith('/') || pathPrefix.split('/').some(part => part === '..' || !part)) throw new Error('Invalid observation archive options')
28
+ const records = new Map(), rejected = new Set()
29
+ let inputCount = 0, inputBytes = 0, duplicates = 0, conflicts = 0, outsideWindow = 0
30
+ for (const record of input) {
31
+ if (++inputCount > maxRecords) throw new Error('Observation record limit exceeded')
32
+ const serialized = JSON.stringify(record)
33
+ if (serialized === undefined || (inputBytes += Buffer.byteLength(serialized)) > maxBytes) throw new Error('Observation byte limit exceeded')
34
+ if (!record || typeof record.id !== 'string' || !record.id || record.value === undefined) throw new Error('Observation identity and value required')
35
+ const timestamp = instant(record.timeUtc)
36
+ if (record.receivedAt !== undefined) instant(record.receivedAt)
37
+ if (record.sourceRecordedAt !== undefined) instant(record.sourceRecordedAt)
38
+ if (timestamp < start || timestamp >= end) { outsideWindow++; continue }
39
+ if (rejected.has(record.id)) { conflicts++; continue }
40
+ const prior = records.get(record.id)
41
+ if (prior) {
42
+ if (prior.serialized === serialized) { duplicates++; continue }
43
+ conflicts++
44
+ if (duplicatePolicy === 'reject') throw new Error(`Conflicting observation identity: ${record.id}`)
45
+ if (duplicatePolicy === 'omit') { records.delete(record.id); rejected.add(record.id); continue }
46
+ }
47
+ records.set(record.id, { record, serialized, timestamp })
48
+ }
49
+ const ordered = [...records.values()].sort((a, b) => a.timestamp - b.timestamp || a.record.id.localeCompare(b.record.id))
50
+ if (ordered.length < minimumRecords) throw new Error(`Observation archive has ${ordered.length} records; ${minimumRecords} required`)
51
+ const slots = new Set(ordered.map(item => Math.floor((item.timestamp - start) / (cadenceSeconds * 1000))))
52
+ const expectedSlots = Math.ceil(durationSeconds / cadenceSeconds)
53
+ const missingSlots = []
54
+ // A caller-controlled duration/cadence must not allocate an unbounded coverage index.
55
+ if (expectedSlots > maxRecords) throw new Error('Observation coverage index limit exceeded')
56
+ for (let slot = 0; slot < expectedSlots; slot++) if (!slots.has(slot)) missingSlots.push(slot)
57
+ let longestGapSeconds = 0
58
+ for (let i = 1; i < ordered.length; i++) longestGapSeconds = Math.max(longestGapSeconds, (ordered[i].timestamp - ordered[i - 1].timestamp) / 1000)
59
+ const coverage = { inputCount, retainedRecords: ordered.length, duplicates, conflicts, outsideWindow,
60
+ occupiedSlots: slots.size, expectedSlots, missingSlots, longestGapSeconds,
61
+ leadingGapSeconds: ordered.length ? (ordered[0].timestamp - start) / 1000 : durationSeconds,
62
+ trailingGapSeconds: ordered.length ? (end - ordered.at(-1).timestamp) / 1000 : durationSeconds }
63
+ const groups = new Map()
64
+ for (const { record, timestamp } of ordered) {
65
+ const index = Math.floor((timestamp - start) / (windowSeconds * 1000))
66
+ if (!groups.has(index)) groups.set(index, [])
67
+ groups.get(index).push({ ...record, time: (timestamp - start) / 1000 })
68
+ }
69
+ const chunks = [...groups].map(([index, frames]) => {
70
+ const windowStart = index * windowSeconds, windowEnd = Math.min(durationSeconds, windowStart + windowSeconds)
71
+ const id = `${windowStart}-${windowEnd}`, artifact = { windowStart, windowEnd, frames }, encoded = jsonArtifact(artifact)
72
+ return { descriptor: { id, windowStart, windowEnd, path: `${pathPrefix}/${id}.json`, frameCount: frames.length, bytes: encoded.bytes, sha256: encoded.sha256 }, artifact, json: encoded.json }
73
+ })
74
+ return { manifest: { schemaVersion: 1, sourceId, evidenceKind, timeBasis, startUtc, endUtc, durationSeconds, cadenceSeconds,
75
+ duplicatePolicy, coverage, chunks: chunks.map(chunk => chunk.descriptor) }, chunks }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/data",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Offline data preparation primitives for Motion Studies.",
@@ -20,9 +20,21 @@
20
20
  "tag": "next"
21
21
  },
22
22
  "engines": {
23
- "node": ">=20"
23
+ "node": ">=24.0.0"
24
24
  },
25
25
  "exports": {
26
+ "./observation-windows": {
27
+ "types": "./observation-windows.d.mts",
28
+ "import": "./observation-windows.mjs"
29
+ },
30
+ "./adsb-heatmap": {
31
+ "types": "./adsb-heatmap.d.mts",
32
+ "import": "./adsb-heatmap.mjs"
33
+ },
34
+ "./air-endpoints": {
35
+ "types": "./air-endpoints.d.mts",
36
+ "import": "./air-endpoints.mjs"
37
+ },
26
38
  "./gtfs": {
27
39
  "types": "./gtfs.d.mts",
28
40
  "import": "./gtfs.mjs"
@@ -38,6 +50,42 @@
38
50
  "./merge-network": {
39
51
  "types": "./merge-network.d.mts",
40
52
  "import": "./merge-network.mjs"
53
+ },
54
+ "./network-chunk-cli": {
55
+ "types": "./network-chunk-cli.d.mts",
56
+ "import": "./network-chunk-cli.mjs"
57
+ },
58
+ "./source-store": {
59
+ "types": "./source-store.d.mts",
60
+ "import": "./source-store.mjs"
61
+ },
62
+ "./uk-service-day": {
63
+ "types": "./uk-service-day.d.mts",
64
+ "import": "./uk-service-day.mjs"
65
+ },
66
+ "./webtris": {
67
+ "types": "./webtris.d.mts",
68
+ "import": "./webtris.mjs"
69
+ },
70
+ "./rail-wtt": {
71
+ "types": "./rail-wtt.d.mts",
72
+ "import": "./rail-wtt.mjs"
73
+ },
74
+ "./rail-journeys": {
75
+ "types": "./rail-journeys.d.mts",
76
+ "import": "./rail-journeys.mjs"
77
+ },
78
+ "./rail-routing": {
79
+ "types": "./rail-routing.d.mts",
80
+ "import": "./rail-routing.mjs"
81
+ },
82
+ "./rail-public-calls": {
83
+ "types": "./rail-public-calls.d.mts",
84
+ "import": "./rail-public-calls.mjs"
85
+ },
86
+ "./rail-geometry": {
87
+ "types": "./rail-geometry.d.mts",
88
+ "import": "./rail-geometry.mjs"
41
89
  }
42
90
  },
43
91
  "files": [
@@ -46,11 +94,14 @@
46
94
  "**/*.d.ts",
47
95
  "**/*.d.mts",
48
96
  "**/*.css",
97
+ "**/*.woff2",
98
+ "**/*-OFL.txt",
49
99
  "README.md",
50
100
  "LICENSE"
51
101
  ],
52
102
  "sideEffects": false,
53
103
  "dependencies": {
54
- "@motionstudies/core": "0.1.0-alpha.2"
104
+ "@motionstudies/core": "0.1.0-alpha.21",
105
+ "fast-xml-parser": "5.11.1"
55
106
  }
56
107
  }
@@ -0,0 +1,7 @@
1
+ import type {RailCoordinate, RailOsm} from './rail-routing.mjs'
2
+ export type RailStop = [number, number, string, string, string]
3
+ export interface CorridorGeometry { stops: RailStop[]; paths: RailCoordinate[][]; wayIds?: number[]; pairPaths?: Record<string, RailCoordinate[]>; corridorPaths?: RailCoordinate[][] }
4
+ export interface CorridorJourney { id: string; direction: 'inbound' | 'outbound'; stops: [number, number, number][] }
5
+ export function distance(a: RailCoordinate, b: RailCoordinate): number
6
+ export function railCorridorGeometry(osm: RailOsm, stationCodes: string[]): CorridorGeometry & {wayIds: number[]}
7
+ export function assembleRailCorridor<T extends CorridorJourney, M extends {coverage?: Record<string, unknown>}, B> (input: {journeys: T[]; geometry: CorridorGeometry; metadata: M; bounds: B; excluded?: unknown[]}): {metadata: M & {coverage: {includedJourneys: number; excluded: unknown[]}}; bounds: B; stops: RailStop[]; paths: RailCoordinate[][]; edges: never[]; trains: (T & {start: number; end: number; pathSegments: number[]})[]; corridorPaths: RailCoordinate[][]; fadeKilometres: number}
@@ -0,0 +1,108 @@
1
+ export const distance = (a, b) => Math.hypot((a[0] - b[0]) * Math.cos((a[1] + b[1]) * Math.PI / 360), a[1] - b[1]) * 111.32
2
+ /** Route on connected OSM rails through each station; never replace gaps with straight lines. */
3
+ export function railCorridorGeometry(osm, stationCodes) {
4
+ if (!Array.isArray(stationCodes) || stationCodes.length < 2 || new Set(stationCodes).size !== stationCodes.length) throw new Error('Expected distinct corridor station codes')
5
+ const nodes = new Map(osm.elements.filter(e => e.type === 'node').map(e => [e.id, e]))
6
+ const graph = new Map()
7
+ for (const way of osm.elements.filter(e => e.type === 'way' && e.tags?.railway === 'rail' && !['yard', 'siding', 'spur'].includes(e.tags.service))) {
8
+ for (let i = 1; i < way.nodes.length; i++) {
9
+ const a = nodes.get(way.nodes[i - 1]), b = nodes.get(way.nodes[i])
10
+ if (!a || !b) throw new Error(`Missing OSM nodes on ${way.id}`)
11
+ const weight = distance([a.lon, a.lat], [b.lon, b.lat])
12
+ for (const [from, to] of [[a.id, b.id], [b.id, a.id]]) {
13
+ if (!graph.has(from)) graph.set(from, [])
14
+ graph.get(from).push({ to, weight, way: way.id })
15
+ }
16
+ }
17
+ }
18
+ const stations = stationCodes.map(code => {
19
+ const station = [...nodes.values()].find(node => node.tags?.railway === 'station' && node.tags['ref:crs'] === code)
20
+ if (!station) throw new Error(`Missing OSM station ${code}`)
21
+ return station
22
+ })
23
+ // The largest connected component excludes isolated rails and works-in-progress.
24
+ const visited = new Set(); let component = []
25
+ for (const start of graph.keys()) {
26
+ if (visited.has(start)) continue
27
+ const found = [], queue = [start]; visited.add(start)
28
+ for (let i = 0; i < queue.length; i++) {
29
+ const id = queue[i]; found.push(id)
30
+ for (const { to } of graph.get(id)) if (!visited.has(to)) { visited.add(to); queue.push(to) }
31
+ }
32
+ if (found.length > component.length) component = found
33
+ }
34
+ if (!component.length) throw new Error('No connected railway geometry')
35
+ const anchors = stations.map(station => {
36
+ const point = [station.lon, station.lat]
37
+ const id = component.reduce((best, id) => distance(point, [nodes.get(id).lon, nodes.get(id).lat]) < distance(point, [nodes.get(best).lon, nodes.get(best).lat]) ? id : best, component[0])
38
+ if (distance(point, [nodes.get(id).lon, nodes.get(id).lat]) > 0.25) throw new Error(`Station too far from connected railway: ${station.tags.name}`)
39
+ return id
40
+ })
41
+ const queue = [{ id: anchors[0], weight: 0 }], best = new Map([[anchors[0], 0]]), previous = new Map()
42
+ const target = anchors.at(-1)
43
+ while (queue.length) {
44
+ queue.sort((a, b) => b.weight - a.weight)
45
+ const current = queue.pop()
46
+ if (current.weight !== best.get(current.id)) continue
47
+ if (current.id === target) break
48
+ for (const edge of graph.get(current.id)) {
49
+ const weight = current.weight + edge.weight
50
+ if (weight >= (best.get(edge.to) ?? Infinity)) continue
51
+ best.set(edge.to, weight); previous.set(edge.to, { id: current.id, way: edge.way }); queue.push({ id: edge.to, weight })
52
+ }
53
+ }
54
+ if (!best.has(target)) throw new Error('Disconnected railway corridor')
55
+ const ids = [target], wayIds = new Set()
56
+ while (ids.at(-1) !== anchors[0]) {
57
+ const edge = previous.get(ids.at(-1)); wayIds.add(edge.way); ids.push(edge.id)
58
+ }
59
+ const line = ids.reverse().map(id => [nodes.get(id).lon, nodes.get(id).lat])
60
+ // Snap every intermediate station to this continuous route, avoiding jumps between parallel tracks.
61
+ const indexes = stations.map(station => line.reduce((best, point, i) => distance(point, [station.lon, station.lat]) < distance(line[best], [station.lon, station.lat]) ? i : best, 0))
62
+ const paths = []
63
+ for (let i = 0; i < indexes.length; i++) {
64
+ if (distance(line[indexes[i]], [stations[i].lon, stations[i].lat]) > 0.25) throw new Error(`Railway misses ${stationCodes[i]}`)
65
+ if (!i) continue
66
+ if (indexes[i] <= indexes[i - 1]) throw new Error(`Railway reverses at ${stationCodes[i]}`)
67
+ const path = line.slice(indexes[i - 1], indexes[i] + 1)
68
+ const length = path.slice(1).reduce((sum, point, j) => sum + distance(path[j], point), 0)
69
+ if (length > Math.max(2, distance(path[0], path.at(-1)) * 1.8)) throw new Error(`Implausible railway detour to ${stationCodes[i]}`)
70
+ paths.push(path)
71
+ }
72
+ return { stops: stations.map((station, i) => [...line[indexes[i]], station.tags.name, '', `crs:${stationCodes[i]}`]), paths, wayIds: [...wayIds].sort((a, b) => a - b) }
73
+ }
74
+
75
+
76
+ /** Shared output boundary for operator tables or normalized machine-readable schedules. */
77
+ export function assembleRailCorridor({ journeys, geometry, metadata, bounds, excluded = [] }) {
78
+ const ids = new Set(), paths = [], pathIndexes = new Map()
79
+ const trains = journeys.map(train => {
80
+ if (ids.has(train.id)) throw new Error(`Duplicate rail identity: ${train.id}`)
81
+ ids.add(train.id)
82
+ if (train.stops.length < 2 || !['inbound', 'outbound'].includes(train.direction)) throw new Error(`Invalid rail journey: ${train.id}`)
83
+ for (let i = 0; i < train.stops.length; i++) {
84
+ const [stop, a, d] = train.stops[i]
85
+ if (!geometry.stops[stop] || !Number.isFinite(a) || !Number.isFinite(d) || d < a || (i && a < train.stops[i - 1][2])) throw new Error(`Non-monotonic rail journey: ${train.id}`)
86
+ }
87
+ return { ...train, start: train.stops[0][1], end: train.stops.at(-1)[2], pathSegments: train.stops.slice(1).map(([to], index) => {
88
+ const from = train.stops[index][0], key = `${from}:${to}`
89
+ if (from === to) throw new Error(`Repeated corridor station: ${train.id}`)
90
+ if (pathIndexes.has(key)) return pathIndexes.get(key)
91
+ if (geometry.pairPaths) {
92
+ const path = geometry.pairPaths[key]
93
+ if (!path || path.length < 2 || JSON.stringify(path[0]) !== JSON.stringify(geometry.stops[from].slice(0, 2)) || JSON.stringify(path.at(-1)) !== JSON.stringify(geometry.stops[to].slice(0, 2))) throw new Error(`Missing or disconnected rail path: ${key}`)
94
+ const pathIndex = paths.length
95
+ paths.push(path); pathIndexes.set(key, pathIndex)
96
+ return pathIndex
97
+ }
98
+ const segments = geometry.paths.slice(Math.min(from, to), Math.max(from, to))
99
+ if (segments.length !== Math.abs(to - from) || segments.some(path => path.length < 2)) throw new Error(`Missing corridor geometry: ${key}`)
100
+ for (let i = 1; i < segments.length; i++) if (JSON.stringify(segments[i - 1].at(-1)) !== JSON.stringify(segments[i][0])) throw new Error(`Disconnected corridor geometry: ${key}`)
101
+ const path = segments.flatMap((points, i) => i ? points.slice(1) : points)
102
+ const pathIndex = paths.length
103
+ paths.push(from < to ? path : [...path].reverse()); pathIndexes.set(key, pathIndex)
104
+ return pathIndex
105
+ }) }
106
+ }).sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))
107
+ return { metadata: { ...metadata, coverage: { ...metadata.coverage, includedJourneys: trains.length, excluded } }, bounds, stops: geometry.stops, paths, edges: [], trains, corridorPaths: geometry.corridorPaths ?? geometry.paths, fadeKilometres: 4 }
108
+ }
@@ -0,0 +1,14 @@
1
+ import type { WttGrid } from './rail-wtt.mjs'
2
+ export interface RailStation { id?: number; lon: number; lat: number; tags: Record<string, string> }
3
+ export interface RailSource { table: string; sheet?: string; column?: number; days?: string; dates?: string; page?: number; sha256?: string; url?: string }
4
+ export interface RailPoint { code: string; arrival: number; departure: number; passenger: boolean; pickupOnly: boolean; setDownOnly: boolean; block?: number; rows: number[]; source: number; arr?: number; dep?: number; pass?: number; publicSupplement?: RailSource }
5
+ export interface RailJourney { uid: string; tid: string; operator: string; origin: string; destination: string; originDate: string; points: RailPoint[]; sources: RailSource[] }
6
+ export interface RailConflict { id: string; point: RailPoint; previous: RailPoint | null; sources: RailSource[] }
7
+ export interface JoinedRailJourneys { journeys: RailJourney[]; stations: Record<string, RailStation>; sources: RailSource[]; unmapped: Record<string, number>; conflicts: RailConflict[]; columnCounts: Record<string, number> }
8
+ export interface JoinWttOptions { serviceDate: string; stations: Record<string, RailStation>; operators: string[]; resolveLocation: (name: string, operator: string, context?: {header: boolean}) => string | null | undefined; normalizeName: (name: string) => string; includeTrain?: (tid: string) => boolean }
9
+ export function joinWttColumns(tables: {table: string; sha256: string; sheets: WttGrid}[], options: JoinWttOptions): JoinedRailJourneys
10
+ export interface PublicCall { code: string; time: number; kind: 'arrival' | 'departure' }
11
+ export interface PublicCallRecord extends PublicCall { operator: string; anchors: PublicCall[]; table: string; page: number; column: number }
12
+ export interface PublicCallAudit { added: {uid: string; originDate: string; code: string; time: number; table: string; page: number; column: number}[]; unmatched: Pick<PublicCallRecord, 'code' | 'time' | 'table' | 'page' | 'column'>[]; ambiguous: {record: PublicCallRecord; uids: string[]}[]; sourceColumns: number }
13
+ /** Mutates the supplied journeys. Callers must reject ambiguous/unreviewed unmatched records before emitting output. */
14
+ export function supplementPublicCalls(result: Pick<JoinedRailJourneys, 'journeys'>, records: PublicCallRecord[]): PublicCallAudit