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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile, rename, rm, stat, readdir, unlink } from 'node:fs/promises'
3
+ import { join, dirname } from 'node:path'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+ import { gzipSync, gunzipSync } from 'node:zlib'
6
+
7
+ export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
8
+
9
+ export function publicSourceUrl(source, value) {
10
+ const url = new URL(value)
11
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash ||
12
+ [...url.searchParams.keys()].some(key => /key|token|password|secret|signature|credential/i.test(key))) {
13
+ throw new Error('Source URLs must be public HTTPS URLs without credentials or fragments')
14
+ }
15
+ if (!source.origins.includes(url.origin)) throw new Error(`URL origin is not configured for ${source.id}`)
16
+ return url.href
17
+ }
18
+
19
+ export async function atomicJson(path, value) {
20
+ await atomicWrite(path, Buffer.from(`${JSON.stringify(value, null, 2)}\n`))
21
+ }
22
+
23
+ async function atomicWrite(path, bytes) {
24
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
25
+ const temporary = `${path}.${randomUUID()}.partial`
26
+ try {
27
+ await writeFile(temporary, bytes, { mode: 0o600 })
28
+ await rename(temporary, path)
29
+ } finally {
30
+ await rm(temporary, { force: true })
31
+ }
32
+ }
33
+
34
+ function requestKey(source, url) { return sha256(`${source.id}\n${url}`) }
35
+
36
+ export async function readCapture(store, source, url, validate = () => {}) {
37
+ const record = JSON.parse(await readFile(join(store, 'requests', `${requestKey(source, url)}.json`), 'utf8'))
38
+ if (record.source.id !== source.id || record.url !== url || !/^[a-f0-9]{64}$/.test(record.sha256)) throw new Error('Capture identity mismatch')
39
+ const bytes = await readObject(store, record.sha256, record.bytes)
40
+ if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) throw new Error('Capture integrity check failed')
41
+ await validate(bytes)
42
+ return { bytes, record, cached: true }
43
+ }
44
+ /** Read an object by its raw-content hash, whether it is stored raw or gzip-compressed at rest. */
45
+ export async function readObject(store, hash, expectedBytes) {
46
+ try { return await readFile(join(store, 'objects', hash)) } catch (error) { if (error.code !== 'ENOENT') throw error }
47
+ const compressed = await readFile(join(store, 'objects', `${hash}.gz`))
48
+ try { return gunzipSync(compressed, { maxOutputLength: (expectedBytes ?? 512 * 1024 * 1024) + 1 }) } catch { throw new Error('Compressed capture could not be expanded within its recorded size') }
49
+ }
50
+ /** Store object bytes, compressing at rest when asked and worthwhile; the hash of the raw bytes remains the identity. */
51
+ async function writeObject(store, hash, bytes, compression) {
52
+ if (compression === 'gzip') {
53
+ const compressed = gzipSync(bytes, { level: 6 })
54
+ if (compressed.length <= bytes.length * 0.9) { await atomicWrite(join(store, 'objects', `${hash}.gz`), compressed); return { storedBytes: compressed.length, encoding: 'gzip' } }
55
+ } else if (compression !== undefined) throw new Error('Unsupported compression; use gzip or leave unset')
56
+ await atomicWrite(join(store, 'objects', hash), bytes)
57
+ return { storedBytes: bytes.length, encoding: null }
58
+ }
59
+ /** Compress existing raw objects in a store where gzip saves at least the given fraction. Records keep their raw hash and byte count; reads follow either form. */
60
+ export async function compactObjects(store, { minimumSaving = 0.1 } = {}) {
61
+ const result = { examined: 0, compressed: 0, kept: 0, rawBytes: 0, storedBytes: 0 }
62
+ let names
63
+ try { names = await readdir(join(store, 'objects')) } catch (error) { if (error.code === 'ENOENT') return result; throw error }
64
+ for (const name of names.filter(n => /^[a-f0-9]{64}$/.test(n))) {
65
+ const bytes = await readFile(join(store, 'objects', name))
66
+ result.examined++; result.rawBytes += bytes.length
67
+ const compressed = gzipSync(bytes, { level: 6 })
68
+ if (compressed.length <= bytes.length * (1 - minimumSaving)) {
69
+ await atomicWrite(join(store, 'objects', `${name}.gz`), compressed)
70
+ if (sha256(gunzipSync(compressed)) !== name) throw new Error('Compressed object failed verification; raw object retained')
71
+ await unlink(join(store, 'objects', name)); result.compressed++; result.storedBytes += compressed.length
72
+ } else { result.kept++; result.storedBytes += bytes.length }
73
+ }
74
+ return result
75
+ }
76
+
77
+ async function persist(store, source, url, bytes, acquisition, response = {}, compression) {
78
+ const hash = sha256(bytes)
79
+ const stored = await writeObject(store, hash, bytes, compression)
80
+ const record = {
81
+ schemaVersion: 1, source: { id: source.id, publisher: source.publisher, kind: source.kind, documentation: source.documentation, license: source.license, ...(source.attribution ? { attribution: source.attribution } : {}) },
82
+ url, sha256: hash, bytes: bytes.length, storedBytes: stored.storedBytes, encoding: stored.encoding,
83
+ ...(acquisition === 'http' ? { retrievedAt: new Date().toISOString() } : { retrievedAt: null, importedAt: new Date().toISOString() }),
84
+ acquisition, validation: 'raw-capture; see adapter audit for fitness', ...response,
85
+ }
86
+ await atomicJson(join(store, 'records', `${randomUUID()}.json`), record)
87
+ await atomicJson(join(store, 'requests', `${requestKey(source, url)}.json`), record)
88
+ return { bytes, record, cached: false }
89
+ }
90
+
91
+ export async function importCapture({ store, source, url, input, maxBytes = 512 * 1024 * 1024, validate = () => {}, compression }) {
92
+ url = publicSourceUrl(source, url)
93
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Expected a positive byte limit')
94
+ if ((await stat(input)).size > maxBytes) throw new Error('Source file exceeds byte limit')
95
+ const bytes = await readFile(input)
96
+ if (!bytes.length || bytes.length > maxBytes) throw new Error('Source file is empty or exceeds byte limit')
97
+ await validate(bytes)
98
+ return persist(store, source, url, bytes, 'local-import', {}, compression)
99
+ }
100
+
101
+ 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', compression }) {
102
+ url = publicSourceUrl(source, url)
103
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error('Invalid capture limits')
104
+ if (offline && refresh) throw new Error('Offline and refresh cannot be combined')
105
+ if (!refresh) {
106
+ try { return await readCapture(store, source, url, validate) }
107
+ catch (error) { if (error.code !== 'ENOENT') throw error }
108
+ }
109
+ if (offline) throw new Error(`No cached capture for ${source.id}; offline mode never downloads`)
110
+ for (let attempt = 0; attempt <= retries; attempt++) {
111
+ let retryable = true
112
+ try {
113
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'User-Agent': userAgent } })
114
+ if (response.status === 204) {
115
+ retryable = false
116
+ throw new Error('Source returned HTTP 204: no report content; this does not mean zero traffic')
117
+ }
118
+ if (!response.ok) {
119
+ retryable = response.status === 429 || response.status >= 500
120
+ await response.body?.cancel()
121
+ throw new Error(`Source returned HTTP ${response.status}`)
122
+ }
123
+ if (Number(response.headers.get('content-length')) > maxBytes) {
124
+ retryable = false
125
+ await response.body?.cancel()
126
+ throw new Error('Source exceeds byte limit')
127
+ }
128
+ if (!response.body) throw new Error('Source response has no body')
129
+ const reader = response.body.getReader()
130
+ const chunks = []
131
+ let size = 0
132
+ try {
133
+ while (true) {
134
+ const { value, done } = await reader.read()
135
+ if (done) break
136
+ size += value.length
137
+ if (size > maxBytes) { retryable = false; await reader.cancel(); throw new Error('Source exceeds byte limit') }
138
+ chunks.push(value)
139
+ }
140
+ } finally { reader.releaseLock() }
141
+ const bytes = Buffer.concat(chunks)
142
+ retryable = false
143
+ if (!bytes.length) throw new Error('Source response is empty')
144
+ await validate(bytes)
145
+ return await persist(store, source, url, bytes, 'http', {
146
+ httpStatus: response.status, contentType: response.headers.get('content-type'),
147
+ lastModified: response.headers.get('last-modified'), etag: response.headers.get('etag'),
148
+ }, compression)
149
+ } catch (error) {
150
+ if (!retryable || attempt === retries) throw error
151
+ await delay(250 * 2 ** attempt)
152
+ }
153
+ }
154
+ }
155
+
156
+ export function jsonDocument(bytes) {
157
+ const value = JSON.parse(bytes.toString('utf8'))
158
+ if (!value || typeof value !== 'object') throw new Error('Expected a JSON document')
159
+ return value
160
+ }
@@ -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
+ }