@motionstudies/core 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/air-search.d.ts +3 -0
- package/domain/aggregate-road.d.ts +48 -0
- package/domain/aggregate-road.js +22 -0
- package/domain/air-day.d.ts +3 -1
- package/domain/air.d.ts +11 -0
- package/domain/airport.d.ts +16 -0
- package/domain/airport.js +23 -0
- package/domain/daylight.d.ts +32 -0
- package/domain/daylight.js +32 -0
- package/domain/field-paths.d.ts +35 -0
- package/domain/field-paths.js +43 -0
- package/domain/gridded-series.d.ts +30 -0
- package/domain/gridded-series.js +61 -0
- package/domain/live-airport.d.ts +42 -0
- package/domain/live-airport.js +32 -0
- package/domain/movement-board.d.ts +21 -0
- package/domain/movement-board.js +23 -0
- package/domain/network-patterns.d.ts +19 -0
- package/domain/network-patterns.js +73 -0
- package/domain/network.js +37 -1
- package/domain/power-day.d.ts +84 -0
- package/domain/power-day.js +104 -0
- package/domain/published-day.d.ts +132 -0
- package/domain/published-day.js +162 -0
- package/domain/station-calls.d.ts +48 -0
- package/domain/station-calls.js +73 -0
- package/domain/station-departures.d.ts +15 -0
- package/domain/station-departures.js +25 -0
- package/domain/supported-field.d.ts +78 -0
- package/domain/supported-field.js +95 -0
- package/domain/vehicle-calls.d.ts +11 -0
- package/domain/vehicle-calls.js +14 -0
- package/domain/vehicle-counts.d.ts +13 -0
- package/domain/vehicle-counts.js +33 -0
- package/package.json +73 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A regular latitude/longitude grid of one or more variables at listed instants, such as cloud
|
|
2
|
+
* cover, rainfall or a model wind component. Values keep the source's own units; null marks a
|
|
3
|
+
* missing sample. Readings interpolate bilinearly in space and linearly in time, and a missing
|
|
4
|
+
* sample with any weight makes the whole reading unavailable rather than bridging the gap.
|
|
5
|
+
*/
|
|
6
|
+
export interface GriddedSeriesGrid {
|
|
7
|
+
readonly west: number;
|
|
8
|
+
readonly south: number;
|
|
9
|
+
readonly step: number;
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
}
|
|
13
|
+
export interface GriddedSeries<K extends string = string> {
|
|
14
|
+
/** ISO instants in ascending order. */
|
|
15
|
+
readonly times: readonly string[];
|
|
16
|
+
readonly grid: GriddedSeriesGrid;
|
|
17
|
+
/** One frame per instant; rows run south to north. */
|
|
18
|
+
readonly frames: readonly Readonly<Partial<Record<K, readonly (number | null)[]>>>[];
|
|
19
|
+
}
|
|
20
|
+
export interface TimeBracket {
|
|
21
|
+
readonly a: number;
|
|
22
|
+
readonly b: number;
|
|
23
|
+
readonly fraction: number;
|
|
24
|
+
}
|
|
25
|
+
/** The adjacent frames around an instant. Exact instants bracket themselves; instants outside the series are null. */
|
|
26
|
+
export declare function bracketTime(times: readonly string[], time: string | number): TimeBracket | null;
|
|
27
|
+
/** One variable at a place and instant, or null outside the grid, outside the times, or beside a missing sample. */
|
|
28
|
+
export declare function sampleGriddedSeries<K extends string>(series: GriddedSeries<K>, key: K, latitude: number, longitude: number, time: string | number): number | null;
|
|
29
|
+
/** Area-weighted mean of one variable over the whole grid at an instant. Any missing or out-of-range weighted sample makes the mean unavailable. */
|
|
30
|
+
export declare function meanGriddedSeries<K extends string>(series: GriddedSeries<K>, key: K, time: string | number, range?: readonly [number, number]): number | null;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The adjacent frames around an instant. Exact instants bracket themselves; instants outside the series are null. */
|
|
2
|
+
export function bracketTime(times, time) {
|
|
3
|
+
const value = typeof time === 'number' ? time : Date.parse(time);
|
|
4
|
+
if (!times.length || !Number.isFinite(value) || value < Date.parse(times[0]) || value > Date.parse(times[times.length - 1]))
|
|
5
|
+
return null;
|
|
6
|
+
for (let i = 0; i < times.length; i++) {
|
|
7
|
+
const stamp = Date.parse(times[i]);
|
|
8
|
+
if (value === stamp)
|
|
9
|
+
return { a: i, b: i, fraction: 0 };
|
|
10
|
+
if (stamp > value)
|
|
11
|
+
return { a: i - 1, b: i, fraction: (value - Date.parse(times[i - 1])) / (stamp - Date.parse(times[i - 1])) };
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const valid = (sample, range) => typeof sample === 'number' && Number.isFinite(sample) && (!range || (sample >= range[0] && sample <= range[1]));
|
|
16
|
+
/** One variable at a place and instant, or null outside the grid, outside the times, or beside a missing sample. */
|
|
17
|
+
export function sampleGriddedSeries(series, key, latitude, longitude, time) {
|
|
18
|
+
const bracket = bracketTime(series.times, time), { west, south, step, width, height } = series.grid, x = (longitude - west) / step, y = (latitude - south) / step;
|
|
19
|
+
if (!bracket || !(x >= 0 && y >= 0 && x <= width - 1 && y <= height - 1))
|
|
20
|
+
return null;
|
|
21
|
+
const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;
|
|
22
|
+
let value = 0;
|
|
23
|
+
for (const [t, wt] of [[bracket.a, 1 - bracket.fraction], [bracket.b, bracket.fraction]]) {
|
|
24
|
+
for (const [ix, wx] of [[x0, 1 - fx], [Math.min(x0 + 1, width - 1), fx]])
|
|
25
|
+
for (const [iy, wy] of [[y0, 1 - fy], [Math.min(y0 + 1, height - 1), fy]]) {
|
|
26
|
+
const weight = wt * wx * wy;
|
|
27
|
+
if (weight === 0)
|
|
28
|
+
continue;
|
|
29
|
+
const sample = series.frames[t]?.[key]?.[iy * width + ix];
|
|
30
|
+
if (!valid(sample))
|
|
31
|
+
return null;
|
|
32
|
+
value += sample * weight;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/** Area-weighted mean of one variable over the whole grid at an instant. Any missing or out-of-range weighted sample makes the mean unavailable. */
|
|
38
|
+
export function meanGriddedSeries(series, key, time, range) {
|
|
39
|
+
const bracket = bracketTime(series.times, time);
|
|
40
|
+
if (!bracket)
|
|
41
|
+
return null;
|
|
42
|
+
const { south, step, width, height } = series.grid;
|
|
43
|
+
let sum = 0, weight = 0;
|
|
44
|
+
for (let y = 0; y < height; y++) {
|
|
45
|
+
const area = Math.cos((south + y * step) * Math.PI / 180);
|
|
46
|
+
for (let x = 0; x < width; x++) {
|
|
47
|
+
let value = 0;
|
|
48
|
+
for (const [frame, w] of [[bracket.a, 1 - bracket.fraction], [bracket.b, bracket.fraction]]) {
|
|
49
|
+
if (w === 0)
|
|
50
|
+
continue;
|
|
51
|
+
const sample = series.frames[frame]?.[key]?.[y * width + x];
|
|
52
|
+
if (!valid(sample, range))
|
|
53
|
+
return null;
|
|
54
|
+
value += sample * w;
|
|
55
|
+
}
|
|
56
|
+
sum += value * area;
|
|
57
|
+
weight += area;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return weight ? sum / weight : null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Live boards use Unix seconds throughout, independent of an edition's recorded service day. */
|
|
2
|
+
export interface LiveAirportMovement {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly service: string;
|
|
5
|
+
readonly scheduledTime?: number;
|
|
6
|
+
/** Provider revision: may be estimated or actual; do not relabel as confirmed. */
|
|
7
|
+
readonly revisedTime?: number;
|
|
8
|
+
readonly place?: string;
|
|
9
|
+
readonly gate?: string;
|
|
10
|
+
readonly status?: string;
|
|
11
|
+
readonly tone?: 'neutral' | 'accent' | 'warning';
|
|
12
|
+
}
|
|
13
|
+
export interface LiveAirportSnapshot {
|
|
14
|
+
readonly airport: {
|
|
15
|
+
readonly iata: string;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly city: string;
|
|
18
|
+
readonly timeZone: string;
|
|
19
|
+
};
|
|
20
|
+
readonly fetchedAt: number;
|
|
21
|
+
readonly freshUntil: number;
|
|
22
|
+
readonly expiresAt: number;
|
|
23
|
+
readonly windowStart: number;
|
|
24
|
+
readonly windowEnd: number;
|
|
25
|
+
readonly departures: readonly LiveAirportMovement[];
|
|
26
|
+
readonly arrivals: readonly LiveAirportMovement[];
|
|
27
|
+
}
|
|
28
|
+
export type AirportFeedReason = 'disabled' | 'not-configured' | 'subscription' | 'budget' | 'provider' | 'rate-limit';
|
|
29
|
+
export interface AirportFeedResponse {
|
|
30
|
+
readonly version: 1;
|
|
31
|
+
readonly status: 'fresh' | 'stale' | 'unavailable' | 'disabled';
|
|
32
|
+
readonly reason?: AirportFeedReason;
|
|
33
|
+
readonly retryAfterSeconds: number;
|
|
34
|
+
readonly snapshot?: LiveAirportSnapshot;
|
|
35
|
+
}
|
|
36
|
+
export interface AirportFeedCapabilities {
|
|
37
|
+
readonly version: 1;
|
|
38
|
+
readonly enabled: boolean;
|
|
39
|
+
readonly airports: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
/** Validate the network boundary before publishing data to a card. */
|
|
42
|
+
export declare function isAirportFeedResponse(value: unknown): value is AirportFeedResponse;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const record = (value) => !!value && typeof value === 'object';
|
|
2
|
+
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
3
|
+
function movement(value) {
|
|
4
|
+
return record(value) && typeof value.id === 'string' && typeof value.service === 'string'
|
|
5
|
+
&& ['scheduledTime', 'revisedTime'].every((key) => value[key] === undefined || finite(value[key]))
|
|
6
|
+
&& ['place', 'gate', 'status'].every((key) => value[key] === undefined || typeof value[key] === 'string')
|
|
7
|
+
&& (value.tone === undefined || ['neutral', 'accent', 'warning'].includes(String(value.tone)));
|
|
8
|
+
}
|
|
9
|
+
/** Validate the network boundary before publishing data to a card. */
|
|
10
|
+
export function isAirportFeedResponse(value) {
|
|
11
|
+
if (!record(value) || value.version !== 1 || !['fresh', 'stale', 'unavailable', 'disabled'].includes(String(value.status))
|
|
12
|
+
|| !finite(value.retryAfterSeconds) || value.retryAfterSeconds < 0)
|
|
13
|
+
return false;
|
|
14
|
+
if (value.status === 'disabled' || value.status === 'unavailable')
|
|
15
|
+
return value.snapshot === undefined;
|
|
16
|
+
const s = value.snapshot;
|
|
17
|
+
try {
|
|
18
|
+
if (!record(s) || !record(s.airport) || typeof s.airport.timeZone !== 'string')
|
|
19
|
+
return false;
|
|
20
|
+
new Intl.DateTimeFormat('en', { timeZone: s.airport.timeZone }).format(0);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return record(s) && record(s.airport)
|
|
26
|
+
&& ['iata', 'name', 'city', 'timeZone'].every((key) => typeof s.airport[key] === 'string')
|
|
27
|
+
&& ['fetchedAt', 'freshUntil', 'expiresAt', 'windowStart', 'windowEnd'].every((key) => finite(s[key]))
|
|
28
|
+
&& Number(s.fetchedAt) <= Number(s.freshUntil) && Number(s.freshUntil) <= Number(s.expiresAt)
|
|
29
|
+
&& Number(s.windowStart) < Number(s.windowEnd)
|
|
30
|
+
&& Array.isArray(s.departures) && s.departures.every(movement)
|
|
31
|
+
&& Array.isArray(s.arrivals) && s.arrivals.every(movement);
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** All values use the study's time coordinate, in seconds (including times beyond midnight). */
|
|
2
|
+
export interface MovementBoardStudy {
|
|
3
|
+
readonly time: number;
|
|
4
|
+
readonly windowStart: number;
|
|
5
|
+
readonly windowEnd: number;
|
|
6
|
+
}
|
|
7
|
+
export interface MovementBoardHorizon {
|
|
8
|
+
readonly lookBehindSeconds?: number;
|
|
9
|
+
readonly lookAheadSeconds?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface MovementBoardWindow {
|
|
12
|
+
readonly start: number;
|
|
13
|
+
readonly end: number;
|
|
14
|
+
}
|
|
15
|
+
/** Keep recent movements briefly, then show the next hour, clipped to the selected study. */
|
|
16
|
+
export declare function movementBoardWindow(study: MovementBoardStudy, { lookBehindSeconds, lookAheadSeconds }?: MovementBoardHorizon): MovementBoardWindow | undefined;
|
|
17
|
+
/** Unknown times cannot be placed in a time-windowed board. Never mutate the caller's rows. */
|
|
18
|
+
export declare function movementsForBoard<T extends {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly time?: number;
|
|
21
|
+
}>(movements: readonly T[], window: MovementBoardWindow | undefined, maxRows?: number): readonly T[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Keep recent movements briefly, then show the next hour, clipped to the selected study. */
|
|
2
|
+
export function movementBoardWindow(study, { lookBehindSeconds = 600, lookAheadSeconds = 3600 } = {}) {
|
|
3
|
+
const { time, windowStart, windowEnd } = study;
|
|
4
|
+
if (![time, windowStart, windowEnd, lookBehindSeconds, lookAheadSeconds].every(Number.isFinite)
|
|
5
|
+
|| windowStart > windowEnd || time < windowStart || time > windowEnd
|
|
6
|
+
|| lookBehindSeconds < 0 || lookAheadSeconds < 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
return {
|
|
9
|
+
start: Math.max(windowStart, time - lookBehindSeconds),
|
|
10
|
+
end: Math.min(windowEnd, time + lookAheadSeconds),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Unknown times cannot be placed in a time-windowed board. Never mutate the caller's rows. */
|
|
14
|
+
export function movementsForBoard(movements, window, maxRows = 8) {
|
|
15
|
+
if (!window || ![window.start, window.end, maxRows].every(Number.isFinite)
|
|
16
|
+
|| window.start > window.end || maxRows < 1)
|
|
17
|
+
return [];
|
|
18
|
+
return movements
|
|
19
|
+
.filter((entry) => entry.time !== undefined && Number.isFinite(entry.time)
|
|
20
|
+
&& entry.time >= window.start && entry.time <= window.end)
|
|
21
|
+
.sort((first, second) => first.time - second.time || (first.id < second.id ? -1 : first.id > second.id ? 1 : 0))
|
|
22
|
+
.slice(0, Math.floor(maxRows));
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { NetworkDayChunk, NetworkTrain, ServiceCategory } from './network.ts';
|
|
2
|
+
export declare const NETWORK_PATTERN_FORMAT = "network-patterns-v1";
|
|
3
|
+
export type NetworkPattern = Omit<NetworkTrain, 'id' | 'start'>;
|
|
4
|
+
export interface PatternNetworkChunk {
|
|
5
|
+
readonly format: string;
|
|
6
|
+
readonly windowStart: number;
|
|
7
|
+
readonly windowEnd: number;
|
|
8
|
+
readonly patterns: readonly NetworkPattern[];
|
|
9
|
+
readonly journeys: readonly (readonly [id: string, start: number, pattern: number])[];
|
|
10
|
+
}
|
|
11
|
+
export interface PatternChunkOptions {
|
|
12
|
+
/** A consumer may retain a previously published wire-format tag. */
|
|
13
|
+
readonly format?: string;
|
|
14
|
+
readonly categories?: readonly ServiceCategory[];
|
|
15
|
+
}
|
|
16
|
+
/** Deduplicate relative schedules on disk; identities and absolute calls survive decoding. */
|
|
17
|
+
export declare function encodeNetworkPatterns(chunk: NetworkDayChunk, options?: PatternChunkOptions): PatternNetworkChunk;
|
|
18
|
+
export declare function validateNetworkPatterns(value: unknown, options?: PatternChunkOptions): asserts value is PatternNetworkChunk;
|
|
19
|
+
export declare function decodeNetworkPatterns(value: unknown, options?: PatternChunkOptions): NetworkDayChunk;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const NETWORK_PATTERN_FORMAT = 'network-patterns-v1';
|
|
2
|
+
/** Deduplicate relative schedules on disk; identities and absolute calls survive decoding. */
|
|
3
|
+
export function encodeNetworkPatterns(chunk, options = {}) {
|
|
4
|
+
const patterns = [], indexes = new Map();
|
|
5
|
+
const journeys = chunk.trains.map(({ id, start, ...train }) => {
|
|
6
|
+
const pattern = { ...train, end: train.end - start,
|
|
7
|
+
stops: train.stops.map(([stop, arrival, departure]) => [stop, arrival - start, departure - start]),
|
|
8
|
+
};
|
|
9
|
+
const key = JSON.stringify(pattern);
|
|
10
|
+
let index = indexes.get(key);
|
|
11
|
+
if (index === undefined) {
|
|
12
|
+
index = patterns.length;
|
|
13
|
+
indexes.set(key, index);
|
|
14
|
+
patterns.push(pattern);
|
|
15
|
+
}
|
|
16
|
+
return [id, start, index];
|
|
17
|
+
});
|
|
18
|
+
const result = { format: options.format ?? NETWORK_PATTERN_FORMAT, windowStart: chunk.windowStart,
|
|
19
|
+
windowEnd: chunk.windowEnd, patterns, journeys };
|
|
20
|
+
validateNetworkPatterns(result, options);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
export function validateNetworkPatterns(value, options = {}) {
|
|
24
|
+
const chunk = value;
|
|
25
|
+
if (!chunk || chunk.format !== (options.format ?? NETWORK_PATTERN_FORMAT) ||
|
|
26
|
+
!Number.isFinite(chunk.windowStart) || !Number.isFinite(chunk.windowEnd) || chunk.windowEnd <= chunk.windowStart ||
|
|
27
|
+
!Array.isArray(chunk.patterns) || !Array.isArray(chunk.journeys))
|
|
28
|
+
throw new Error('Invalid network pattern chunk');
|
|
29
|
+
for (const pattern of chunk.patterns) {
|
|
30
|
+
if (!pattern || !Number.isFinite(pattern.end) || pattern.end < 0 ||
|
|
31
|
+
typeof pattern.route !== 'string' || typeof pattern.headsign !== 'string' || typeof pattern.shortName !== 'string' ||
|
|
32
|
+
typeof pattern.category !== 'string' || (options.categories && !options.categories.includes(pattern.category)) ||
|
|
33
|
+
!Array.isArray(pattern.stops) || pattern.stops.length < 2)
|
|
34
|
+
throw new Error('Invalid network pattern');
|
|
35
|
+
let previous = -Infinity;
|
|
36
|
+
for (const stop of pattern.stops) {
|
|
37
|
+
if (!Array.isArray(stop) || stop.length !== 3)
|
|
38
|
+
throw new Error('Invalid pattern stop');
|
|
39
|
+
const [index, arrival, departure] = stop;
|
|
40
|
+
if (!Number.isSafeInteger(index) || index < 0 || !Number.isFinite(arrival) || !Number.isFinite(departure) ||
|
|
41
|
+
departure < arrival || arrival < previous)
|
|
42
|
+
throw new Error('Invalid pattern stop timing');
|
|
43
|
+
previous = departure;
|
|
44
|
+
}
|
|
45
|
+
if (pattern.end < previous)
|
|
46
|
+
throw new Error('Pattern ends before its final call');
|
|
47
|
+
if (pattern.pathSegments && (!Array.isArray(pattern.pathSegments) || pattern.pathSegments.length !== pattern.stops.length - 1 ||
|
|
48
|
+
pattern.pathSegments.some((index) => index !== null && (!Number.isSafeInteger(index) || index < 0))))
|
|
49
|
+
throw new Error('Invalid pattern paths');
|
|
50
|
+
}
|
|
51
|
+
const ids = new Set();
|
|
52
|
+
for (const journey of chunk.journeys) {
|
|
53
|
+
if (!Array.isArray(journey) || journey.length !== 3)
|
|
54
|
+
throw new Error('Invalid pattern journey');
|
|
55
|
+
const [id, start, index] = journey;
|
|
56
|
+
if (typeof id !== 'string' || !id || ids.has(id) || !Number.isFinite(start) || !Number.isSafeInteger(index) || index < 0 ||
|
|
57
|
+
!chunk.patterns[index] || !Number.isFinite(start + chunk.patterns[index].end) ||
|
|
58
|
+
chunk.patterns[index].stops.some((stop) => !Number.isFinite(start + stop[1]) || !Number.isFinite(start + stop[2])))
|
|
59
|
+
throw new Error('Invalid pattern journey identity or time');
|
|
60
|
+
ids.add(id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function decodeNetworkPatterns(value, options = {}) {
|
|
64
|
+
validateNetworkPatterns(value, options);
|
|
65
|
+
return { windowStart: value.windowStart, windowEnd: value.windowEnd,
|
|
66
|
+
trains: value.journeys.map(([id, start, index]) => {
|
|
67
|
+
const pattern = value.patterns[index];
|
|
68
|
+
return { ...pattern, id, start, end: start + pattern.end,
|
|
69
|
+
stops: pattern.stops.map(([stop, arrival, departure]) => [stop, start + arrival, start + departure]),
|
|
70
|
+
};
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
package/domain/network.js
CHANGED
|
@@ -69,7 +69,7 @@ export function buildStationIndex(snapshot) {
|
|
|
69
69
|
routes: [...record.routes.values()].sort((first, second) => first.name.localeCompare(second.name, 'de-CH')),
|
|
70
70
|
}));
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
function linearPositionForTrain(train, time) {
|
|
73
73
|
if (train.realtime?.status === 'cancelled' ||
|
|
74
74
|
time < train.start ||
|
|
75
75
|
time > train.end ||
|
|
@@ -98,6 +98,42 @@ export function positionForTrain(train, time) {
|
|
|
98
98
|
const last = train.stops.at(-1);
|
|
99
99
|
return { fromStop: last[0], toStop: last[0], progress: 0 };
|
|
100
100
|
}
|
|
101
|
+
// Timetables are immutable. A replacement stop array (including realtime edits)
|
|
102
|
+
// gets its own validation; old schedules can be garbage collected.
|
|
103
|
+
const chronologicalSchedules = new WeakMap();
|
|
104
|
+
export function positionForTrain(train, time) {
|
|
105
|
+
if (train.realtime?.status === 'cancelled' || time < train.start || time > train.end || train.stops.length < 2)
|
|
106
|
+
return;
|
|
107
|
+
const stops = train.stops;
|
|
108
|
+
let chronological = chronologicalSchedules.get(stops);
|
|
109
|
+
if (chronological === undefined) {
|
|
110
|
+
chronological = stops.every((stop, index) => Number.isFinite(stop[1]) && Number.isFinite(stop[2]) &&
|
|
111
|
+
stop[2] >= stop[1] && (index === 0 || stop[1] >= stops[index - 1][2]));
|
|
112
|
+
chronologicalSchedules.set(stops, chronological);
|
|
113
|
+
}
|
|
114
|
+
// Preserve sequential semantics for unordered observations and non-finite clocks.
|
|
115
|
+
if (!chronological || !Number.isFinite(time))
|
|
116
|
+
return linearPositionForTrain(train, time);
|
|
117
|
+
let lower = 0;
|
|
118
|
+
let upper = stops.length;
|
|
119
|
+
while (lower < upper) {
|
|
120
|
+
const middle = (lower + upper) >>> 1;
|
|
121
|
+
if (stops[middle][2] < time)
|
|
122
|
+
lower = middle + 1;
|
|
123
|
+
else
|
|
124
|
+
upper = middle;
|
|
125
|
+
}
|
|
126
|
+
const next = stops[Math.min(lower, stops.length - 1)];
|
|
127
|
+
if (lower === 0 || lower === stops.length || time > next[1]) {
|
|
128
|
+
return { fromStop: next[0], toStop: next[0], progress: 0 };
|
|
129
|
+
}
|
|
130
|
+
const previous = stops[lower - 1];
|
|
131
|
+
return {
|
|
132
|
+
fromStop: previous[0], toStop: next[0],
|
|
133
|
+
progress: Math.min(1, Math.max(0, (time - previous[2]) / Math.max(1, next[1] - previous[2]))),
|
|
134
|
+
segmentIndex: lower - 1,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
101
137
|
export function formatServiceTime(totalSeconds) {
|
|
102
138
|
const normalized = ((Math.round(totalSeconds) % 86400) + 86400) % 86400;
|
|
103
139
|
const hours = Math.floor(normalized / 3600);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Dated electricity evidence. All times are elapsed UTC seconds from startUtc.
|
|
2
|
+
* Settlement is interval energy (MWh); plans and national aggregates are power (MW).
|
|
3
|
+
* Geography, population selection and placement policy belong to the consuming work.
|
|
4
|
+
*/
|
|
5
|
+
export interface PowerSource {
|
|
6
|
+
sha256: string;
|
|
7
|
+
url: string;
|
|
8
|
+
retrievedAt: string;
|
|
9
|
+
publisher: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PowerPlacement {
|
|
12
|
+
method: 'site' | 'connection' | 'curated' | 'aggregate' | 'interconnector' | 'unplaced';
|
|
13
|
+
latitude?: number;
|
|
14
|
+
longitude?: number;
|
|
15
|
+
note?: string | null;
|
|
16
|
+
source?: string | null;
|
|
17
|
+
score?: number;
|
|
18
|
+
matched?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export interface PowerUnit {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
fuelType: string | null;
|
|
24
|
+
capacityMw: number | null;
|
|
25
|
+
settledMwh: (number | null)[];
|
|
26
|
+
runs: (string | null)[];
|
|
27
|
+
/** [start, end, start MW, end MW]; half-open intervals, no extrapolation. */
|
|
28
|
+
plan: [number, number, number, number][];
|
|
29
|
+
placement?: PowerPlacement;
|
|
30
|
+
evidence?: {
|
|
31
|
+
path: string;
|
|
32
|
+
sha256: string;
|
|
33
|
+
bytes: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface PowerDay {
|
|
37
|
+
kind: 'power-day';
|
|
38
|
+
schemaVersion: 1;
|
|
39
|
+
date: string;
|
|
40
|
+
timezone: string;
|
|
41
|
+
startUtc: string;
|
|
42
|
+
endUtc: string;
|
|
43
|
+
periodSeconds: 1800;
|
|
44
|
+
quantityUnit: 'MWh';
|
|
45
|
+
powerUnit: 'MW';
|
|
46
|
+
scope: string;
|
|
47
|
+
population: string;
|
|
48
|
+
units: PowerUnit[];
|
|
49
|
+
demandMw: (number | null)[];
|
|
50
|
+
fuelMix: {
|
|
51
|
+
time: number;
|
|
52
|
+
byFuel: Record<string, number | null>;
|
|
53
|
+
}[];
|
|
54
|
+
settlementSources: string[];
|
|
55
|
+
planSources: string[];
|
|
56
|
+
sources: PowerSource[];
|
|
57
|
+
attribution: {
|
|
58
|
+
statement: string;
|
|
59
|
+
licence: string;
|
|
60
|
+
}[];
|
|
61
|
+
}
|
|
62
|
+
/** Validate untrusted JSON at both publication and browser boundaries. Does not fetch. */
|
|
63
|
+
export declare function readPowerDay(value: unknown): PowerDay;
|
|
64
|
+
export declare function powerPeriod(day: PowerDay, seconds: number): number | null;
|
|
65
|
+
/** Return null for a gap or ambiguous overlapping declarations, never infer a plan. */
|
|
66
|
+
export declare function plannedPower(unit: PowerUnit, seconds: number): number | null;
|
|
67
|
+
export declare function unitPower(day: PowerDay, unit: PowerUnit, seconds: number): {
|
|
68
|
+
period: number | null;
|
|
69
|
+
settledMwh: number | null;
|
|
70
|
+
averageMw: number | null;
|
|
71
|
+
plannedMw: number | null;
|
|
72
|
+
run: string | null;
|
|
73
|
+
};
|
|
74
|
+
/** Membership is supplied by the work; report missing contributors alongside partial sums. */
|
|
75
|
+
export declare function sumPower(day: PowerDay, units: readonly PowerUnit[], seconds: number): {
|
|
76
|
+
averageMw: number | null;
|
|
77
|
+
available: number;
|
|
78
|
+
missing: number;
|
|
79
|
+
};
|
|
80
|
+
/** Last national sample, valid for at most five minutes and never outside this day. */
|
|
81
|
+
export declare function fuelMixAt(day: PowerDay, seconds: number): {
|
|
82
|
+
time: number;
|
|
83
|
+
byFuel: Record<string, number | null>;
|
|
84
|
+
} | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
2
|
+
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
3
|
+
const nullable = (v) => v === null || finite(v);
|
|
4
|
+
const hash = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
5
|
+
const nonempty = (v) => typeof v === 'string' && v.length > 0;
|
|
6
|
+
const utc = (v) => typeof v === 'string' && /Z$/.test(v) && Number.isFinite(Date.parse(v));
|
|
7
|
+
function requireValue(ok, message) { if (!ok)
|
|
8
|
+
throw new Error(`Invalid power day: ${message}`); }
|
|
9
|
+
/** Validate untrusted JSON at both publication and browser boundaries. Does not fetch. */
|
|
10
|
+
export function readPowerDay(value) {
|
|
11
|
+
requireValue(object(value), 'expected object');
|
|
12
|
+
const d = value;
|
|
13
|
+
requireValue(d.kind === 'power-day' && d.schemaVersion === 1, 'schema');
|
|
14
|
+
requireValue(nonempty(d.timezone) && typeof d.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d.date), 'date/timezone');
|
|
15
|
+
requireValue(utc(d.startUtc) && utc(d.endUtc), 'UTC bounds');
|
|
16
|
+
const duration = (Date.parse(d.endUtc) - Date.parse(d.startUtc)) / 1000, periods = duration / 1800;
|
|
17
|
+
requireValue([46, 48, 50].includes(periods) && d.periodSeconds === 1800, 'civil-day intervals');
|
|
18
|
+
// A power day is a complete civil day, including both clock-change days.
|
|
19
|
+
const dateAt = (t) => new Intl.DateTimeFormat('en-CA', { timeZone: d.timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(t);
|
|
20
|
+
const midnight = (t) => new Intl.DateTimeFormat('en-GB', { timeZone: d.timezone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).format(t) === '00:00:00';
|
|
21
|
+
requireValue(dateAt(Date.parse(d.startUtc)) === d.date && midnight(Date.parse(d.startUtc)) && midnight(Date.parse(d.endUtc)) && dateAt(Date.parse(d.endUtc) - 1) === d.date, 'civil date');
|
|
22
|
+
requireValue(d.quantityUnit === 'MWh' && d.powerUnit === 'MW', 'explicit units');
|
|
23
|
+
requireValue(nonempty(d.scope) && nonempty(d.population), 'scope/population');
|
|
24
|
+
requireValue(Array.isArray(d.sources) && d.sources.length > 0, 'sources');
|
|
25
|
+
const hashes = new Set();
|
|
26
|
+
for (const s of d.sources) {
|
|
27
|
+
requireValue(object(s) && hash(s.sha256) && nonempty(s.publisher) && utc(s.retrievedAt) && typeof s.url === 'string' && /^https:\/\//.test(s.url), 'source record');
|
|
28
|
+
requireValue(!hashes.has(s.sha256), 'duplicate source');
|
|
29
|
+
hashes.add(s.sha256);
|
|
30
|
+
}
|
|
31
|
+
for (const key of ['settlementSources', 'planSources'])
|
|
32
|
+
requireValue(Array.isArray(d[key]) && d[key].length === periods && d[key].every((h) => typeof h === 'string' && hashes.has(h)), `${key} linkage`);
|
|
33
|
+
requireValue(Array.isArray(d.attribution) && d.attribution.length > 0 && d.attribution.every(a => object(a) && nonempty(a.statement) && typeof a.licence === 'string' && /^https:\/\//.test(a.licence)), 'attribution');
|
|
34
|
+
requireValue(Array.isArray(d.demandMw) && d.demandMw.length === periods && d.demandMw.every(nullable), 'demand');
|
|
35
|
+
requireValue(Array.isArray(d.fuelMix), 'fuel mix');
|
|
36
|
+
let previous = -Infinity;
|
|
37
|
+
for (const f of d.fuelMix) {
|
|
38
|
+
requireValue(object(f) && finite(f.time) && f.time >= 0 && f.time < duration && f.time > previous && object(f.byFuel) && Object.values(f.byFuel).every(nullable), 'fuel mix samples');
|
|
39
|
+
previous = f.time;
|
|
40
|
+
}
|
|
41
|
+
requireValue(Array.isArray(d.units), 'units');
|
|
42
|
+
const ids = new Set();
|
|
43
|
+
for (const u of d.units) {
|
|
44
|
+
requireValue(object(u) && nonempty(u.id) && !ids.has(u.id) && nonempty(u.name), 'unit identity');
|
|
45
|
+
ids.add(u.id);
|
|
46
|
+
requireValue((u.fuelType === null || nonempty(u.fuelType)) && nullable(u.capacityMw), 'unit register');
|
|
47
|
+
requireValue(Array.isArray(u.settledMwh) && u.settledMwh.length === periods && u.settledMwh.every(nullable), 'settlement values');
|
|
48
|
+
const settledMwh = u.settledMwh;
|
|
49
|
+
requireValue(Array.isArray(u.runs) && u.runs.length === periods && u.runs.every((r, i) => settledMwh[i] === null ? r === null : ['II', 'SF', 'R1', 'R2', 'R3', 'RF', 'DF'].includes(r)), 'settlement runs');
|
|
50
|
+
requireValue(Array.isArray(u.plan) && u.plan.every(p => Array.isArray(p) && p.length === 4 && p.every(finite) && p[0] >= 0 && p[1] <= duration && p[1] > p[0]), 'declared segments');
|
|
51
|
+
if (u.placement !== undefined) {
|
|
52
|
+
const p = u.placement;
|
|
53
|
+
requireValue(object(p) && ['site', 'connection', 'curated', 'aggregate', 'interconnector', 'unplaced'].includes(String(p.method)), 'placement method');
|
|
54
|
+
const located = ['site', 'connection', 'curated'].includes(String(p.method));
|
|
55
|
+
requireValue(located ? finite(p.latitude) && Math.abs(p.latitude) <= 90 && finite(p.longitude) && Math.abs(p.longitude) <= 180 : p.latitude === undefined && p.longitude === undefined, 'placement coordinates');
|
|
56
|
+
if (p.method === 'curated')
|
|
57
|
+
requireValue(nonempty(p.source), 'curated citation');
|
|
58
|
+
}
|
|
59
|
+
if (u.evidence !== undefined) {
|
|
60
|
+
const e = u.evidence;
|
|
61
|
+
requireValue(object(e) && typeof e.path === 'string' && /^evidence\/[a-f0-9]{64}\.json$/.test(e.path) && hash(e.sha256) && Number.isSafeInteger(e.bytes) && Number(e.bytes) > 0, 'evidence artifact');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
export function powerPeriod(day, seconds) {
|
|
67
|
+
return Number.isFinite(seconds) && seconds >= 0 && seconds < day.demandMw.length * day.periodSeconds ? Math.floor(seconds / day.periodSeconds) : null;
|
|
68
|
+
}
|
|
69
|
+
/** Return null for a gap or ambiguous overlapping declarations, never infer a plan. */
|
|
70
|
+
export function plannedPower(unit, seconds) {
|
|
71
|
+
const segments = unit.plan.filter(p => seconds >= p[0] && seconds < p[1]);
|
|
72
|
+
if (segments.length !== 1)
|
|
73
|
+
return null;
|
|
74
|
+
const [start, end, a, b] = segments[0];
|
|
75
|
+
return a + (b - a) * (seconds - start) / (end - start);
|
|
76
|
+
}
|
|
77
|
+
export function unitPower(day, unit, seconds) {
|
|
78
|
+
const period = powerPeriod(day, seconds), mwh = period === null ? null : unit.settledMwh[period];
|
|
79
|
+
return { period, settledMwh: mwh, averageMw: mwh === null ? null : mwh * 3600 / day.periodSeconds, plannedMw: period === null ? null : plannedPower(unit, seconds), run: period === null ? null : unit.runs[period] };
|
|
80
|
+
}
|
|
81
|
+
/** Membership is supplied by the work; report missing contributors alongside partial sums. */
|
|
82
|
+
export function sumPower(day, units, seconds) {
|
|
83
|
+
let averageMw = 0, available = 0;
|
|
84
|
+
for (const unit of units) {
|
|
85
|
+
const value = unitPower(day, unit, seconds).averageMw;
|
|
86
|
+
if (value !== null) {
|
|
87
|
+
averageMw += value;
|
|
88
|
+
available++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { averageMw: available ? averageMw : null, available, missing: units.length - available };
|
|
92
|
+
}
|
|
93
|
+
/** Last national sample, valid for at most five minutes and never outside this day. */
|
|
94
|
+
export function fuelMixAt(day, seconds) {
|
|
95
|
+
if (powerPeriod(day, seconds) === null)
|
|
96
|
+
return null;
|
|
97
|
+
let sample;
|
|
98
|
+
for (let i = day.fuelMix.length - 1; i >= 0; i--)
|
|
99
|
+
if (day.fuelMix[i].time <= seconds) {
|
|
100
|
+
sample = day.fuelMix[i];
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
return sample && seconds - sample.time < 300 ? sample : null;
|
|
104
|
+
}
|