@motionstudies/data 0.1.0-alpha.2 → 0.1.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -2
- package/adsb-heatmap.d.mts +56 -0
- package/adsb-heatmap.mjs +223 -0
- package/air-endpoints.d.mts +5 -0
- package/air-endpoints.mjs +130 -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/package.json +54 -3
- 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/source-store.d.mts +15 -0
- package/source-store.mjs +160 -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/source-store.mjs
ADDED
|
@@ -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
|
+
}
|