@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,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
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {wttTiming,wttRunsOn,wttTerminal} from './rail-wtt.mjs'
|
|
2
|
+
import {checkedDate} from './uk-service-day.mjs'
|
|
3
|
+
const addDays=(date,days)=>new Date(Date.parse(date+'T12:00:00Z')+days*86400000).toISOString().slice(0,10)
|
|
4
|
+
// Preserve the source reader's nearest-day tie handling (round half to even).
|
|
5
|
+
const roundDay=x=>{const floor=Math.floor(x);return x-floor===.5?(floor%2===0?floor:floor+1):Math.round(x)}
|
|
6
|
+
|
|
7
|
+
/** Join WTT banks by UID and originating date. Region/operator resolution is supplied by the edition. */
|
|
8
|
+
export function joinWttColumns(tables,{serviceDate,stations,operators,resolveLocation,normalizeName,includeTrain=tid=>/^[129][A-Z][0-9]{2}$/.test(tid)}){
|
|
9
|
+
checkedDate(serviceDate)
|
|
10
|
+
if([0,1,6].includes(new Date(serviceDate+'T12:00:00Z').getUTCDay()))throw new Error('Sunday banks are unsupported; joined assembly requires all three adjacent banks (Tuesday to Friday only)')
|
|
11
|
+
const joined=new Map(),counts={},unmapped={},sources=[]
|
|
12
|
+
for(const {table,sha256,sheets} of tables){
|
|
13
|
+
sources.push({table,sha256})
|
|
14
|
+
for(const [sheet,rows] of Object.entries(sheets)){
|
|
15
|
+
if(!sheet.startsWith('Mondays to Fridays')&&!sheet.startsWith('Saturdays'))throw new Error(`Unexpected sheet ${sheet}`)
|
|
16
|
+
const expected=['TID','UID','Operator','Origin','Destination','Timing Load','Dates of Operation','Running Days','Service Code']
|
|
17
|
+
if(JSON.stringify(rows.slice(0,9).map(r=>r[1]??''))!==JSON.stringify(expected))throw new Error(`Unrecognized WTT headers: ${table}/${sheet}`)
|
|
18
|
+
const labelled=[];let location='',block=0
|
|
19
|
+
for(let i=9;i<rows.length;i++){const row=rows[i];if(row[0]){location=row[0];block++}if(['arr','dep','pass'].includes(row[1]))labelled.push([i+1,block,location,row[1],row])}
|
|
20
|
+
for(let column=2;column<rows[0].length;column++){
|
|
21
|
+
const cell=i=>rows[i][column]??'',tid=cell(0),uid=cell(1),operator=cell(2)
|
|
22
|
+
if(!operators.includes(operator)||!includeTrain(tid))continue
|
|
23
|
+
const [origin,originTime]=wttTerminal(cell(3)),[destination,destinationTime]=wttTerminal(cell(4)),days=cell(7),dateRange=cell(6)
|
|
24
|
+
const activeDays=[-1,0,1].filter(offset=>{
|
|
25
|
+
const date=addDays(serviceDate,offset),day=new Date(date+'T12:00:00Z').getUTCDay()
|
|
26
|
+
return (day===6&&sheet.startsWith('Saturdays')||day>=1&&day<=5&&sheet.startsWith('Mondays to Fridays'))&&wttRunsOn(days,dateRange,date)
|
|
27
|
+
})
|
|
28
|
+
if(!activeDays.length)continue
|
|
29
|
+
if(!uid)throw new Error('Missing UID')
|
|
30
|
+
const points=[];let previous=-1,day=0,firstTime=null
|
|
31
|
+
for(const [rowNumber,block,name,kind,row] of labelled){
|
|
32
|
+
const value=row[column]??'';if(value===''||value==='..')continue
|
|
33
|
+
let [time,symbols]=wttTiming(value);time+=day*86400
|
|
34
|
+
if(time<previous-43200){day++;time+=86400}
|
|
35
|
+
if(time<previous)throw new Error(`Non-monotonic WTT ${table}:${sheet}:${column+1}:${rowNumber}`)
|
|
36
|
+
previous=time;if(firstTime===null)firstTime=time
|
|
37
|
+
const code=resolveLocation(name,operator)
|
|
38
|
+
if(!code){if(symbols.includes('T')||symbols.startsWith('U')||symbols.startsWith('D'))unmapped[name]=(unmapped[name]??0)+1;continue}
|
|
39
|
+
if(!stations[code])throw new Error(`Unknown resolved station: ${code}`)
|
|
40
|
+
if(!points.length||points.at(-1).block!==block)points.push({code,block,rows:[],passenger:false,pickupOnly:false,setDownOnly:false})
|
|
41
|
+
const point=points.at(-1);point[kind]=time;point.rows.push(rowNumber)
|
|
42
|
+
const activity=symbols.split(' ')[0]
|
|
43
|
+
point.passenger ||= (activity.includes('T')||['U','D'].includes(activity))&&!activity.includes('N')&&!activity.includes('S')
|
|
44
|
+
point.pickupOnly ||= activity==='U';point.setDownOnly ||= activity==='D'
|
|
45
|
+
}
|
|
46
|
+
if(!points.length)continue
|
|
47
|
+
const originDay=originTime>firstTime+3600?-1:0
|
|
48
|
+
for(const offset of activeDays){
|
|
49
|
+
const originDate=addDays(serviceDate,offset+originDay),key=`${uid}:${originDate}`,source={table,sheet,column:column+1,days,dates:dateRange}
|
|
50
|
+
if(!joined.has(key))joined.set(key,{uid,tid,operator,origin,destination,originDate,points:[],sources:[]})
|
|
51
|
+
const entry=joined.get(key)
|
|
52
|
+
if(entry.origin!==origin||entry.destination!==destination||entry.operator!==operator)throw new Error(`Conflicting active UID ${key}`)
|
|
53
|
+
entry.sources.push(source)
|
|
54
|
+
for(const point of points){
|
|
55
|
+
let arrival=point.arr??point.dep??point.pass,departure=point.dep??point.arr??point.pass
|
|
56
|
+
const station=stations[point.code],publicTimes=[]
|
|
57
|
+
for(const [terminalName,terminalTime] of [[origin,originTime],[destination,destinationTime]])if(normalizeName(station.tags.name)===normalizeName(terminalName))publicTimes.push(terminalTime+roundDay((arrival-terminalTime)/86400)*86400)
|
|
58
|
+
if(publicTimes.length){const closest=publicTimes.reduce((a,b)=>Math.abs(b-arrival)<Math.abs(a-arrival)?b:a);if(Math.abs(closest-arrival)<=300)arrival=departure=closest}
|
|
59
|
+
entry.points.push({...point,rows:[...point.rows],arrival:arrival+offset*86400,departure:departure+offset*86400,source:entry.sources.length-1})
|
|
60
|
+
}
|
|
61
|
+
for(const [headerRow,name,terminalTime] of [[4,origin,originTime],[5,destination,destinationTime]]){
|
|
62
|
+
const code=resolveLocation(name,operator,{header:true});if(!code)continue
|
|
63
|
+
const reference=headerRow===4?firstTime:previous,time=terminalTime+roundDay((reference-terminalTime)/86400)*86400+offset*86400
|
|
64
|
+
if(!entry.points.some(p=>p.code===code&&Math.abs(p.arrival-time)<=300))entry.points.push({code,arrival:time,departure:time,passenger:true,pickupOnly:false,setDownOnly:false,block:-headerRow,rows:[headerRow],source:entry.sources.length-1})
|
|
65
|
+
}
|
|
66
|
+
counts[operator]=(counts[operator]??0)+1
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const conflicts=[],journeys=[]
|
|
72
|
+
for(const [id,entry] of joined){
|
|
73
|
+
const points=[]
|
|
74
|
+
for(const point of entry.points.sort((a,b)=>a.arrival-b.arrival||a.departure-b.departure)){
|
|
75
|
+
let matches=points.slice(-8).filter(p=>p.code===point.code&&Math.abs(p.arrival-point.arrival)<=60&&Math.abs(p.departure-point.departure)<=60)
|
|
76
|
+
if(points.length&&points.at(-1).code===point.code&&point.arrival-points.at(-1).departure<=1200)matches=[points.at(-1)]
|
|
77
|
+
if(matches.length){const other=matches[0];other.arrival=Math.min(other.arrival,point.arrival);other.departure=Math.max(other.departure,point.departure);other.passenger ||= point.passenger;other.pickupOnly ||= point.pickupOnly;other.setDownOnly ||= point.setDownOnly}
|
|
78
|
+
else points.push(point)
|
|
79
|
+
}
|
|
80
|
+
if(points.length<2||points[0].arrival>=86400||points.at(-1).departure<0)continue
|
|
81
|
+
points.forEach((point,i)=>{if(point.departure<point.arrival||i&&point.arrival<points[i-1].departure)conflicts.push({id,point,previous:i?points[i-1]:null,sources:entry.sources})})
|
|
82
|
+
entry.points=points;journeys.push(entry)
|
|
83
|
+
}
|
|
84
|
+
return {journeys,stations,sources,unmapped,conflicts,columnCounts:counts}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reconcile supplied public-call records using two surrounding passenger anchors.
|
|
88
|
+
* Parsing table layouts and accepting known calendar exclusions remain edition policy.
|
|
89
|
+
*/
|
|
90
|
+
export function supplementPublicCalls(result,records){
|
|
91
|
+
const byOperator=new Map();for(const train of result.journeys){if(!byOperator.has(train.operator))byOperator.set(train.operator,[]);byOperator.get(train.operator).push(train)}
|
|
92
|
+
const added=[],unmatched=[],ambiguous=[]
|
|
93
|
+
for(const record of records){
|
|
94
|
+
const candidates=[]
|
|
95
|
+
for(const train of byOperator.get(record.operator)??[])for(const day of [-1,0,1]){
|
|
96
|
+
const time=record.time+day*86400
|
|
97
|
+
if(time<0||time>=86400||time<train.points[0].arrival||time>train.points.at(-1).departure)continue
|
|
98
|
+
const matches=[]
|
|
99
|
+
for(const anchor of record.anchors)for(const point of train.points){
|
|
100
|
+
if(point.code!==anchor.code||!point.passenger)continue
|
|
101
|
+
const delta=((point[anchor.kind]-anchor.time+43200)%86400+86400)%86400-43200
|
|
102
|
+
if(Math.abs(delta)<=60)matches.push([point,Math.abs(delta)])
|
|
103
|
+
}
|
|
104
|
+
const before=matches.filter(([p])=>p.departure<=time),after=matches.filter(([p])=>p.arrival>=time)
|
|
105
|
+
if(before.length&&after.length&&new Set(matches.map(([p])=>p.code)).size>=2)candidates.push({score:matches.reduce((n,[,d])=>n+d,0)/matches.length,train,time,bracket:[Math.max(...before.map(([p])=>p.departure)),Math.min(...after.map(([p])=>p.arrival))]})
|
|
106
|
+
}
|
|
107
|
+
if(!candidates.length){const {code,time,table,page,column}=record;unmatched.push({code,time,table,page,column});continue}
|
|
108
|
+
const best=Math.min(...candidates.map(c=>c.score)),selected=candidates.filter(c=>c.score===best)
|
|
109
|
+
if(new Set(selected.map(c=>JSON.stringify(c.bracket))).size>1){ambiguous.push({record,uids:selected.map(c=>c.train.uid)});continue}
|
|
110
|
+
for(const {train,time} of selected){
|
|
111
|
+
if(train.points.some(p=>p.code===record.code&&Math.abs(p.arrival-time)<=120))continue
|
|
112
|
+
const source={table:record.table,page:record.page,column:record.column};train.sources.push(source)
|
|
113
|
+
train.points.push({code:record.code,arrival:time,departure:time,passenger:true,pickupOnly:false,setDownOnly:false,rows:[],source:train.sources.length-1,publicSupplement:source});train.points.sort((a,b)=>a.arrival-b.arrival)
|
|
114
|
+
added.push({uid:train.uid,originDate:train.originDate,code:record.code,time,...source})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {added,unmatched,ambiguous,sourceColumns:records.length}
|
|
118
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type {PublicCallRecord} from './rail-journeys.mjs'
|
|
2
|
+
export interface PublicTimetablePage { page: number; rows: [number, [number, number, string][]][]; text: string }
|
|
3
|
+
export function publicTimetablePages(bytes: Uint8Array): PublicTimetablePage[]
|
|
4
|
+
export function publicTimetableColumns(pages: PublicTimetablePage[], options: {table: string; target: string; operator: string; acceptPage: (page: PublicTimetablePage) => boolean; resolveCode: (name: string) => string | null | undefined}): PublicCallRecord[]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {parseRailXml, xmlContent, xmlAttribute, xmlText} from './rail-xml.mjs'
|
|
2
|
+
|
|
3
|
+
const descendants = (nodes, name) => nodes.flatMap(node => Object.entries(node).flatMap(([key, value]) => key === ':@' ? [] : [...(key.split(':').at(-1) === name ? [node] : []), ...(Array.isArray(value) ? descendants(value, name) : [])]))
|
|
4
|
+
|
|
5
|
+
/** Read pdftotext bbox words. Page/calendar acceptance and station names belong to the caller. */
|
|
6
|
+
export function publicTimetablePages(bytes) {
|
|
7
|
+
// pdftotext emits a fixed XHTML declaration. It is metadata, never fetched or expanded.
|
|
8
|
+
const text = Buffer.from(bytes).toString('utf8').replace(/<!DOCTYPE html PUBLIC "-\/\/W3C\/\/DTD XHTML 1\.0 Transitional\/\/EN"\s+"http:\/\/www\.w3\.org\/TR\/xhtml1\/DTD\/xhtml1-transitional\.dtd">/, '')
|
|
9
|
+
return descendants(parseRailXml(Buffer.from(text)), 'page').map((page, index) => {
|
|
10
|
+
const rows = []
|
|
11
|
+
const words = descendants(xmlContent(page), 'word').map(word => ({ y: Number(xmlAttribute(word, 'yMin')), a: Number(xmlAttribute(word, 'xMin')), b: Number(xmlAttribute(word, 'xMax')), text: xmlText(xmlContent(word)) }))
|
|
12
|
+
if (words.some(w => ![w.y, w.a, w.b].every(Number.isFinite))) throw new Error('Invalid public timetable word bounds')
|
|
13
|
+
for (const word of words.sort((a, b) => a.y - b.y)) {
|
|
14
|
+
if (!rows.length || Math.abs(rows.at(-1)[0] - word.y) > .8) rows.push([word.y, []])
|
|
15
|
+
rows.at(-1)[1].push([word.a, word.b, word.text])
|
|
16
|
+
}
|
|
17
|
+
for (const [, words] of rows) words.sort((a, b) => a[0] - b[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0))
|
|
18
|
+
return { page: index + 1, rows, text: rows.flatMap(([, words]) => words.map(w => w[2])).join(' ') }
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function publicTimetableColumns(pages, {table, target, operator, acceptPage, resolveCode}) {
|
|
23
|
+
const records = []
|
|
24
|
+
for (const page of pages) {
|
|
25
|
+
if (!acceptPage(page)) continue
|
|
26
|
+
const {rows} = page, starts = rows.flatMap(([, words], i) => words.some(w => w[2] === 'Operator') ? [i] : [])
|
|
27
|
+
for (let bank = 0; bank < starts.length; bank++) {
|
|
28
|
+
const start = starts[bank], end = starts[bank + 1] ?? rows.length, header = rows[start][1], word = header.find(w => w[2] === 'Operator')
|
|
29
|
+
const columns = header.filter(w => w[0] > word[1] && w[2] !== 'Operator').map(w => (w[0] + w[1]) / 2)
|
|
30
|
+
if (!columns.length) continue
|
|
31
|
+
const calls = columns.map(() => [])
|
|
32
|
+
for (const [, words] of rows.slice(start + 1, end)) {
|
|
33
|
+
const name = words.filter(w => w[1] < columns[0] - 5).map(w => w[2]).join(' '), code = resolveCode(name)
|
|
34
|
+
if (!code) continue
|
|
35
|
+
const kind = name.trimEnd().endsWith('a') ? 'arrival' : 'departure'
|
|
36
|
+
for (const [a, b, word] of words) {
|
|
37
|
+
const time = /^(\d{2})(\d{2})([a-z]?)$/.exec(word)
|
|
38
|
+
if (!time || +time[1] > 23 || +time[2] > 59) continue
|
|
39
|
+
const middle = (a + b) / 2, column = columns.reduce((best, x, i) => Math.abs(x - middle) < Math.abs(columns[best] - middle) ? i : best, 0)
|
|
40
|
+
if (Math.abs(columns[column] - middle) > 5) continue
|
|
41
|
+
calls[column].push({code, time: +time[1] * 3600 + +time[2] * 60, kind: time[3] === 'a' ? 'arrival' : kind})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
calls.forEach((values, i) => { for (const call of values) if (call.code === target) records.push({...call, operator, anchors: values.filter(v => v.code !== target), table, page: page.page, column: i + 1}) })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return records
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {RailStation, JoinedRailJourneys} from './rail-journeys.mjs'
|
|
2
|
+
export type RailCoordinate = [number, number]
|
|
3
|
+
export interface RailOsmNode { type: 'node'; id: number; lon: number; lat: number; tags?: Record<string, string> }
|
|
4
|
+
export interface RailOsmWay { type: 'way'; id: number; nodes: number[]; tags?: Record<string, string> }
|
|
5
|
+
export interface RailOsm { elements: (RailOsmNode | RailOsmWay | {type: 'relation'; id: number})[] }
|
|
6
|
+
export interface RailRoutingOptions { project: (point: RailCoordinate) => RailCoordinate; measure?: (a: RailCoordinate, b: RailCoordinate) => number; includeWay?: (way: RailOsmWay, nodes: (RailOsmNode | undefined)[]) => boolean; anchorRadius?: (code: string) => number }
|
|
7
|
+
export class RailwayGraph {
|
|
8
|
+
constructor(osm: RailOsm, stations: Record<string, RailStation>, options: RailRoutingOptions)
|
|
9
|
+
stationCandidates(code: string): [number, number][]
|
|
10
|
+
distance(a: RailCoordinate, b: RailCoordinate): number
|
|
11
|
+
route(a: string, b: string): [RailCoordinate[], number[], number]
|
|
12
|
+
}
|
|
13
|
+
export function railBoundaryDistance(point: RailCoordinate, rings: RailCoordinate[][], project: RailRoutingOptions['project']): number
|
|
14
|
+
export type RailBoundary = {type: 'Polygon'; coordinates: RailCoordinate[][]} | {type: 'MultiPolygon'; coordinates: RailCoordinate[][][]}
|
|
15
|
+
export interface RoutedRailJourneys extends JoinedRailJourneys { geometry: Record<string, {path: RailCoordinate[]; ways: number[]; length: number}>; stationBoundaryDistances: Record<string, number>; geometryFailures: Record<string, string>; excludedGeometry: {uid: string; originDate: string; operator: string; missing: string[]}[] }
|
|
16
|
+
export function routeRailJourneys(result: JoinedRailJourneys, osm: RailOsm, options: RailRoutingOptions & {boundary: RailBoundary; routeMargin?: number; visibleMargin?: number}): RoutedRailJourneys
|
package/rail-routing.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Connected OSM routing. Projection and regional track exceptions are explicit caller policy. */
|
|
2
|
+
export class RailwayGraph {
|
|
3
|
+
constructor(osm, stations, { project, measure, includeWay = way => way.tags?.railway === 'rail', anchorRadius = () => .25 } = {}) {
|
|
4
|
+
if (typeof project !== 'function') throw new Error('A kilometre projection is required')
|
|
5
|
+
this.project = project; this.measure = measure; this.stations = stations; this.anchorRadius = anchorRadius
|
|
6
|
+
this.nodes = new Map(osm.elements.filter(e => e.type === 'node').map(e => [e.id, e]))
|
|
7
|
+
this.graph = new Map(); this.grid = new Map(); this.candidates = new Map(); this.cache = new Map()
|
|
8
|
+
for (const way of osm.elements) {
|
|
9
|
+
if (way.type !== 'way' || ['yard', 'siding', 'spur'].includes(way.tags?.service)) continue
|
|
10
|
+
const nodes = way.nodes.map(id => this.nodes.get(id))
|
|
11
|
+
if (!includeWay(way, nodes)) continue
|
|
12
|
+
if (nodes.some(n => !n)) throw new Error(`Missing OSM nodes on ${way.id}`)
|
|
13
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
14
|
+
const a = nodes[i - 1], b = nodes[i], length = this.distance([a.lon, a.lat], [b.lon, b.lat])
|
|
15
|
+
for (const [from, to] of [[a.id, b.id], [b.id, a.id]]) {
|
|
16
|
+
if (!this.graph.has(from)) this.graph.set(from, [])
|
|
17
|
+
this.graph.get(from).push([to, length, way.id])
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
for (const id of this.graph.keys()) {
|
|
22
|
+
const n = this.nodes.get(id), [x, y] = project([n.lon, n.lat]), key = `${Math.floor(x / .25)}:${Math.floor(y / .25)}`
|
|
23
|
+
if (!this.grid.has(key)) this.grid.set(key, [])
|
|
24
|
+
this.grid.get(key).push(id)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
distance(a, b) { if (this.measure) return this.measure(a, b); const x = this.project(a), y = this.project(b); return Math.hypot(x[0] - y[0], x[1] - y[1]) }
|
|
28
|
+
stationCandidates(code) {
|
|
29
|
+
if (this.candidates.has(code)) return this.candidates.get(code)
|
|
30
|
+
const station = this.stations[code]
|
|
31
|
+
if (!station) throw new Error(`Unknown station ${code}`)
|
|
32
|
+
const point = [station.lon, station.lat], [x, y] = this.project(point), gx = Math.floor(x / .25), gy = Math.floor(y / .25), found = [], radius = this.anchorRadius(code)
|
|
33
|
+
if (!(radius > 0 && radius <= .5)) throw new Error('Anchor radius must be within 0–0.5 km')
|
|
34
|
+
for (let dx = -2; dx <= 2; dx++) for (let dy = -2; dy <= 2; dy++) for (const id of this.grid.get(`${gx + dx}:${gy + dy}`) ?? []) {
|
|
35
|
+
const n = this.nodes.get(id), length = this.distance(point, [n.lon, n.lat])
|
|
36
|
+
if (length <= radius) found.push([id, length])
|
|
37
|
+
}
|
|
38
|
+
found.sort((a, b) => a[1] - b[1]); this.candidates.set(code, found.slice(0, 100)); return this.candidates.get(code)
|
|
39
|
+
}
|
|
40
|
+
route(a, b) {
|
|
41
|
+
const key = `${a}:${b}`, reverse = this.cache.get(`${b}:${a}`)
|
|
42
|
+
if (this.cache.has(key)) return this.cache.get(key)
|
|
43
|
+
if (reverse) return [[...reverse[0]].reverse(), reverse[1], reverse[2]]
|
|
44
|
+
const na = this.stations[a], nb = this.stations[b]
|
|
45
|
+
if (!na || !nb) throw new Error(`Unknown route station ${!na ? a : b}`)
|
|
46
|
+
const pa = [na.lon, na.lat], pb = [nb.lon, nb.lat], starts = this.stationCandidates(a), targets = new Map(this.stationCandidates(b))
|
|
47
|
+
if (!starts.length || !targets.size) throw new Error(`Station lacks a connected railway anchor: ${!starts.length ? a : b}`)
|
|
48
|
+
const queue = new MinHeap(), best = new Map(), previous = new Map()
|
|
49
|
+
const heuristic = id => { const n = this.nodes.get(id); return this.distance([n.lon, n.lat], pb) }
|
|
50
|
+
for (const [id, length] of starts) { best.set(id, length); queue.push([length + heuristic(id), length, id]) }
|
|
51
|
+
let finish, total = Infinity
|
|
52
|
+
while (queue.length) {
|
|
53
|
+
const [estimate, cost, id] = queue.pop()
|
|
54
|
+
if (estimate >= total) break
|
|
55
|
+
if (cost !== best.get(id)) continue
|
|
56
|
+
if (targets.has(id) && cost + targets.get(id) < total) { finish = id; total = cost + targets.get(id) }
|
|
57
|
+
for (const [target, length, way] of this.graph.get(id)) {
|
|
58
|
+
const candidate = cost + length
|
|
59
|
+
if (candidate >= (best.get(target) ?? Infinity)) continue
|
|
60
|
+
best.set(target, candidate); previous.set(target, [id, way]); queue.push([candidate + heuristic(target), candidate, target])
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (finish === undefined) throw new Error(`Disconnected rail path ${a}–${b}`)
|
|
64
|
+
const nodes = [finish], ways = new Set()
|
|
65
|
+
while (previous.has(nodes.at(-1))) { const [id, way] = previous.get(nodes.at(-1)); nodes.push(id); ways.add(way) }
|
|
66
|
+
const path = [pa, ...nodes.reverse().map(id => { const n = this.nodes.get(id); return [n.lon, n.lat] }), pb].filter((p, i, all) => !i || p[0] !== all[i - 1][0] || p[1] !== all[i - 1][1])
|
|
67
|
+
if (total > Math.max(3, this.distance(pa, pb) * 2.6)) throw new Error(`Rail detour ${a}–${b}: ${total.toFixed(2)} km vs ${this.distance(pa, pb).toFixed(2)} km direct`)
|
|
68
|
+
const result = [path, [...ways].sort((a, b) => a - b), total]; this.cache.set(key, result); return result
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Numeric lexicographic ordering preserves deterministic A* ties.
|
|
73
|
+
class MinHeap {
|
|
74
|
+
items = []
|
|
75
|
+
get length() { return this.items.length }
|
|
76
|
+
less(a, b) { return (a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) < 0 }
|
|
77
|
+
push(value) { const a = this.items; let i = a.length; a.push(value); while (i) { const p = (i - 1) >> 1; if (!this.less(value, a[p])) break; a[i] = a[p]; i = p } a[i] = value }
|
|
78
|
+
pop() { const a = this.items, value = a[0], last = a.pop(); if (a.length) { let i = 0; while (i * 2 + 1 < a.length) { let c = i * 2 + 1; if (c + 1 < a.length && this.less(a[c + 1], a[c])) c++; if (!this.less(a[c], last)) break; a[i] = a[c]; i = c } a[i] = last } return value }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function railBoundaryDistance(point, rings, project) {
|
|
82
|
+
let inside = false, nearest = Infinity
|
|
83
|
+
const [px, py] = project(point)
|
|
84
|
+
for (const ring of rings) for (let i = 0; i < ring.length; i++) {
|
|
85
|
+
const a = ring[i], b = ring[(i + 1) % ring.length]
|
|
86
|
+
if ((a[1] > point[1]) !== (b[1] > point[1]) && point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside
|
|
87
|
+
const [ax, ay] = project(a), [bx, by] = project(b), dx = bx - ax, dy = by - ay, length = dx * dx + dy * dy
|
|
88
|
+
const t = length ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length)) : 0
|
|
89
|
+
nearest = Math.min(nearest, Math.hypot(px - ax - t * dx, py - ay - t * dy))
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(nearest)) throw new Error('Empty railway study boundary')
|
|
92
|
+
return inside ? -nearest : nearest
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function routeRailJourneys(result, osm, { boundary, routeMargin = 5, visibleMargin = 4, ...options }) {
|
|
96
|
+
if (result.conflicts.length) throw new Error('Resolve timetable conflicts before compiling geometry')
|
|
97
|
+
if (!['Polygon', 'MultiPolygon'].includes(boundary.type)) throw new Error('Expected polygon boundary')
|
|
98
|
+
if (!(routeMargin >= visibleMargin && visibleMargin >= 0)) throw new Error('Invalid railway boundary margins')
|
|
99
|
+
const rings = boundary.type === 'Polygon' ? boundary.coordinates : boundary.coordinates.flat()
|
|
100
|
+
const graph = new RailwayGraph(osm, result.stations, options)
|
|
101
|
+
const distances = Object.fromEntries(Object.entries(result.stations).map(([code, n]) => [code, railBoundaryDistance([n.lon, n.lat], rings, options.project)]))
|
|
102
|
+
const pairs = new Map()
|
|
103
|
+
for (const train of result.journeys) for (let i = 1; i < train.points.length; i++) { const a = train.points[i - 1].code, b = train.points[i].code; if (a !== b) pairs.set(`${a}:${b}`, [a, b]) }
|
|
104
|
+
const failed = {}, geometry = {}
|
|
105
|
+
for (const [key, [a, b]] of [...pairs].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
106
|
+
if (distances[a] > routeMargin && distances[b] > routeMargin) continue
|
|
107
|
+
try { const [path, ways, length] = graph.route(a, b); geometry[key] = { path, ways, length } } catch (error) { failed[key] = error.message }
|
|
108
|
+
}
|
|
109
|
+
const included = [], excluded = []
|
|
110
|
+
for (const train of result.journeys) {
|
|
111
|
+
const visible = train.points.flatMap((p, i) => distances[p.code] <= visibleMargin ? [i] : [])
|
|
112
|
+
if (!visible.length) continue
|
|
113
|
+
const points = train.points.slice(Math.max(0, visible[0] - 1), visible.at(-1) + 2)
|
|
114
|
+
const missing = points.slice(1).map((p, i) => `${points[i].code}:${p.code}`).filter(key => !geometry[key])
|
|
115
|
+
if (missing.length) excluded.push({ uid: train.uid, originDate: train.originDate, operator: train.operator, missing })
|
|
116
|
+
else included.push({ ...train, points })
|
|
117
|
+
}
|
|
118
|
+
return { ...result, journeys: included, geometry, stationBoundaryDistances: distances, geometryFailures: failed, excludedGeometry: excluded }
|
|
119
|
+
}
|
package/rail-wtt.d.mts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type WttGrid = Record<string, string[][]>
|
|
2
|
+
export interface WttEvent { location: string; kind: 'arr' | 'dep' | 'pass'; sourceTime: string; wallSecondsFromBankMidnight: number; symbols: string; row: number }
|
|
3
|
+
export interface WttIdentity { table: string; sheet: string; column: number; uid: string }
|
|
4
|
+
export interface WttColumn extends WttIdentity { tid: string; operator: string; origin: string; destination: string; datesOfOperation: string; runningDays: string; serviceCode: string; events: WttEvent[] }
|
|
5
|
+
export interface WttRejection extends WttIdentity { reason: string }
|
|
6
|
+
export function listRailArchive(archive: string): Promise<string[]>
|
|
7
|
+
export function readRailArchiveEntry(archive: string, member: string, maxBytes?: number): Promise<Uint8Array>
|
|
8
|
+
export function readWtt(input: string | Uint8Array, options?: { weekends?: boolean; maxWorkbookBytes?: number; maxExpandedBytes?: number; maxCells?: number }): Promise<WttGrid>
|
|
9
|
+
export function wttTiming(value: string): [number, string]
|
|
10
|
+
export function wttRunsOn(code: string, dateRange: string, date: string): boolean
|
|
11
|
+
export function wttTerminal(value: string): [string, number]
|
|
12
|
+
export function auditWttGrid(sheets: WttGrid, table: string, serviceDate: string, options?: { includeColumn?: (column: WttColumn) => boolean }): { columns: WttColumn[]; rejected: WttRejection[] }
|
package/rail-wtt.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {execFile} from 'node:child_process'
|
|
2
|
+
import {promisify} from 'node:util'
|
|
3
|
+
import {mkdtemp,writeFile,rm,stat} from 'node:fs/promises'
|
|
4
|
+
import {tmpdir} from 'node:os'
|
|
5
|
+
import {join,posix} from 'node:path'
|
|
6
|
+
import {parseRailXml,xmlChildren,xmlContent,xmlText,xmlAttribute} from './rail-xml.mjs'
|
|
7
|
+
import {checkedDate} from './uk-service-day.mjs'
|
|
8
|
+
const execute=promisify(execFile)
|
|
9
|
+
const spreadsheetNamespace='http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
|
10
|
+
function rootContent(nodes,name,namespace=spreadsheetNamespace){
|
|
11
|
+
const roots=xmlChildren(nodes,name)
|
|
12
|
+
if(roots.length!==1)throw new Error(`Missing or ambiguous workbook root: ${name}`)
|
|
13
|
+
const node=roots[0],key=Object.keys(node).find(k=>k!==':@'),prefix=key.includes(':')?key.split(':')[0]:null
|
|
14
|
+
if(node[':@']?.[prefix?'@_xmlns:'+prefix:'@_xmlns']!==namespace)throw new Error(`Unsupported workbook namespace: ${name}`)
|
|
15
|
+
return xmlContent(node)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function listRailArchive(archive){
|
|
19
|
+
const {stdout}=await execute('unzip',['-Z1',archive],{maxBuffer:8*1024*1024,timeout:30000})
|
|
20
|
+
const entries=stdout.split('\n').filter(Boolean)
|
|
21
|
+
if(new Set(entries).size!==entries.length)throw new Error('Duplicate archive member names')
|
|
22
|
+
return entries
|
|
23
|
+
}
|
|
24
|
+
export async function readRailArchiveEntry(archive,member,maxBytes=32*1024*1024){
|
|
25
|
+
if(!member||/[?*[\r\n]/.test(member)||!Number.isSafeInteger(maxBytes)||maxBytes<1)throw new Error('Invalid archive member or byte limit')
|
|
26
|
+
const {stdout}=await execute('unzip',['-p',archive,member],{encoding:'buffer',maxBuffer:maxBytes,timeout:30000})
|
|
27
|
+
return stdout
|
|
28
|
+
}
|
|
29
|
+
/** XLSX source cells only: formula results are retained; formulas are never evaluated. */
|
|
30
|
+
export async function readWtt(input,{weekends=false,maxWorkbookBytes=32*1024*1024,maxExpandedBytes=128*1024*1024,maxCells=8_000_000}={}){
|
|
31
|
+
for(const value of [maxWorkbookBytes,maxExpandedBytes,maxCells])if(!Number.isSafeInteger(value)||value<1)throw new Error('Invalid workbook resource limit')
|
|
32
|
+
let archive=input,temporary
|
|
33
|
+
if(typeof input!=='string'){
|
|
34
|
+
if(!(input instanceof Uint8Array)||input.length>maxWorkbookBytes)throw new Error('Workbook exceeds limit')
|
|
35
|
+
temporary=await mkdtemp(join(tmpdir(),'motion-wtt-'));archive=join(temporary,'workbook.xlsx');await writeFile(archive,input,{mode:0o600})
|
|
36
|
+
}
|
|
37
|
+
try{
|
|
38
|
+
if((await stat(archive)).size>maxWorkbookBytes)throw new Error('Workbook exceeds limit')
|
|
39
|
+
const entries=new Set(await listRailArchive(archive));let expanded=0,allocated=0
|
|
40
|
+
const read=async name=>{
|
|
41
|
+
if(!entries.has(name))throw new Error(`Missing workbook member: ${name}`)
|
|
42
|
+
const bytes=await readRailArchiveEntry(archive,name,maxExpandedBytes-expanded)
|
|
43
|
+
expanded+=bytes.length;return parseRailXml(bytes)
|
|
44
|
+
}
|
|
45
|
+
const strings=entries.has('xl/sharedStrings.xml')?xmlChildren(rootContent(await read('xl/sharedStrings.xml'),'sst'),'si').map(n=>xmlText(xmlContent(n))):[]
|
|
46
|
+
const links=xmlChildren(rootContent(await read('xl/_rels/workbook.xml.rels'),'Relationships','http://schemas.openxmlformats.org/package/2006/relationships'),'Relationship')
|
|
47
|
+
const targets=new Map()
|
|
48
|
+
for(const link of links){const id=xmlAttribute(link,'Id');if(targets.has(id))throw new Error('Duplicate workbook relationship');targets.set(id,link)}
|
|
49
|
+
const book=rootContent(await read('xl/workbook.xml'),'workbook'),sheetsNode=xmlChildren(book,'sheets')[0]
|
|
50
|
+
const result={}
|
|
51
|
+
for(const sheet of xmlChildren(xmlContent(sheetsNode),'sheet')){
|
|
52
|
+
const name=xmlAttribute(sheet,'name')
|
|
53
|
+
if(!name.startsWith('Mondays to Fridays')&&!(weekends&&name.startsWith('Saturdays')))continue
|
|
54
|
+
if(Object.hasOwn(result,name))throw new Error('Duplicate workbook sheet')
|
|
55
|
+
const link=targets.get(xmlAttribute(sheet,'id'));if(!link||xmlAttribute(link,'TargetMode')==='External')throw new Error('Invalid workbook relationship')
|
|
56
|
+
const target=xmlAttribute(link,'Target'),member=posix.normalize(target.startsWith('/')?target.slice(1):'xl/'+target)
|
|
57
|
+
if(!member.startsWith('xl/'))throw new Error('Worksheet escapes workbook')
|
|
58
|
+
const worksheet=rootContent(await read(member),'worksheet'),data=xmlChildren(worksheet,'sheetData')[0],rows=[]
|
|
59
|
+
const rowIds=new Set()
|
|
60
|
+
for(const row of xmlChildren(xmlContent(data),'row')){
|
|
61
|
+
const index=Number(xmlAttribute(row,'r'))-1
|
|
62
|
+
if(!Number.isInteger(index)||index<0||index>=10000||rowIds.has(index))throw new Error('Invalid or duplicate worksheet row')
|
|
63
|
+
rowIds.add(index);while(rows.length<=index)rows.push([])
|
|
64
|
+
const cols=new Set()
|
|
65
|
+
for(const cell of xmlChildren(xmlContent(row),'c')){
|
|
66
|
+
const address=/^([A-Z]+)(\d+)$/.exec(xmlAttribute(cell,'r')??'')
|
|
67
|
+
if(!address||Number(address[2])!==index+1)throw new Error('Invalid cell address')
|
|
68
|
+
let col=0;for(const letter of address[1])col=col*26+letter.charCodeAt(0)-64
|
|
69
|
+
if(col>16384||cols.has(col))throw new Error('Invalid or duplicate cell column');cols.add(col)
|
|
70
|
+
allocated+=Math.max(0,col-rows[index].length);if(allocated>maxCells)throw new Error('Worksheet cell budget exceeded')
|
|
71
|
+
while(rows[index].length<col)rows[index].push('')
|
|
72
|
+
const content=xmlContent(cell),value=xmlChildren(content,'v')[0],type=xmlAttribute(cell,'t')
|
|
73
|
+
let text=value?xmlText(xmlContent(value)):''
|
|
74
|
+
if(type==='s'){const i=Number(text);if(!/^\d+$/.test(text)||i>=strings.length)throw new Error('Invalid shared string reference');text=strings[i]}
|
|
75
|
+
else if(type==='inlineStr'){const inline=xmlChildren(content,'is')[0];text=inline?xmlText(xmlContent(inline)):''}
|
|
76
|
+
rows[index][col-1]=text
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
result[name]=rows
|
|
80
|
+
}
|
|
81
|
+
return result
|
|
82
|
+
}finally{if(temporary)await rm(temporary,{recursive:true,force:true})}
|
|
83
|
+
}
|
|
84
|
+
const SYMBOLS=new Set(['','/','T','TF','TB','U','D','A','T X','OP','S','T -D','T -U','T -DK','TBX','T -UK','T RM','T K -D','TBK','T K -U','TBN','U -D','D -U','TBS','TFN','TFS','T A','OPC','TBK KE','C A','C','X OP','OPX','RMN','RMD','RMU','D RM'])
|
|
85
|
+
export function wttTiming(value){
|
|
86
|
+
const m=/^(\d{2})([A-Z/ -]*)(\d{2})(½?)$/.exec(value)
|
|
87
|
+
if(!m||!SYMBOLS.has(m[2])||Number(m[1])>23||Number(m[3])>59)throw new Error(`Unreviewed WTT time: ${value}`)
|
|
88
|
+
return [Number(m[1])*3600+Number(m[3])*60+(m[4]?30:0),m[2]]
|
|
89
|
+
}
|
|
90
|
+
export function wttRunsOn(code,dateRange,date){
|
|
91
|
+
checkedDate(date)
|
|
92
|
+
const parts=/^(\d{2})\/(\d{2})\/(\d{4}) to (\d{2})\/(\d{2})\/(\d{4})$/.exec(dateRange)
|
|
93
|
+
if(!parts)throw new Error(`Unreviewed date range: ${dateRange}`)
|
|
94
|
+
const start=checkedDate(`${parts[3]}-${parts[2]}-${parts[1]}`),end=checkedDate(`${parts[6]}-${parts[5]}-${parts[4]}`)
|
|
95
|
+
if(start>end)throw new Error('Reversed WTT date range')
|
|
96
|
+
if(date<start||date>end)return false
|
|
97
|
+
const m=/^((?:Th|Sun|[MTWFS])+)([OX])$/.exec(code)
|
|
98
|
+
if(!m)throw new Error(`Unreviewed running days: ${code}`)
|
|
99
|
+
const day=['Sun','M','T','W','Th','F','S'][new Date(date+'T12:00:00Z').getUTCDay()]
|
|
100
|
+
return m[1].match(/Th|Sun|[MTWFS]/g).includes(day)===(m[2]==='O')
|
|
101
|
+
}
|
|
102
|
+
export function wttTerminal(value){const i=value.lastIndexOf('\n');if(i<0)throw new Error('Missing terminal time');return [value.slice(0,i),wttTiming(value.slice(i+1).replaceAll(':',''))[0]]}
|
|
103
|
+
|
|
104
|
+
/** Audit source columns without assigning stations or joining different table banks. */
|
|
105
|
+
export function auditWttGrid(sheets,table,serviceDate,{includeColumn=()=>true}={}){
|
|
106
|
+
checkedDate(serviceDate)
|
|
107
|
+
const day=new Date(serviceDate+'T12:00:00Z').getUTCDay()
|
|
108
|
+
if(day===0)throw new Error('Sunday is not supported by the reviewed reader; no partial Sunday audit is produced')
|
|
109
|
+
const prefix=day===6?'Saturdays':'Mondays to Fridays',selected=Object.entries(sheets).filter(([name])=>name.startsWith(prefix))
|
|
110
|
+
if(!selected.length)throw new Error(`No ${prefix} bank in ${table}`)
|
|
111
|
+
const columns=[],rejected=[],expected=['TID','UID','Operator','Origin','Destination','Timing Load','Dates of Operation','Running Days','Service Code']
|
|
112
|
+
for(const [sheet,rows] of selected){
|
|
113
|
+
if(JSON.stringify(rows.slice(0,9).map(r=>r[1]??''))!==JSON.stringify(expected))throw new Error(`Unrecognized WTT headers: ${table}/${sheet}`)
|
|
114
|
+
let location='';const locations=[]
|
|
115
|
+
for(let i=9;i<rows.length;i++){const row=rows[i];if(row[0])location=row[0];if(['arr','dep','pass'].includes(row[1]))locations.push([i+1,location,row[1],row])}
|
|
116
|
+
for(let c=2;c<rows[0].length;c++){
|
|
117
|
+
const cell=r=>rows[r][c]??'',identity={table,sheet,column:c+1,uid:cell(1)}
|
|
118
|
+
try{
|
|
119
|
+
if(!wttRunsOn(cell(7),cell(6),serviceDate))continue
|
|
120
|
+
if(!cell(1))throw new Error('Missing UID')
|
|
121
|
+
const events=[];let previous=null,rollover=0
|
|
122
|
+
for(const [row,location,kind,values] of locations){
|
|
123
|
+
const sourceTime=values[c]??'';if(sourceTime===''||sourceTime==='..')continue
|
|
124
|
+
const [seconds,symbols]=wttTiming(sourceTime);let local=seconds+rollover*86400
|
|
125
|
+
if(previous!==null&&local<previous-43200){rollover++;local+=86400}
|
|
126
|
+
if(previous!==null&&local<previous)throw new Error('Non-monotonic timing sequence')
|
|
127
|
+
previous=local;events.push({location,kind,sourceTime,wallSecondsFromBankMidnight:local,symbols,row})
|
|
128
|
+
}
|
|
129
|
+
const column={...identity,tid:cell(0),operator:cell(2),origin:cell(3),destination:cell(4),datesOfOperation:cell(6),runningDays:cell(7),serviceCode:cell(8),events}
|
|
130
|
+
if(includeColumn(column))columns.push(column)
|
|
131
|
+
}catch(error){rejected.push({...identity,reason:error.message})}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {columns,rejected}
|
|
135
|
+
}
|
package/rail-xml.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {XMLParser,XMLValidator} from 'fast-xml-parser'
|
|
2
|
+
const parser=new XMLParser({preserveOrder:true,ignoreAttributes:false,parseTagValue:false,parseAttributeValue:false,trimValues:false,ignoreDeclaration:true,ignorePiTags:true})
|
|
3
|
+
export function parseRailXml(bytes){
|
|
4
|
+
const xml=Buffer.from(bytes).toString('utf8')
|
|
5
|
+
if(/<!DOCTYPE|<!ENTITY/i.test(xml)||XMLValidator.validate(xml)!==true)throw new Error('Unsupported or invalid source XML')
|
|
6
|
+
return parser.parse(xml)
|
|
7
|
+
}
|
|
8
|
+
const local=name=>name.split(':').at(-1)
|
|
9
|
+
export function xmlChildren(nodes,name){return (nodes??[]).filter(n=>Object.keys(n).some(k=>k!==':@'&&local(k)===name))}
|
|
10
|
+
export function xmlContent(node){const key=Object.keys(node).find(k=>k!==':@');return node[key]}
|
|
11
|
+
export function xmlText(nodes){return (nodes??[]).map(n=>Object.entries(n).filter(([k])=>k!==':@').map(([k,v])=>k==='#text'?String(v):Array.isArray(v)?xmlText(v):'').join('')).join('')}
|
|
12
|
+
export function xmlAttribute(node,name){const attributes=node[':@']??{};const key=Object.keys(attributes).find(k=>local(k.replace(/^@_/,''))===name);return key===undefined?undefined:attributes[key]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FeedHealth } from './feed-observability.mjs'
|
|
2
|
+
export interface RecorderEvidencePlan {
|
|
3
|
+
schemaVersion: 1
|
|
4
|
+
kind: 'recorder-evidence-plan'
|
|
5
|
+
producerId: string
|
|
6
|
+
validUntil: string
|
|
7
|
+
feeds: {
|
|
8
|
+
feedId: string
|
|
9
|
+
archive?: { journalPath: string; closeoutPath: string; expectedEnd: string; deadlineAt: string }
|
|
10
|
+
publication?: { pointerPath: string; operator: string; expectedThrough: string; deadlineAt: string }
|
|
11
|
+
}[]
|
|
12
|
+
}
|
|
13
|
+
/** Reads explicitly selected local files, with integrity checks, 256 MiB per journal and a 512 MiB total read budget. */
|
|
14
|
+
export function recorderEvidenceHealth(registry: unknown, report: unknown, plan: unknown): Promise<FeedHealth>
|