@motionstudies/data 0.1.0-alpha.10 → 0.1.0-alpha.12
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 +24 -0
- package/package.json +14 -2
- package/source-store.d.mts +13 -0
- package/source-store.mjs +127 -0
- package/uk-service-day.d.mts +6 -0
- package/uk-service-day.mjs +49 -0
- package/webtris.d.mts +14 -0
- package/webtris.mjs +67 -0
package/README.md
CHANGED
|
@@ -164,6 +164,28 @@ Rail and other transport consumers can share the same time filtering through `mo
|
|
|
164
164
|
|
|
165
165
|
`@motionstudies/data/air-endpoints` provides offline `enrichAirEndpoints` for existing air manifests, chunks and opening snapshots. Supply cached same-date global ADSB.lol heatmaps, an OurAirports CSV and the service date's local UTC offset. It associates only unambiguous low-altitude endpoints near a reference airport; cruise-only traces and uncertain routes stay unknown. Optional `AirEndpoint` origin/destination fields carry airport identity, observed boundary time and `observed-endpoint` evidence. `airportBoardMovements` maps full manifest entries to board rows without confusing playback chunk boundaries with flight endpoints. Input hashes and source/licence attribution are recorded in fixture metadata. These fields describe inferred observations, never flight schedules, gates or live status.
|
|
166
166
|
|
|
167
|
+
## Vehicle hero cards
|
|
168
|
+
|
|
169
|
+
`VehicleHeroCard` shows one vehicle's destination, next stop and remaining calling points. Import it from `@motionstudies/web/components/VehicleHeroCard` with `@motionstudies/web/vehicle-hero-card.css`. The **Vehicle heroes** lab compares `uk-bus` (amber onboard display), `uk-rail` (dark rail display), `yellow-bus` (yellow next-stop panel) and `sbb` (blue next-stop panel with a red service badge). These are presentation studies, not official operator components.
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
<VehicleHeroCard
|
|
173
|
+
presentation="sbb"
|
|
174
|
+
vehicle={{ service: 'IC 1', operator: 'SBB CFF FFS', destination: 'Genève-Aéroport' }}
|
|
175
|
+
stops={[
|
|
176
|
+
{ id: 'bern', name: 'Bern', time: '10:28', platform: '6' },
|
|
177
|
+
{ id: 'fribourg', name: 'Fribourg/Freiburg', time: '10:56' },
|
|
178
|
+
]}
|
|
179
|
+
note="Synthetic journey · Example calls and times."
|
|
180
|
+
onSelectStop={selectStation}
|
|
181
|
+
selectedStopId={selectedStationId}
|
|
182
|
+
/>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Consumers supply remaining `stops` in journey order and update them with playback, seeking or observations; the card has no independent clock. The first supplied call is the next stop. Optional `time`, `platform` and `detail` are consumer-formatted; absent values stay absent. `vehicle.destination` is optional and shows an explicit unavailable label when missing. The final supplied stop is never inferred to be the destination: set `isDestination` only when confirmed, especially when data is progressively chunked. Empty calls mean no upcoming stops are available, without asserting that the vehicle has arrived.
|
|
186
|
+
|
|
187
|
+
The required `note` explains provenance. Optional `status`, `statusTone`, `clockLabel`, localized `labels`, `loading`, `error` and `onRetry` follow the consumer's data; the component never manufactures an on-time status. Loading and errors hide the calling points. Stop selection is controlled through stable call IDs and does not alter playback or clear when a call leaves the list. All supplied calls are shown, with wrapping names and container-based compact layouts. The lab exercises advance/rewind, unknown destinations/times, localization, disruption, recovery and narrow widths. Edition adoption requires its own package upgrade and data adapter.
|
|
188
|
+
|
|
167
189
|
## Optional live airport feed
|
|
168
190
|
|
|
169
191
|
`AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
|
|
@@ -193,3 +215,5 @@ The renderer now shares active GPU upload ranges, paused frame reuse, label and
|
|
|
193
215
|
`useTransitionValue(target, { durationMs, easing, steps })` from `@motionstudies/web/use-transition-value` animates a numeric value, returning `value` and `transitioning`. It reverses from the current frame, cancels on teardown, and settles immediately when reduced motion becomes active. `smoothTransition` is the default easing; `cosineTransition` and stepped progress support existing edition rhythms. Keep custom easing functions stable. Camera actions and lazy layout loading stay in the edition.
|
|
194
216
|
|
|
195
217
|
Edition chunk scripts can call `runNetworkChunkCli()` from `@motionstudies/data/network-chunk-cli`. It accepts the existing `--input`, `--manifest`, `--opening`, `--chunk-hours`, `--opening-start`, `--opening-end`, and `--focus` arguments. Source acquisition, provenance, output paths and command invocation remain edition-owned.
|
|
218
|
+
|
|
219
|
+
For timetable-backed vehicle selections, `NetworkVehicleHeroCard` from `@motionstudies/web/components/NetworkVehicleHeroCard` accepts `snapshot`, `train`, playback `time`, a required source `note` and the vehicle card's presentation/state/label options. Import `vehicle-hero-card.css`. It resolves the selected train ID against the current snapshot, retaining destination identity but showing no stale calls when that ID is absent. Calls stay in journey order, use stable journey/call IDs and follow backward seeking. During dwell the heading becomes `atStopLabel` (default “At stop”) and the time is the departure; otherwise it is the upcoming arrival. Times are formatted with `formatServiceTime` by default, without normalizing after midnight or adding delay twice. Use `showTimes={false}` for frequency-modelled journeys, and explain the model in `note`. The core `vehicleCalls` helper is also exported from `@motionstudies/core/domain/vehicle-calls` for custom adapters. Selection callback IDs refer to calls, not station indexes.
|
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.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Offline data preparation primitives for Motion Studies.",
|
|
@@ -50,6 +50,18 @@
|
|
|
50
50
|
"./network-chunk-cli": {
|
|
51
51
|
"types": "./network-chunk-cli.d.mts",
|
|
52
52
|
"import": "./network-chunk-cli.mjs"
|
|
53
|
+
},
|
|
54
|
+
"./source-store": {
|
|
55
|
+
"types": "./source-store.d.mts",
|
|
56
|
+
"import": "./source-store.mjs"
|
|
57
|
+
},
|
|
58
|
+
"./uk-service-day": {
|
|
59
|
+
"types": "./uk-service-day.d.mts",
|
|
60
|
+
"import": "./uk-service-day.mjs"
|
|
61
|
+
},
|
|
62
|
+
"./webtris": {
|
|
63
|
+
"types": "./webtris.d.mts",
|
|
64
|
+
"import": "./webtris.mjs"
|
|
53
65
|
}
|
|
54
66
|
},
|
|
55
67
|
"files": [
|
|
@@ -63,6 +75,6 @@
|
|
|
63
75
|
],
|
|
64
76
|
"sideEffects": false,
|
|
65
77
|
"dependencies": {
|
|
66
|
-
"@motionstudies/core": "0.1.0-alpha.
|
|
78
|
+
"@motionstudies/core": "0.1.0-alpha.12"
|
|
67
79
|
}
|
|
68
80
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {Buffer} from 'node:buffer'
|
|
2
|
+
export interface CaptureSource {id:string;publisher:string;kind:string;documentation:string;license:string;attribution?:string;origins:readonly string[]}
|
|
3
|
+
export interface CaptureRecord {schemaVersion:1;source:Omit<CaptureSource,'origins'>;url:string;sha256:string;bytes:number;retrievedAt:string|null;importedAt?:string;acquisition:'http'|'local-import';validation:string;httpStatus?:number;contentType?:string|null;lastModified?:string|null;etag?:string|null}
|
|
4
|
+
export interface CaptureResult {bytes:Buffer;record:CaptureRecord;cached:boolean}
|
|
5
|
+
export type CaptureValidator=(bytes:Buffer)=>unknown|Promise<unknown>
|
|
6
|
+
export interface CaptureOptions {store:string;source:CaptureSource;url:string;offline?:boolean;refresh?:boolean;maxBytes?:number;timeoutMs?:number;retries?:number;validate?:CaptureValidator;fetchImpl?:typeof fetch;userAgent?:string}
|
|
7
|
+
export function sha256(bytes:string|Uint8Array):string
|
|
8
|
+
export function publicSourceUrl(source:CaptureSource,value:string):string
|
|
9
|
+
export function atomicJson(path:string,value:unknown):Promise<void>
|
|
10
|
+
export function readCapture(store:string,source:CaptureSource,url:string,validate?:CaptureValidator):Promise<CaptureResult>
|
|
11
|
+
export function importCapture(options:Pick<CaptureOptions,'store'|'source'|'url'|'maxBytes'|'validate'>&{input:string}):Promise<CaptureResult>
|
|
12
|
+
export function capture(options:CaptureOptions):Promise<CaptureResult>
|
|
13
|
+
export function jsonDocument(bytes:Buffer):Record<string,unknown>|unknown[]
|
package/source-store.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, writeFile, rename, rm, stat } from 'node:fs/promises'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
|
+
|
|
6
|
+
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
7
|
+
|
|
8
|
+
export function publicSourceUrl(source, value) {
|
|
9
|
+
const url = new URL(value)
|
|
10
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash ||
|
|
11
|
+
[...url.searchParams.keys()].some(key => /key|token|password|secret|signature|credential/i.test(key))) {
|
|
12
|
+
throw new Error('Source URLs must be public HTTPS URLs without credentials or fragments')
|
|
13
|
+
}
|
|
14
|
+
if (!source.origins.includes(url.origin)) throw new Error(`URL origin is not configured for ${source.id}`)
|
|
15
|
+
return url.href
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function atomicJson(path, value) {
|
|
19
|
+
await atomicWrite(path, Buffer.from(`${JSON.stringify(value, null, 2)}\n`))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function atomicWrite(path, bytes) {
|
|
23
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
24
|
+
const temporary = `${path}.${randomUUID()}.partial`
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(temporary, bytes, { mode: 0o600 })
|
|
27
|
+
await rename(temporary, path)
|
|
28
|
+
} finally {
|
|
29
|
+
await rm(temporary, { force: true })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requestKey(source, url) { return sha256(`${source.id}\n${url}`) }
|
|
34
|
+
|
|
35
|
+
export async function readCapture(store, source, url, validate = () => {}) {
|
|
36
|
+
const record = JSON.parse(await readFile(join(store, 'requests', `${requestKey(source, url)}.json`), 'utf8'))
|
|
37
|
+
if (record.source.id !== source.id || record.url !== url || !/^[a-f0-9]{64}$/.test(record.sha256)) throw new Error('Capture identity mismatch')
|
|
38
|
+
const bytes = await readFile(join(store, 'objects', record.sha256))
|
|
39
|
+
if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) throw new Error('Capture integrity check failed')
|
|
40
|
+
await validate(bytes)
|
|
41
|
+
return { bytes, record, cached: true }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function persist(store, source, url, bytes, acquisition, response = {}) {
|
|
45
|
+
const hash = sha256(bytes)
|
|
46
|
+
const record = {
|
|
47
|
+
schemaVersion: 1, source: { id: source.id, publisher: source.publisher, kind: source.kind, documentation: source.documentation, license: source.license, ...(source.attribution ? { attribution: source.attribution } : {}) },
|
|
48
|
+
url, sha256: hash, bytes: bytes.length,
|
|
49
|
+
...(acquisition === 'http' ? { retrievedAt: new Date().toISOString() } : { retrievedAt: null, importedAt: new Date().toISOString() }),
|
|
50
|
+
acquisition, validation: 'raw-capture; see adapter audit for fitness', ...response,
|
|
51
|
+
}
|
|
52
|
+
await atomicWrite(join(store, 'objects', hash), bytes)
|
|
53
|
+
await atomicJson(join(store, 'records', `${randomUUID()}.json`), record)
|
|
54
|
+
await atomicJson(join(store, 'requests', `${requestKey(source, url)}.json`), record)
|
|
55
|
+
return { bytes, record, cached: false }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function importCapture({ store, source, url, input, maxBytes = 512 * 1024 * 1024, validate = () => {} }) {
|
|
59
|
+
url = publicSourceUrl(source, url)
|
|
60
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Expected a positive byte limit')
|
|
61
|
+
if ((await stat(input)).size > maxBytes) throw new Error('Source file exceeds byte limit')
|
|
62
|
+
const bytes = await readFile(input)
|
|
63
|
+
if (!bytes.length || bytes.length > maxBytes) throw new Error('Source file is empty or exceeds byte limit')
|
|
64
|
+
await validate(bytes)
|
|
65
|
+
return persist(store, source, url, bytes, 'local-import')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function capture({ store, source, url, offline = false, refresh = false, maxBytes = 32 * 1024 * 1024, timeoutMs = 20000, retries = 2, validate = () => {}, fetchImpl = fetch, userAgent = 'Motion Studies source capture' }) {
|
|
69
|
+
url = publicSourceUrl(source, url)
|
|
70
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error('Invalid capture limits')
|
|
71
|
+
if (offline && refresh) throw new Error('Offline and refresh cannot be combined')
|
|
72
|
+
if (!refresh) {
|
|
73
|
+
try { return await readCapture(store, source, url, validate) }
|
|
74
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
75
|
+
}
|
|
76
|
+
if (offline) throw new Error(`No cached capture for ${source.id}; offline mode never downloads`)
|
|
77
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
78
|
+
let retryable = true
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'User-Agent': userAgent } })
|
|
81
|
+
if (response.status === 204) {
|
|
82
|
+
retryable = false
|
|
83
|
+
throw new Error('Source returned HTTP 204: no report content; this does not mean zero traffic')
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
retryable = response.status === 429 || response.status >= 500
|
|
87
|
+
await response.body?.cancel()
|
|
88
|
+
throw new Error(`Source returned HTTP ${response.status}`)
|
|
89
|
+
}
|
|
90
|
+
if (Number(response.headers.get('content-length')) > maxBytes) {
|
|
91
|
+
retryable = false
|
|
92
|
+
await response.body?.cancel()
|
|
93
|
+
throw new Error('Source exceeds byte limit')
|
|
94
|
+
}
|
|
95
|
+
if (!response.body) throw new Error('Source response has no body')
|
|
96
|
+
const reader = response.body.getReader()
|
|
97
|
+
const chunks = []
|
|
98
|
+
let size = 0
|
|
99
|
+
try {
|
|
100
|
+
while (true) {
|
|
101
|
+
const { value, done } = await reader.read()
|
|
102
|
+
if (done) break
|
|
103
|
+
size += value.length
|
|
104
|
+
if (size > maxBytes) { retryable = false; await reader.cancel(); throw new Error('Source exceeds byte limit') }
|
|
105
|
+
chunks.push(value)
|
|
106
|
+
}
|
|
107
|
+
} finally { reader.releaseLock() }
|
|
108
|
+
const bytes = Buffer.concat(chunks)
|
|
109
|
+
retryable = false
|
|
110
|
+
if (!bytes.length) throw new Error('Source response is empty')
|
|
111
|
+
await validate(bytes)
|
|
112
|
+
return await persist(store, source, url, bytes, 'http', {
|
|
113
|
+
httpStatus: response.status, contentType: response.headers.get('content-type'),
|
|
114
|
+
lastModified: response.headers.get('last-modified'), etag: response.headers.get('etag'),
|
|
115
|
+
})
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!retryable || attempt === retries) throw error
|
|
118
|
+
await delay(250 * 2 ** attempt)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function jsonDocument(bytes) {
|
|
124
|
+
const value = JSON.parse(bytes.toString('utf8'))
|
|
125
|
+
if (!value || typeof value !== 'object') throw new Error('Expected a JSON document')
|
|
126
|
+
return value
|
|
127
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** UK civil day boundaries. GTFS service time has a separate noon-based origin. */
|
|
2
|
+
export interface UkStudyDay {serviceDate:string;timezone:'Europe/London';startUtc:string;endUtc:string;durationSeconds:number}
|
|
3
|
+
export function checkedDate(value:string):string
|
|
4
|
+
export function studyDay(serviceDate:string):UkStudyDay
|
|
5
|
+
export function utcDatesForDay(day:UkStudyDay):string[]
|
|
6
|
+
export function gtfsServiceInstant(serviceDate:string,seconds:number):string
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const TIMEZONE = 'Europe/London'
|
|
2
|
+
|
|
3
|
+
export function checkedDate(value) {
|
|
4
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
|
|
5
|
+
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value) {
|
|
6
|
+
throw new Error('An actual service date in YYYY-MM-DD form is required')
|
|
7
|
+
}
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// UK midnight is unambiguous on both DST transition days. Never infer a full day as 86400 seconds.
|
|
12
|
+
function londonMidnight(date) {
|
|
13
|
+
const nominal = Date.parse(`${date}T00:00:00Z`)
|
|
14
|
+
const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: TIMEZONE, timeZoneName: 'longOffset' })
|
|
15
|
+
let instant = nominal
|
|
16
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
17
|
+
const offset = formatter.formatToParts(instant).find(part => part.type === 'timeZoneName').value
|
|
18
|
+
const match = /^GMT([+-])(\d{2}):(\d{2})$/.exec(offset)
|
|
19
|
+
if (offset !== 'GMT' && !match) throw new Error('Unsupported timezone offset')
|
|
20
|
+
const minutes = match ? (match[1] === '+' ? 1 : -1) * (Number(match[2]) * 60 + Number(match[3])) : 0
|
|
21
|
+
instant = nominal - minutes * 60000
|
|
22
|
+
}
|
|
23
|
+
return instant
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function studyDay(serviceDate) {
|
|
27
|
+
checkedDate(serviceDate)
|
|
28
|
+
const next = new Date(Date.parse(`${serviceDate}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)
|
|
29
|
+
const start = londonMidnight(serviceDate)
|
|
30
|
+
const end = londonMidnight(next)
|
|
31
|
+
return { serviceDate, timezone: TIMEZONE, startUtc: new Date(start).toISOString(), endUtc: new Date(end).toISOString(), durationSeconds: (end - start) / 1000 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function utcDatesForDay(day) {
|
|
35
|
+
const first = Date.parse(day.startUtc.slice(0, 10))
|
|
36
|
+
const last = Date.parse(new Date(Date.parse(day.endUtc) - 1).toISOString().slice(0, 10))
|
|
37
|
+
const dates = []
|
|
38
|
+
for (let time = first; time <= last; time += 86400000) dates.push(new Date(time).toISOString().slice(0, 10))
|
|
39
|
+
return dates
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// GTFS defines service time from local noon minus twelve elapsed hours. On UK
|
|
43
|
+
// DST transition days this differs from the observed civil-day midnight.
|
|
44
|
+
export function gtfsServiceInstant(serviceDate, seconds) {
|
|
45
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0) throw new Error('GTFS seconds must be a nonnegative whole number')
|
|
46
|
+
const day = studyDay(serviceDate)
|
|
47
|
+
const noonMinusTwelveHours = Date.parse(day.endUtc) - 86400000
|
|
48
|
+
return new Date(noonMinusTwelveHours + seconds * 1000).toISOString()
|
|
49
|
+
}
|
package/webtris.d.mts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface WebtrisSite {id:string;description:string}
|
|
2
|
+
export interface WebtrisSample {interval:number;periodEnding:string;totalVolume:number|null;averageSpeedMph:number|null;lengthClassCounts:(number|null)[];classTotalMatches:boolean|null}
|
|
3
|
+
export interface WebtrisReport<S extends WebtrisSite=WebtrisSite> {
|
|
4
|
+
schemaVersion:1;kind:'observed-detector-report-audit';serviceDate:string;
|
|
5
|
+
clock:{basis:string;timezone:null;utcMapping:string;nominalIntervals:96;completeness:string};
|
|
6
|
+
lengthClasses:string[];representation:string;
|
|
7
|
+
audit:{inputRows:number;foreignSite:number;foreignDate:number;invalidInterval:number;exactDuplicates:number;conflictingIntervals:number};
|
|
8
|
+
observations:{site:S;samples:WebtrisSample[];reportIntervals:number;validVolumeIntervals:number;validSpeedIntervals:number;missingNominalIntervals:number[];conflictingIntervals:number[];classTotalMismatches:number}[];
|
|
9
|
+
}
|
|
10
|
+
export function reportUrl(siteIds:readonly string[],serviceDate:string,page:number,pageSize?:number):string
|
|
11
|
+
/** Source slots and labels are retained. No UTC placement, flow-rate conversion or class inference. */
|
|
12
|
+
export function normalizeReports<S extends WebtrisSite>(rows:readonly Record<string,unknown>[],sites:readonly S[],serviceDate:string):WebtrisReport<S>
|
|
13
|
+
/** Pass stable source-record references (for example capture SHA-256 identities). */
|
|
14
|
+
export function aggregateRoadSeries(observation:WebtrisReport['observations'][number],serviceDate:string,dataset:string,sourceRecordIds:readonly string[]):import('@motionstudies/core/domain/aggregate-road').AggregateRoadSeries
|
package/webtris.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { checkedDate } from './uk-service-day.mjs'
|
|
2
|
+
|
|
3
|
+
const ENDPOINT = 'https://webtris.nationalhighways.co.uk/api/v1.0/reports/daily'
|
|
4
|
+
const CLASSES = ['0 - 520 cm', '521 - 660 cm', '661 - 1160 cm', '1160+ cm']
|
|
5
|
+
const number = (v, integer = false) => {
|
|
6
|
+
if (!['number', 'string'].includes(typeof v) || String(v).trim() === '') return null
|
|
7
|
+
const n = Number(v)
|
|
8
|
+
return Number.isFinite(n) && n >= 0 && (!integer || Number.isInteger(n)) ? n : null
|
|
9
|
+
}
|
|
10
|
+
export function reportUrl(siteIds, serviceDate, page, pageSize = 1000) {
|
|
11
|
+
checkedDate(serviceDate)
|
|
12
|
+
if (!siteIds.length || siteIds.length > 10 || siteIds.some(id => !/^\d+$/.test(id)) || new Set(siteIds).size !== siteIds.length) throw new Error('Select 1..10 distinct numeric site IDs')
|
|
13
|
+
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1000) throw new Error('Expected a positive page and page size of 1..1000')
|
|
14
|
+
const date = serviceDate.split('-').reverse().join('')
|
|
15
|
+
return `${ENDPOINT}?${new URLSearchParams({ sites: siteIds.join(','), start_date: date, end_date: date, page: String(page), page_size: String(pageSize) }).toString().replaceAll('%2C', ',')}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeReports(rows, sites, serviceDate) {
|
|
19
|
+
checkedDate(serviceDate)
|
|
20
|
+
if (!Array.isArray(rows) || !Array.isArray(sites) || sites.some(s => !s || typeof s.id !== 'string' || typeof s.description !== 'string') || new Set(sites.map(s => s.id)).size !== sites.length) throw new Error('Expected rows and unique named sites')
|
|
21
|
+
const names = new Map(sites.map(site => [site.description, site]))
|
|
22
|
+
if (names.size !== sites.length) throw new Error('Selected site descriptions are ambiguous')
|
|
23
|
+
const grouped = new Map(sites.map(site => [site.id, new Map()]))
|
|
24
|
+
const audit = { inputRows: rows.length, foreignSite: 0, foreignDate: 0, invalidInterval: 0, exactDuplicates: 0, conflictingIntervals: 0 }
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Error('Invalid WebTRIS row')
|
|
27
|
+
const site = names.get(row['Site Name'])
|
|
28
|
+
if (!site) { audit.foreignSite++; continue }
|
|
29
|
+
if (row['Report Date'] !== `${serviceDate}T00:00:00`) { audit.foreignDate++; continue }
|
|
30
|
+
const interval = number(row['Time Interval'], true)
|
|
31
|
+
const ending = row['Time Period Ending']
|
|
32
|
+
// Preserve the source's minute-inclusive ending label (00:14 etc). No invented UTC offset.
|
|
33
|
+
if (interval === null || interval > 99 || !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(ending ?? '')) { audit.invalidInterval++; continue }
|
|
34
|
+
const sample = { interval, periodEnding: ending, totalVolume: number(row['Total Volume'], true), averageSpeedMph: number(row['Avg mph']), lengthClassCounts: CLASSES.map(key => number(row[key], true)) }
|
|
35
|
+
sample.classTotalMatches = sample.totalVolume === null || sample.lengthClassCounts.includes(null) ? null : sample.lengthClassCounts.reduce((a,b) => a+b,0) === sample.totalVolume
|
|
36
|
+
const map = grouped.get(site.id)
|
|
37
|
+
if (map.has(interval)) {
|
|
38
|
+
if (JSON.stringify(map.get(interval)) === JSON.stringify(sample)) audit.exactDuplicates++
|
|
39
|
+
else { if (map.get(interval) !== null) audit.conflictingIntervals++; map.set(interval, null) }
|
|
40
|
+
} else map.set(interval, sample)
|
|
41
|
+
}
|
|
42
|
+
const observations = sites.map(site => {
|
|
43
|
+
const map = grouped.get(site.id)
|
|
44
|
+
const samples = [...map.values()].filter(Boolean).sort((a,b) => a.interval-b.interval)
|
|
45
|
+
return { site, samples, reportIntervals: samples.length, validVolumeIntervals: samples.filter(x => x.totalVolume !== null).length, validSpeedIntervals: samples.filter(x => x.averageSpeedMph !== null).length,
|
|
46
|
+
missingNominalIntervals: Array.from({ length: 96 }, (_,i) => i).filter(i => !map.get(i)),
|
|
47
|
+
conflictingIntervals: [...map].filter(([,v]) => v === null).map(([i])=>i).sort((a,b)=>a-b),
|
|
48
|
+
classTotalMismatches: samples.filter(x => x.classTotalMatches === false).length }
|
|
49
|
+
})
|
|
50
|
+
return { schemaVersion: 1, kind: 'observed-detector-report-audit', serviceDate, clock: { basis: 'Provider report date, interval index and period-ending label', timezone: null, utcMapping: 'Unverified; not placed on the scene clock', nominalIntervals: 96, completeness: '96-slot reference only; DST and source time semantics require verification' }, lengthClasses: CLASSES, audit, observations,
|
|
51
|
+
representation: 'Point detector counts and reported mph; no reconstructed vehicles, light/heavy inference or gap interpolation' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
/** Translate source observations without placing their unverified clock onto UTC. */
|
|
56
|
+
export function aggregateRoadSeries(observation, serviceDate, dataset, sourceRecordIds) {
|
|
57
|
+
checkedDate(serviceDate)
|
|
58
|
+
if (!dataset || !observation?.site?.id || !Array.isArray(sourceRecordIds) || !sourceRecordIds.length || sourceRecordIds.some(id => typeof id !== 'string' || !id)) throw new Error('Aggregate evidence requires dataset, site and source records')
|
|
59
|
+
return {
|
|
60
|
+
id: JSON.stringify(['webtris', dataset, observation.site.id]), provider: 'webtris', dataset, siteId: observation.site.id,
|
|
61
|
+
samples: observation.samples.map(s => ({
|
|
62
|
+
interval: { kind: 'provider-slot', serviceDate, index: s.interval, endingLabel: s.periodEnding },
|
|
63
|
+
vehicleCount: s.totalVolume, averageSpeed: s.averageSpeedMph === null ? null : { value: s.averageSpeedMph, unit: 'mph' },
|
|
64
|
+
sourceRecordIds: [...sourceRecordIds],
|
|
65
|
+
})),
|
|
66
|
+
}
|
|
67
|
+
}
|