@motionstudies/data 0.1.0-alpha.3 → 0.1.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -2
- package/adsb-heatmap.d.mts +56 -0
- package/adsb-heatmap.mjs +223 -0
- package/air-endpoints.d.mts +7 -0
- package/air-endpoints.mjs +148 -0
- package/feed-expectations.d.mts +3 -0
- package/feed-expectations.mjs +33 -0
- package/feed-incidents.d.mts +44 -0
- package/feed-incidents.mjs +148 -0
- package/feed-observability.d.mts +68 -0
- package/feed-observability.mjs +154 -0
- package/feed-observer.d.mts +30 -0
- package/feed-observer.mjs +193 -0
- package/network-chunk-cli.d.mts +1 -0
- package/network-chunk-cli.mjs +65 -0
- package/observation-windows.d.mts +38 -0
- package/observation-windows.mjs +76 -0
- package/operational-files.mjs +21 -0
- package/package.json +85 -2
- package/rail-geometry.d.mts +7 -0
- package/rail-geometry.mjs +108 -0
- package/rail-journeys.d.mts +14 -0
- package/rail-journeys.mjs +118 -0
- package/rail-public-calls.d.mts +4 -0
- package/rail-public-calls.mjs +48 -0
- package/rail-routing.d.mts +16 -0
- package/rail-routing.mjs +119 -0
- package/rail-wtt.d.mts +12 -0
- package/rail-wtt.mjs +135 -0
- package/rail-xml.mjs +12 -0
- package/recorder-evidence.d.mts +14 -0
- package/recorder-evidence.mjs +136 -0
- package/recorder-observability.d.mts +3 -0
- package/recorder-observability.mjs +106 -0
- package/release-files.d.mts +14 -0
- package/release-files.mjs +52 -0
- package/release.d.mts +10 -0
- package/release.mjs +62 -0
- package/source-store.d.mts +15 -0
- package/source-store.mjs +160 -0
- package/uk-service-day.d.mts +7 -0
- package/uk-service-day.mjs +49 -0
- package/webtris.d.mts +14 -0
- package/webtris.mjs +67 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { readFeedRegistry, readFeedHealth, readOperationalTime, summarizeFeedStages } from './feed-observability.mjs'
|
|
2
|
+
import { recorderFeedHealth } from './recorder-observability.mjs'
|
|
3
|
+
import { dailyFeedExpectation } from './feed-expectations.mjs'
|
|
4
|
+
import { studyDay, SERVICE_TIMEZONE } from './uk-service-day.mjs'
|
|
5
|
+
|
|
6
|
+
export const OBSERVER_LIMITS = Object.freeze({ requests: 9, totalBytes: 10 * 1024 * 1024, timeoutMs: 5000, totalMs: 30000, healthBytes: 1024 * 1024, manifestBytes: 8192, releaseBytes: 2 * 1024 * 1024 })
|
|
7
|
+
const digest = s => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s)
|
|
8
|
+
const sha = async bytes => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
9
|
+
const stage = (state, reason, timestamps = {}, metrics = {}) => ({ stageId: 'serve', state, reasons: [reason], timestamps, metrics })
|
|
10
|
+
function checkUrl(value) {
|
|
11
|
+
const url = new URL(value)
|
|
12
|
+
if (url.username || url.password || url.hash || url.search || !['https:', 'http:'].includes(url.protocol) || url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('Use HTTPS or loopback HTTP without embedded credentials or query strings')
|
|
13
|
+
return url.href
|
|
14
|
+
}
|
|
15
|
+
function config(value, registry) {
|
|
16
|
+
if (value?.schemaVersion !== 1 || value.kind !== 'feed-observer-config' || Object.keys(value).some(k => !['schemaVersion', 'kind', 'producerId', 'healthUrl', 'consumers'].includes(k))) throw new Error('Invalid observer config')
|
|
17
|
+
if (!registry.feeds.some(f => f.producerId === value.producerId) || !Array.isArray(value.consumers) || value.consumers.length > 4) throw new Error('Invalid observer targets')
|
|
18
|
+
const consumers = value.consumers.map(c => {
|
|
19
|
+
if (!c || Object.keys(c).some(k => !['feedId', 'manifestUrl', 'operator', 'deadline', 'timeZone'].includes(k))) throw new Error('Invalid consumer config')
|
|
20
|
+
if (!registry.feeds.some(f => f.id === c.feedId && f.producerId === value.producerId && f.stages.some(s => s.id === 'serve'))) throw new Error('Register the consumer serve stage')
|
|
21
|
+
if (typeof c.operator !== 'string' || !c.operator.trim() || c.operator.length > 100 || c.timeZone !== SERVICE_TIMEZONE || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(c.deadline)) throw new Error('Invalid bus consumer expectation')
|
|
22
|
+
return { ...c, manifestUrl: checkUrl(c.manifestUrl) }
|
|
23
|
+
})
|
|
24
|
+
if (new Set(consumers.map(c => c.feedId)).size !== consumers.length) throw new Error('Duplicate consumer target')
|
|
25
|
+
return { producerId: value.producerId, healthUrl: checkUrl(value.healthUrl), consumers }
|
|
26
|
+
}
|
|
27
|
+
class CheckError extends Error { constructor(reason) { super(reason); this.reason = reason } }
|
|
28
|
+
function client(fetchImpl, tokens, limits) {
|
|
29
|
+
const began = performance.now(), usage = { requests: 0, bytes: 0 }
|
|
30
|
+
const read = async (url, cap, token) => {
|
|
31
|
+
if (usage.requests >= limits.requests || usage.bytes >= limits.totalBytes || performance.now() - began >= limits.totalMs) throw new CheckError('check-budget-exhausted')
|
|
32
|
+
usage.requests++
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
let reader, body, timer
|
|
35
|
+
const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new CheckError('request-timeout')) }, Math.min(limits.timeoutMs, limits.totalMs - (performance.now() - began))) })
|
|
36
|
+
try {
|
|
37
|
+
return await Promise.race([timeout, (async () => {
|
|
38
|
+
const credential = typeof token === 'string' ? { bearer: token } : token ?? {}
|
|
39
|
+
const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache', ...(credential.bearer ? { Authorization: `Bearer ${credential.bearer}` } : {}) }
|
|
40
|
+
if (credential.accessClientId && credential.accessClientSecret) {
|
|
41
|
+
headers['CF-Access-Client-Id'] = credential.accessClientId
|
|
42
|
+
headers['CF-Access-Client-Secret'] = credential.accessClientSecret
|
|
43
|
+
} else if (credential.accessClientId || credential.accessClientSecret) throw new CheckError('credentials-invalid')
|
|
44
|
+
// Workers supports manual redirects; reject 3xx below without forwarding credentials.
|
|
45
|
+
const response = await fetchImpl(url, { headers, signal: controller.signal, redirect: 'manual', cache: 'no-store' })
|
|
46
|
+
body = response.body
|
|
47
|
+
if (!response.ok || response.redirected) throw new CheckError('http-unavailable')
|
|
48
|
+
const advertised = response.headers.get('content-length')
|
|
49
|
+
if (advertised !== null && (!/^\d+$/.test(advertised) || Number(advertised) > cap)) throw new CheckError('response-too-large')
|
|
50
|
+
reader = response.body?.getReader()
|
|
51
|
+
if (!reader) throw new CheckError('document-invalid')
|
|
52
|
+
let size = 0
|
|
53
|
+
const chunks = []
|
|
54
|
+
while (true) {
|
|
55
|
+
const { done, value } = await reader.read()
|
|
56
|
+
if (done) break
|
|
57
|
+
size += value.byteLength; usage.bytes += value.byteLength
|
|
58
|
+
if (size > cap || usage.bytes > limits.totalBytes) throw new CheckError('response-too-large')
|
|
59
|
+
chunks.push(value)
|
|
60
|
+
}
|
|
61
|
+
const bytes = new Uint8Array(size)
|
|
62
|
+
let offset = 0
|
|
63
|
+
for (const c of chunks) { bytes.set(c, offset); offset += c.length }
|
|
64
|
+
let json
|
|
65
|
+
try { json = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) } catch { throw new CheckError('document-invalid') }
|
|
66
|
+
return { bytes, json }
|
|
67
|
+
})()])
|
|
68
|
+
} catch (e) { throw e instanceof CheckError ? e : new CheckError('request-failed') }
|
|
69
|
+
finally { clearTimeout(timer); controller.abort(); if (reader) void reader.cancel().catch(() => {}); else if (body) void body.cancel().catch(() => {}) }
|
|
70
|
+
}
|
|
71
|
+
return { usage, health: url => read(url, limits.healthBytes, tokens.health), consumer: (url, kind, token) => read(url, limits[`${kind}Bytes`], token) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reevaluate a mirrored producer report at the checker's clock; never renew its heartbeat. */
|
|
75
|
+
function refresh(registry, raw, producerId, now) {
|
|
76
|
+
const report = readFeedHealth(raw, registry)
|
|
77
|
+
if (report.producerId !== producerId || Date.parse(report.observedAt) > now + registry.clockSkewSeconds * 1000) throw new Error('Unexpected producer/observer clock')
|
|
78
|
+
const generated = report.producerGeneratedAt ? Date.parse(report.producerGeneratedAt) : null
|
|
79
|
+
const elapsed = generated === null ? null : (now - generated) / 1000
|
|
80
|
+
report.observedAt = new Date(now).toISOString()
|
|
81
|
+
report.telemetry = { state: elapsed === null ? report.telemetry.state : elapsed < -registry.clockSkewSeconds ? 'invalid' : elapsed > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current', ageSeconds: elapsed === null ? null : Math.max(0, elapsed) }
|
|
82
|
+
for (const f of report.feeds) {
|
|
83
|
+
const entry = registry.feeds.find(e => e.id === f.feedId)
|
|
84
|
+
f.stages = f.stages.map(s => {
|
|
85
|
+
if (report.telemetry.state !== 'current') return { ...s, state: 'unknown', reasons: [`telemetry-${report.telemetry.state}`], timestamps: {}, metrics: {} }
|
|
86
|
+
const def = entry.stages.find(d => d.id === s.stageId)
|
|
87
|
+
const field = { capture: 'receivedAt', normalize: 'latestPeriodEnd', analytics: 'lastAttemptAt' }[s.stageId]
|
|
88
|
+
const metric = { capture: 'capture-age-seconds', normalize: 'normalization-lag-seconds', analytics: 'analytics-status-age-seconds' }[s.stageId]
|
|
89
|
+
if (field && s.timestamps[field]) s.metrics[metric] = Math.max(0, (now - Date.parse(s.timestamps[field])) / 1000)
|
|
90
|
+
if (s.stageId === 'capture' && s.timestamps.sourceGeneratedAt) s.metrics['source-age-seconds'] = Math.max(0, (now - Date.parse(s.timestamps.sourceGeneratedAt)) / 1000)
|
|
91
|
+
if (f.configuredState !== 'active' || !['healthy', 'waiting'].includes(s.state)) return s
|
|
92
|
+
if (field && def.maxAgeSeconds !== null) {
|
|
93
|
+
const stamp = s.timestamps[field], age = stamp ? (now - Date.parse(stamp)) / 1000 : null
|
|
94
|
+
if (age === null) return { ...s, state: 'unknown', reasons: ['stage-clock-unavailable'] }
|
|
95
|
+
if (age < -registry.clockSkewSeconds) return { ...s, state: 'unknown', reasons: ['stage-clock-invalid'] }
|
|
96
|
+
if (age > def.maxAgeSeconds) return { ...s, state: s.stageId === 'analytics' ? 'unknown' : 'degraded', reasons: [`${s.stageId}-stale`] }
|
|
97
|
+
}
|
|
98
|
+
if (s.stageId === 'capture' && def.sourceMaxAgeSeconds) {
|
|
99
|
+
const stamp = s.timestamps.sourceGeneratedAt
|
|
100
|
+
if (!stamp) return { ...s, state: 'unknown', reasons: ['source-time-unavailable'] }
|
|
101
|
+
if ((now - Date.parse(stamp)) / 1000 < -registry.clockSkewSeconds) return { ...s, state: 'unknown', reasons: ['source-clock-invalid'] }
|
|
102
|
+
if ((now - Date.parse(stamp)) / 1000 > def.sourceMaxAgeSeconds) return { ...s, state: 'degraded', reasons: ['source-stale'] }
|
|
103
|
+
}
|
|
104
|
+
return s
|
|
105
|
+
})
|
|
106
|
+
f.state = f.configuredState === 'active' ? summarizeFeedStages(f.stages) : f.configuredState
|
|
107
|
+
}
|
|
108
|
+
if (report.telemetry.state !== 'current') report.capacity = { state: 'unknown', reasons: [`telemetry-${report.telemetry.state}`], metrics: {} }
|
|
109
|
+
return readFeedHealth(report, registry)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function consumer(c, expectation, api, now, skew, token) {
|
|
113
|
+
try {
|
|
114
|
+
const { json: manifest } = await api.consumer(c.manifestUrl, 'manifest', token)
|
|
115
|
+
const id = manifest.resultId, file = manifest.report
|
|
116
|
+
if (manifest.schemaVersion !== 1 || !digest(id) || file?.path !== `releases/${id}/result.json` || !Number.isSafeInteger(file.bytes) || file.bytes < 1 || file.bytes > OBSERVER_LIMITS.releaseBytes || !digest(file.sha256)) throw new CheckError('manifest-invalid')
|
|
117
|
+
const publishedAt = readOperationalTime(manifest.observedAt)
|
|
118
|
+
if (Date.parse(publishedAt) > now + skew * 1000) throw new CheckError('publication-clock-invalid')
|
|
119
|
+
const { json: release, bytes } = await api.consumer(new URL(file.path, c.manifestUrl).href, 'release', token)
|
|
120
|
+
const { resultId, ...payload } = release
|
|
121
|
+
if (bytes.length !== file.bytes || await sha(bytes) !== file.sha256 || resultId !== id || await sha(new TextEncoder().encode(JSON.stringify(payload))) !== id) throw new CheckError('release-integrity-failed')
|
|
122
|
+
if (release.schemaVersion !== 2 || release.kind !== 'feed-quality-history' || release.operator !== c.operator || !Array.isArray(release.days) || release.days.length > 366) throw new CheckError('release-invalid')
|
|
123
|
+
readOperationalTime(`${release.from}T00:00:00Z`); readOperationalTime(`${release.to}T00:00:00Z`)
|
|
124
|
+
if (release.from > release.to) throw new CheckError('release-invalid')
|
|
125
|
+
const expected = studyDay(expectation.serviceDate)
|
|
126
|
+
const selected = release.days.filter(d => d.date === expectation.serviceDate)
|
|
127
|
+
if (release.from > expectation.serviceDate || release.to < expectation.serviceDate || selected.length === 0) return stage('degraded', 'consumer-period-overdue')
|
|
128
|
+
if (selected.length !== 1) throw new CheckError('release-invalid')
|
|
129
|
+
const day = selected[0]
|
|
130
|
+
if (day.status !== 'current') return stage('degraded', 'consumer-day-overdue')
|
|
131
|
+
if (!digest(day.revision) || !day.quality || day.day?.startUtc !== expected.startUtc || day.day?.endUtc !== expected.endUtc || day.day?.serviceDate !== expected.serviceDate || Date.parse(publishedAt) < Date.parse(expected.endUtc)) throw new CheckError('release-day-invalid')
|
|
132
|
+
return stage('healthy', 'consumer-release-verified', { lastSuccessAt: publishedAt, latestPeriodEnd: expected.endUtc }, { 'verified-release-bytes': bytes.length })
|
|
133
|
+
} catch (e) { return stage('degraded', `consumer-${e instanceof CheckError ? e.reason : 'document-invalid'}`) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Portable Web-API checker; reads configured endpoints only, with no redirects or retries. */
|
|
137
|
+
export async function observeFeeds(registryValue, configValue, { fetchImpl = fetch, clock = Date.now, tokens = {}, limits = OBSERVER_LIMITS } = {}) {
|
|
138
|
+
const registry = readFeedRegistry(registryValue), c = config(configValue, registry)
|
|
139
|
+
// Tests may lower bounds, never raise them.
|
|
140
|
+
for (const [key, value] of Object.entries(OBSERVER_LIMITS)) if (!Number.isSafeInteger(limits[key]) || limits[key] < 1 || limits[key] > value) throw new Error('Invalid check limit')
|
|
141
|
+
const api = client(fetchImpl, tokens, limits), startedAt = new Date(clock()).toISOString()
|
|
142
|
+
let source = null, sourceError = null
|
|
143
|
+
try { source = (await api.health(c.healthUrl)).json; refresh(registry, source, c.producerId, clock()) }
|
|
144
|
+
catch (e) { source = null; sourceError = e instanceof CheckError ? e.reason : 'document-invalid' }
|
|
145
|
+
const checks = []
|
|
146
|
+
for (const target of c.consumers) {
|
|
147
|
+
const definition = registry.feeds.find(f => f.id === target.feedId)
|
|
148
|
+
if (definition.state !== 'active') continue
|
|
149
|
+
const expectation = dailyFeedExpectation(target, clock())
|
|
150
|
+
const health = await consumer(target, expectation, api, clock(), registry.clockSkewSeconds, tokens.consumers?.[target.feedId])
|
|
151
|
+
checks.push({ feedId: target.feedId, expectation, ...health })
|
|
152
|
+
}
|
|
153
|
+
const finished = clock()
|
|
154
|
+
for (const check of checks) {
|
|
155
|
+
const target = c.consumers.find(t => t.feedId === check.feedId), latest = dailyFeedExpectation(target, finished)
|
|
156
|
+
if (latest.serviceDate !== check.expectation.serviceDate) Object.assign(check, stage('degraded', 'consumer-expectation-advanced'), { expectation: latest })
|
|
157
|
+
}
|
|
158
|
+
let report
|
|
159
|
+
if (source) report = refresh(registry, source, c.producerId, finished)
|
|
160
|
+
else report = recorderFeedHealth(registry, sourceError === 'document-invalid' ? {} : null, { producerId: c.producerId, now: finished })
|
|
161
|
+
if (report.telemetry.state === 'current') for (const check of checks) {
|
|
162
|
+
const feed = report.feeds.find(f => f.feedId === check.feedId)
|
|
163
|
+
const { feedId: _feedId, expectation: _expectation, ...health } = check
|
|
164
|
+
feed.stages = feed.stages.map(s => s.stageId === 'serve' ? health : s)
|
|
165
|
+
feed.state = summarizeFeedStages(feed.stages)
|
|
166
|
+
}
|
|
167
|
+
return { schemaVersion: 1, kind: 'feed-observer-check', startedAt, completedAt: new Date(finished).toISOString(), sourceError, requests: api.usage.requests, bytes: api.usage.bytes,
|
|
168
|
+
report: readFeedHealth(report, registry), consumers: checks }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Assess a stored check at read time. A successful old check is historical evidence. */
|
|
172
|
+
export function assessObserverCheck(registryValue, checked, { now = Date.now(), maxAgeSeconds = 180 } = {}) {
|
|
173
|
+
const registry = readFeedRegistry(registryValue)
|
|
174
|
+
if (!Number.isFinite(now) || !Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > 3600) throw new Error('Invalid observer clock/threshold')
|
|
175
|
+
const unknown = reason => ({ state: 'unknown', reasons: [reason] })
|
|
176
|
+
if (!checked) return unknown('checker-missing')
|
|
177
|
+
try {
|
|
178
|
+
if (checked.schemaVersion !== 1 || checked.kind !== 'feed-observer-check' || !Array.isArray(checked.consumers)) return unknown('checker-invalid')
|
|
179
|
+
const completed = Date.parse(readOperationalTime(checked.completedAt)), started = Date.parse(readOperationalTime(checked.startedAt))
|
|
180
|
+
if (started > completed || completed > now + registry.clockSkewSeconds * 1000) return unknown('checker-clock-invalid')
|
|
181
|
+
if (now - completed > maxAgeSeconds * 1000) return unknown('checker-stale')
|
|
182
|
+
const report = refresh(registry, checked.report, checked.report.producerId, now)
|
|
183
|
+
if (report.telemetry.state !== 'current') return unknown(`telemetry-${report.telemetry.state}`)
|
|
184
|
+
for (const c of checked.consumers) {
|
|
185
|
+
if (Date.parse(readOperationalTime(c.expectation.nextDeadlineAt)) <= now) return unknown('consumer-expectation-expired')
|
|
186
|
+
if (!['healthy', 'degraded', 'unknown', 'waiting'].includes(c.state)) return unknown('checker-invalid')
|
|
187
|
+
}
|
|
188
|
+
const states = [report.capacity.state, ...report.feeds.filter(f => f.configuredState === 'active').map(f => f.state), ...checked.consumers.map(c => c.state)]
|
|
189
|
+
if (states.includes('degraded')) return { state: 'degraded', reasons: ['feed-attention-required'] }
|
|
190
|
+
if (states.includes('unknown')) return unknown('feed-evidence-unavailable')
|
|
191
|
+
return { state: 'healthy', reasons: ['check-current'] }
|
|
192
|
+
} catch { return unknown('checker-invalid') }
|
|
193
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { open, constants } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
/** Read a bounded regular file without following a final symlink or blocking on a FIFO. */
|
|
4
|
+
export async function readOperationalFile(path, limit) {
|
|
5
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
6
|
+
try {
|
|
7
|
+
const stat = await handle.stat()
|
|
8
|
+
if (!stat.isFile() || stat.size > limit) throw new Error('Input must be a bounded regular file')
|
|
9
|
+
// Size the buffer from the file, not the limit, so a generous limit costs nothing for small inputs; one spare byte detects growth.
|
|
10
|
+
const bytes = Buffer.alloc(Math.min(limit, stat.size) + 1)
|
|
11
|
+
let size = 0
|
|
12
|
+
while (size <= limit) {
|
|
13
|
+
const { bytesRead } = await handle.read(bytes, size, bytes.length - size, null)
|
|
14
|
+
if (!bytesRead) break
|
|
15
|
+
size += bytesRead
|
|
16
|
+
}
|
|
17
|
+
const after = await handle.stat()
|
|
18
|
+
if (size > limit || size !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) throw new Error('Input changed or exceeds read limit')
|
|
19
|
+
return bytes.subarray(0, size)
|
|
20
|
+
} finally { await handle.close() }
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionstudies/data",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.31",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Offline data preparation primitives for Motion Studies.",
|
|
@@ -23,6 +23,18 @@
|
|
|
23
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,74 @@
|
|
|
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
|
+
"./release": {
|
|
63
|
+
"types": "./release.d.mts",
|
|
64
|
+
"import": "./release.mjs"
|
|
65
|
+
},
|
|
66
|
+
"./release-files": {
|
|
67
|
+
"types": "./release-files.d.mts",
|
|
68
|
+
"import": "./release-files.mjs"
|
|
69
|
+
},
|
|
70
|
+
"./uk-service-day": {
|
|
71
|
+
"types": "./uk-service-day.d.mts",
|
|
72
|
+
"import": "./uk-service-day.mjs"
|
|
73
|
+
},
|
|
74
|
+
"./webtris": {
|
|
75
|
+
"types": "./webtris.d.mts",
|
|
76
|
+
"import": "./webtris.mjs"
|
|
77
|
+
},
|
|
78
|
+
"./rail-wtt": {
|
|
79
|
+
"types": "./rail-wtt.d.mts",
|
|
80
|
+
"import": "./rail-wtt.mjs"
|
|
81
|
+
},
|
|
82
|
+
"./rail-journeys": {
|
|
83
|
+
"types": "./rail-journeys.d.mts",
|
|
84
|
+
"import": "./rail-journeys.mjs"
|
|
85
|
+
},
|
|
86
|
+
"./rail-routing": {
|
|
87
|
+
"types": "./rail-routing.d.mts",
|
|
88
|
+
"import": "./rail-routing.mjs"
|
|
89
|
+
},
|
|
90
|
+
"./rail-public-calls": {
|
|
91
|
+
"types": "./rail-public-calls.d.mts",
|
|
92
|
+
"import": "./rail-public-calls.mjs"
|
|
93
|
+
},
|
|
94
|
+
"./rail-geometry": {
|
|
95
|
+
"types": "./rail-geometry.d.mts",
|
|
96
|
+
"import": "./rail-geometry.mjs"
|
|
97
|
+
},
|
|
98
|
+
"./feed-observability": {
|
|
99
|
+
"types": "./feed-observability.d.mts",
|
|
100
|
+
"import": "./feed-observability.mjs"
|
|
101
|
+
},
|
|
102
|
+
"./recorder-observability": {
|
|
103
|
+
"types": "./recorder-observability.d.mts",
|
|
104
|
+
"import": "./recorder-observability.mjs"
|
|
105
|
+
},
|
|
106
|
+
"./recorder-evidence": {
|
|
107
|
+
"types": "./recorder-evidence.d.mts",
|
|
108
|
+
"import": "./recorder-evidence.mjs"
|
|
109
|
+
},
|
|
110
|
+
"./feed-incidents": {
|
|
111
|
+
"types": "./feed-incidents.d.mts",
|
|
112
|
+
"import": "./feed-incidents.mjs"
|
|
113
|
+
},
|
|
114
|
+
"./feed-observer": {
|
|
115
|
+
"types": "./feed-observer.d.mts",
|
|
116
|
+
"import": "./feed-observer.mjs"
|
|
117
|
+
},
|
|
118
|
+
"./feed-expectations": {
|
|
119
|
+
"types": "./feed-expectations.d.mts",
|
|
120
|
+
"import": "./feed-expectations.mjs"
|
|
41
121
|
}
|
|
42
122
|
},
|
|
43
123
|
"files": [
|
|
@@ -46,11 +126,14 @@
|
|
|
46
126
|
"**/*.d.ts",
|
|
47
127
|
"**/*.d.mts",
|
|
48
128
|
"**/*.css",
|
|
129
|
+
"**/*.woff2",
|
|
130
|
+
"**/*-OFL.txt",
|
|
49
131
|
"README.md",
|
|
50
132
|
"LICENSE"
|
|
51
133
|
],
|
|
52
134
|
"sideEffects": false,
|
|
53
135
|
"dependencies": {
|
|
54
|
-
"@motionstudies/core": "0.1.0-alpha.
|
|
136
|
+
"@motionstudies/core": "0.1.0-alpha.31",
|
|
137
|
+
"fast-xml-parser": "5.11.2"
|
|
55
138
|
}
|
|
56
139
|
}
|
|
@@ -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
|
+
}
|