@motionstudies/core 0.1.0-alpha.16 → 0.1.0-alpha.18

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 CHANGED
@@ -246,6 +246,8 @@ These transport-neutral modules came from Zugunruhe's radar-field studies and se
246
246
 
247
247
  Render support as opacity or reach, not as confidence in the underlying measurement: it says how far the nearest evidence is, nothing about that evidence's accuracy. The kernel scale is a claim about resolution and belongs in the study's provenance.
248
248
 
249
+ `@motionstudies/core/domain/published-day` reads a published day of the national layer as the recording host compiles it: `openPublishedDay` takes a transport (`fetchTransport` over a base URL, or any pair of text and bytes readers) and returns the manifest and index up front, then slices, members and packs on demand and cached per file; `partFor` resolves an operator and vehicle to the pack part that holds it; `sliceNameAt` and `cellOf` reproduce the compiler's slice and cell rules exactly, so a position lands in the cell the compiler put it in; `positionAt` interpolates along a track only inside a tolerated gap, and `journeyAt` finds the run in force at an instant. Packs are gunzipped with the platform's `DecompressionStream` unless an inflater is supplied.
250
+
249
251
  `@motionstudies/core/domain/gridded-series` reads a regular latitude/longitude grid of variables at listed instants. `bracketTime` finds the adjacent frames; `sampleGriddedSeries` interpolates bilinearly in space and linearly in time; `meanGriddedSeries` returns an area-weighted mean, with an optional valid range. A missing sample with any weight makes the reading unavailable instead of bridging the gap. Values keep the source's units; the consumer converts percentages or applies its own scale.
250
252
 
251
253
  `@motionstudies/core/domain/field-paths` integrates illustrative paths through an estimated velocity field. `advanceFieldPath` takes an explicit midpoint step in metres per second, with `secondsPerStep` converting one unit of study time, and refuses to start from, pass through or land on unsupported field. `mixFieldMovement` blends two readings; `visibleTrailSegments` returns the last `trail` steps of a track at a study time with a fade at either end, identical when scrubbing back. Paths are neither observed trajectories nor individual movers and must be labelled as illustration.
@@ -0,0 +1,124 @@
1
+ /** A published day of the national layer, as compiled by the recording host: the sea (one file of 0.1° cells per
2
+ * five-minute slice), the shoal (one file per slice from cell to member vehicles), and the fish (one gzipped pack
3
+ * per operator per day, split into parts by sample budget), with an index from operator and vehicle to part and a
4
+ * manifest of every file. Everything is static; opening one vehicle fetches one pack. Readers supply the transport
5
+ * and, where the platform lacks DecompressionStream, the inflater; nothing here touches the network itself.
6
+ */
7
+ export declare const SLICE_SECONDS = 300, CELL_DEGREES = 0.1;
8
+ export interface PublishedDayManifest {
9
+ readonly date: string;
10
+ readonly compiledAt: string;
11
+ readonly hours: {
12
+ readonly present: readonly string[];
13
+ readonly missing: readonly string[];
14
+ };
15
+ readonly stats: {
16
+ readonly samples: number;
17
+ readonly vehicles: number;
18
+ readonly operators: number;
19
+ readonly slices: number;
20
+ readonly excludedRejected: number;
21
+ readonly excludedOutsideDay: number;
22
+ };
23
+ readonly slices: number;
24
+ readonly files: number;
25
+ readonly bytes: number;
26
+ readonly list: readonly {
27
+ readonly path: string;
28
+ readonly bytes: number;
29
+ }[];
30
+ }
31
+ /** One cell of a slice: centre-less floor coordinates, samples (which sum across cells) and distinct vehicles (which do not). */
32
+ export type SliceCell = readonly [lon: number, lat: number, samples: number, vehicles: number];
33
+ export interface SliceFile {
34
+ readonly slice: string;
35
+ readonly cells: readonly SliceCell[];
36
+ }
37
+ /** Members of each cell as operator index into the day index and vehicle reference. */
38
+ export type Member = readonly [operatorIndex: number, vehicleRef: string];
39
+ export interface MembersFile {
40
+ readonly slice: string;
41
+ readonly cells: Readonly<Record<string, readonly Member[]>>;
42
+ }
43
+ export interface PackPart {
44
+ readonly file: string;
45
+ readonly bytes: number;
46
+ readonly vehicles: number;
47
+ readonly samples: number;
48
+ readonly refs?: readonly string[];
49
+ }
50
+ export interface OperatorEntry {
51
+ readonly ref: string;
52
+ readonly vehicles: number;
53
+ readonly samples: number;
54
+ readonly parts: readonly PackPart[];
55
+ }
56
+ export interface DayIndex {
57
+ readonly date: string;
58
+ readonly operators: readonly OperatorEntry[];
59
+ }
60
+ export type TrackPoint = readonly [epochSeconds: number, lon: number, lat: number];
61
+ export type JourneyRun = readonly [epochSeconds: number, lineRef: string | null, journeyRef: string | null];
62
+ export interface VehicleDay {
63
+ readonly track: readonly TrackPoint[];
64
+ readonly journeys: readonly JourneyRun[];
65
+ }
66
+ export interface Pack {
67
+ readonly date: string;
68
+ readonly operator: string;
69
+ readonly part: number;
70
+ readonly parts: number;
71
+ readonly vehicles: Readonly<Record<string, VehicleDay>>;
72
+ }
73
+ export interface DayTransport {
74
+ /** Text of a file under the day's base, by its relative path. */
75
+ readonly text: (path: string) => Promise<string>;
76
+ /** Bytes of a file under the day's base. */
77
+ readonly bytes: (path: string) => Promise<Uint8Array>;
78
+ /** Gunzip; defaults to the platform's DecompressionStream. */
79
+ readonly inflate?: (bytes: Uint8Array) => Promise<Uint8Array>;
80
+ }
81
+ /** The slice file name for an instant: HH-MM of the five-minute slice containing it, UTC. */
82
+ export declare function sliceNameAt(instant: string | number): string;
83
+ /** The ISO start of a named slice on a date. */
84
+ export declare function sliceStart(date: string, name: string): string;
85
+ /** The cell a position falls in, by the compiler's own rule: floor of the coordinate over the cell size, in double
86
+ * arithmetic, printed to one decimal. The rule is reproduced exactly rather than rounded, so a position near a
87
+ * cell edge lands where the compiler put it. */
88
+ export declare function cellOf(lon: number, lat: number): readonly [number, number];
89
+ export declare function cellKey(cellLon: number, cellLat: number): string;
90
+ /** The pack part holding a vehicle: the only part, or the part whose reference list names it. */
91
+ export declare function partFor(index: DayIndex, operatorRef: string, vehicleRef: string): PackPart | null;
92
+ /** Position along a track at an instant: linear between the samples around it, null outside the track or across a
93
+ * gap longer than the tolerance, so a vehicle that stopped reporting does not glide. */
94
+ export declare function positionAt(track: readonly TrackPoint[], epochSeconds: number, maxGapSeconds?: number): readonly [lon: number, lat: number] | null;
95
+ /** The line and journey a vehicle was on at an instant, from its runs. */
96
+ export declare function journeyAt(journeys: readonly JourneyRun[], epochSeconds: number): JourneyRun | null;
97
+ /** Minimal shapes of the platform pieces used, so the module compiles without DOM typings. */
98
+ interface ByteSource {
99
+ arrayBuffer(): Promise<ArrayBuffer>;
100
+ }
101
+ interface ResponseLike extends ByteSource {
102
+ readonly ok: boolean;
103
+ readonly status: number;
104
+ text(): Promise<string>;
105
+ }
106
+ export type FetchLike = (url: string) => Promise<ResponseLike>;
107
+ export declare function inflateGzip(bytes: Uint8Array): Promise<Uint8Array>;
108
+ /** Packs are stored gzipped, but a server that declares the encoding hands the browser the decoded bytes; either form is accepted by its first two bytes. */
109
+ export declare function inflateIfGzipped(bytes: Uint8Array, inflate: (bytes: Uint8Array) => Promise<Uint8Array>): Promise<Uint8Array>;
110
+ export interface PublishedDay {
111
+ readonly manifest: PublishedDayManifest;
112
+ readonly index: DayIndex;
113
+ readonly slice: (name: string) => Promise<SliceFile>;
114
+ readonly members: (name: string) => Promise<MembersFile>;
115
+ readonly pack: (operatorRef: string, vehicleRef: string) => Promise<Pack>;
116
+ readonly vehicle: (operatorRef: string, vehicleRef: string) => Promise<VehicleDay | null>;
117
+ /** Slice names present, from the manifest's file list, in order. */
118
+ readonly sliceNames: readonly string[];
119
+ }
120
+ /** Open a published day: manifest and index up front, everything else on demand and cached per file. */
121
+ export declare function openPublishedDay(transport: DayTransport): Promise<PublishedDay>;
122
+ /** A transport over fetch for a day served under a base URL. */
123
+ export declare function fetchTransport(base: string, fetchImpl?: FetchLike | undefined): DayTransport;
124
+ export {};
@@ -0,0 +1,114 @@
1
+ /** A published day of the national layer, as compiled by the recording host: the sea (one file of 0.1° cells per
2
+ * five-minute slice), the shoal (one file per slice from cell to member vehicles), and the fish (one gzipped pack
3
+ * per operator per day, split into parts by sample budget), with an index from operator and vehicle to part and a
4
+ * manifest of every file. Everything is static; opening one vehicle fetches one pack. Readers supply the transport
5
+ * and, where the platform lacks DecompressionStream, the inflater; nothing here touches the network itself.
6
+ */
7
+ export const SLICE_SECONDS = 300, CELL_DEGREES = 0.1;
8
+ /** The slice file name for an instant: HH-MM of the five-minute slice containing it, UTC. */
9
+ export function sliceNameAt(instant) {
10
+ const t = typeof instant === 'number' ? instant : Date.parse(instant);
11
+ if (!Number.isFinite(t))
12
+ throw new Error('Invalid instant');
13
+ const s = Math.floor(t / 1000 / SLICE_SECONDS) * SLICE_SECONDS, d = new Date(s * 1000);
14
+ return `${String(d.getUTCHours()).padStart(2, '0')}-${String(d.getUTCMinutes()).padStart(2, '0')}`;
15
+ }
16
+ /** The ISO start of a named slice on a date. */
17
+ export function sliceStart(date, name) { return `${date}T${name.replace('-', ':')}:00Z`; }
18
+ /** The cell a position falls in, by the compiler's own rule: floor of the coordinate over the cell size, in double
19
+ * arithmetic, printed to one decimal. The rule is reproduced exactly rather than rounded, so a position near a
20
+ * cell edge lands where the compiler put it. */
21
+ export function cellOf(lon, lat) {
22
+ return [Number((Math.floor(lon / CELL_DEGREES) * CELL_DEGREES).toFixed(1)), Number((Math.floor(lat / CELL_DEGREES) * CELL_DEGREES).toFixed(1))];
23
+ }
24
+ export function cellKey(cellLon, cellLat) { return `${cellLon.toFixed(1)},${cellLat.toFixed(1)}`; }
25
+ /** The pack part holding a vehicle: the only part, or the part whose reference list names it. */
26
+ export function partFor(index, operatorRef, vehicleRef) {
27
+ const op = index.operators.find(o => o.ref === operatorRef);
28
+ if (!op)
29
+ return null;
30
+ if (op.parts.length === 1)
31
+ return op.parts[0];
32
+ return op.parts.find(p => p.refs?.includes(vehicleRef)) ?? null;
33
+ }
34
+ /** Position along a track at an instant: linear between the samples around it, null outside the track or across a
35
+ * gap longer than the tolerance, so a vehicle that stopped reporting does not glide. */
36
+ export function positionAt(track, epochSeconds, maxGapSeconds = 180) {
37
+ if (!track.length || epochSeconds < track[0][0] || epochSeconds > track[track.length - 1][0])
38
+ return null;
39
+ let lo = 0, hi = track.length - 1;
40
+ while (hi - lo > 1) {
41
+ const mid = (lo + hi) >> 1;
42
+ if (track[mid][0] <= epochSeconds)
43
+ lo = mid;
44
+ else
45
+ hi = mid;
46
+ }
47
+ const a = track[lo], b = track[hi];
48
+ if (a[0] === epochSeconds || lo === hi)
49
+ return [a[1], a[2]];
50
+ if (b[0] === epochSeconds)
51
+ return [b[1], b[2]];
52
+ if (b[0] - a[0] > maxGapSeconds)
53
+ return null;
54
+ const f = (epochSeconds - a[0]) / (b[0] - a[0]);
55
+ return [a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
56
+ }
57
+ /** The line and journey a vehicle was on at an instant, from its runs. */
58
+ export function journeyAt(journeys, epochSeconds) {
59
+ let found = null;
60
+ for (const run of journeys) {
61
+ if (run[0] <= epochSeconds)
62
+ found = run;
63
+ else
64
+ break;
65
+ }
66
+ return found;
67
+ }
68
+ const platform = globalThis;
69
+ export async function inflateGzip(bytes) {
70
+ if (!platform.DecompressionStream || !platform.Blob || !platform.Response)
71
+ throw new Error('No DecompressionStream on this platform; supply an inflater');
72
+ const stream = new platform.Blob([bytes]).stream().pipeThrough(new platform.DecompressionStream('gzip'));
73
+ return new Uint8Array(await new platform.Response(stream).arrayBuffer());
74
+ }
75
+ /** Packs are stored gzipped, but a server that declares the encoding hands the browser the decoded bytes; either form is accepted by its first two bytes. */
76
+ export async function inflateIfGzipped(bytes, inflate) {
77
+ return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b ? inflate(bytes) : bytes;
78
+ }
79
+ function decodeText(bytes) {
80
+ if (!platform.TextDecoder)
81
+ throw new Error('No TextDecoder on this platform');
82
+ return new platform.TextDecoder().decode(bytes);
83
+ }
84
+ /** Open a published day: manifest and index up front, everything else on demand and cached per file. */
85
+ export async function openPublishedDay(transport) {
86
+ const manifest = JSON.parse(await transport.text('manifest.json'));
87
+ const index = JSON.parse(await transport.text('index.json'));
88
+ if (manifest.date !== index.date)
89
+ throw new Error('Manifest and index disagree on the date');
90
+ const cache = new Map();
91
+ const cached = (path, load) => { let p = cache.get(path); if (!p) {
92
+ p = load();
93
+ cache.set(path, p);
94
+ } return p; };
95
+ const inflate = transport.inflate ?? inflateGzip;
96
+ const sliceNames = manifest.list.filter(f => f.path.startsWith('slices/')).map(f => f.path.slice(7, -5)).sort();
97
+ const pack = (operatorRef, vehicleRef) => { const part = partFor(index, operatorRef, vehicleRef); if (!part)
98
+ return Promise.reject(new Error(`No pack for ${operatorRef} ${vehicleRef}`)); return cached(part.file, async () => JSON.parse(decodeText(await inflateIfGzipped(await transport.bytes(part.file), inflate)))); };
99
+ return { manifest, index, sliceNames,
100
+ slice: name => cached(`slices/${name}.json`, async () => JSON.parse(await transport.text(`slices/${name}.json`))),
101
+ members: name => cached(`members/${name}.json`, async () => JSON.parse(await transport.text(`members/${name}.json`))),
102
+ pack,
103
+ vehicle: async (operatorRef, vehicleRef) => { if (!partFor(index, operatorRef, vehicleRef))
104
+ return null; return (await pack(operatorRef, vehicleRef)).vehicles[vehicleRef] ?? null; } };
105
+ }
106
+ /** A transport over fetch for a day served under a base URL. */
107
+ export function fetchTransport(base, fetchImpl = platform.fetch) {
108
+ if (!fetchImpl)
109
+ throw new Error('No fetch on this platform; supply one');
110
+ const url = (path) => `${base.replace(/\/$/, '')}/${path}`;
111
+ const get = async (path) => { const r = await fetchImpl(url(path)); if (!r.ok)
112
+ throw new Error(`${path} returned HTTP ${r.status}`); return r; };
113
+ return { text: async (path) => (await get(path)).text(), bytes: async (path) => new Uint8Array(await (await get(path)).arrayBuffer()) };
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/core",
3
- "version": "0.1.0-alpha.16",
3
+ "version": "0.1.0-alpha.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Transport contracts and motion primitives for Motion Studies.",
@@ -190,6 +190,11 @@
190
190
  "import": "./domain/gridded-series.js",
191
191
  "default": "./domain/gridded-series.js"
192
192
  },
193
+ "./domain/published-day": {
194
+ "types": "./domain/published-day.d.ts",
195
+ "import": "./domain/published-day.js",
196
+ "default": "./domain/published-day.js"
197
+ },
193
198
  "./domain/field-paths": {
194
199
  "types": "./domain/field-paths.d.ts",
195
200
  "import": "./domain/field-paths.js",