@motionstudies/data 0.1.0-alpha.14 → 0.1.0-alpha.16

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
@@ -229,3 +229,27 @@ For timetable-backed vehicle selections, `NetworkVehicleHeroCard` from `@motions
229
229
  ### Railway preparation
230
230
 
231
231
  `@motionstudies/data/rail-wtt`, `rail-journeys`, `rail-public-calls`, `rail-routing` and `rail-geometry` expose typed offline railway preparation. XLSX reading, dated source-column audits, UID/date joins, public-call anchors and connected OSM paths preserve source evidence. Editions supply regional identities, calendar exceptions and clipping policy. Node 24+ and `unzip` are required; no Python runtime is shipped. See `docs/SHARED-RAIL-READERS.md` in the source repository for calendar limits and parity evidence.
232
+
233
+ `mountMotionStudy({ id, theme }, application)` requires presentation identity only; a full `MotionStudyEdition` remains accepted. Applications own their data catalogue and loading lifecycle.
234
+
235
+ For independent airport infrastructure, `mapStyle.airports.visible` hides the mounted group without rebuilding GPU resources, and `showLabels` controls its labels separately. Both default to `true`. Set these from the edition's layout and label controls; airport abbreviations equal to their IATA code are rendered once.
236
+
237
+ ## Compression at rest in the source store
238
+
239
+ `capture` and `importCapture` accept `compression: 'gzip'`. The object is stored as `objects/<sha256>.gz` when gzip saves at least ten percent, otherwise raw; the record keeps the raw content hash and byte count as identity and adds `storedBytes` and `encoding`. `readCapture` and the new `readObject(store, hash, expectedBytes)` follow either form, verify the raw hash after expansion, and refuse output beyond the recorded size. `compactObjects(store, { minimumSaving })` compresses existing raw objects in place, verifying each before removing the raw file; records are not rewritten, since reads do not depend on them. Already-compressed sources such as ZIP archives stay raw. Use `storedBytes` for storage accounting and `bytes` for provenance.
240
+
241
+ ## Estimated fields, gridded series, field paths and daylight
242
+
243
+ These transport-neutral modules came from Zugunruhe's radar-field studies and serve any work that renders a continuous surface between scattered instruments, a gridded weather product, illustrative flow, or the sun's position: modelled electricity demand between substations, rain radar, model wind, or a terminator crossing a national field. They are pure functions with no dependencies. See [the shared-field record](../docs/SHARED-FIELDS.md) for provenance and adoption.
244
+
245
+ `@motionstudies/core/domain/supported-field` estimates values between stations. `fieldNeighbours` weights stations by a Gaussian kernel with a smooth taper to a finite reach; `estimateField` blends station samples and reports `support`, a fade from the nearest station that supplied a value, and `vectorSupport` for the nearest complete vector. All kilometre scales are `FieldSupportOptions`; `DEFAULT_FIELD_SUPPORT` retains Zugunruhe's 90 km scale, 240 km reach and 120–240 km support fade. Null station values are excluded rather than treated as zero; vector components blend as east/north components, never as bearings. `createFieldGrid` precomputes neighbours for a regular grid whose rows run south to north, `sampleFieldGrid` packs value, east, north and support per cell and level with NaN for unavailable channels, and `sampleFieldGridAt` reads it bilinearly, returning null beside any missing corner. The `movement` flag withholds cells without a complete vector and limits their support to the nearest vector, for path integration.
246
+
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
+
249
+ `@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
+
251
+ `@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.
252
+
253
+ `@motionstudies/core/domain/daylight` computes `solarPosition` (altitude and azimuth in degrees, azimuth clockwise from north) from the Astronomy Answers low-precision algorithm, matching SunCalc to a thousandth of a degree without a dependency. `daylightAt` adds a unit `direction` towards the sun in a scene frame with +x east, +y up and +z south, and smooth `twilight`, `warmth` and `daylight` envelopes from `DaylightEnvelopes` (defaults: twilight −18° to −1°, warmth rising −12° to −1° and falling 3° to 15°, daylight −1° to 12°). The envelopes are artistic responses to sun altitude and encode no weather or sky observation. Sample several longitudes to place a terminator across a national field.
254
+
255
+ The lab's **Fields** specimen renders a synthetic archipelago of seven instruments with a mid-study gap and one vectorless instrument, an invented cover series as haze, integrated trails, and a terminator crossing with the study clock. Brightness is the estimate and opacity is support; clicking reads the estimate, cover and sun altitude at a place.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/data",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Offline data preparation primitives for Motion Studies.",
@@ -99,7 +99,7 @@
99
99
  ],
100
100
  "sideEffects": false,
101
101
  "dependencies": {
102
- "@motionstudies/core": "0.1.0-alpha.14",
102
+ "@motionstudies/core": "0.1.0-alpha.16",
103
103
  "fast-xml-parser": "5.11.1"
104
104
  }
105
105
  }
@@ -1,13 +1,15 @@
1
1
  import type {Buffer} from 'node:buffer'
2
2
  export interface CaptureSource {id:string;publisher:string;kind:string;documentation:string;license:string;attribution?:string;origins:readonly string[]}
3
- export interface CaptureRecord {schemaVersion:1;source:Omit<CaptureSource,'origins'>;url:string;sha256:string;bytes:number;retrievedAt:string|null;importedAt?:string;acquisition:'http'|'local-import';validation:string;httpStatus?:number;contentType?:string|null;lastModified?:string|null;etag?:string|null}
3
+ export interface CaptureRecord {schemaVersion:1;source:Omit<CaptureSource,'origins'>;url:string;sha256:string;bytes:number;storedBytes?:number;encoding?:'gzip'|null;retrievedAt:string|null;importedAt?:string;acquisition:'http'|'local-import';validation:string;httpStatus?:number;contentType?:string|null;lastModified?:string|null;etag?:string|null}
4
4
  export interface CaptureResult {bytes:Buffer;record:CaptureRecord;cached:boolean}
5
5
  export type CaptureValidator=(bytes:Buffer)=>unknown|Promise<unknown>
6
- export interface CaptureOptions {store:string;source:CaptureSource;url:string;offline?:boolean;refresh?:boolean;maxBytes?:number;timeoutMs?:number;retries?:number;validate?:CaptureValidator;fetchImpl?:typeof fetch;userAgent?:string}
6
+ export interface CaptureOptions {store:string;source:CaptureSource;url:string;offline?:boolean;refresh?:boolean;maxBytes?:number;timeoutMs?:number;retries?:number;validate?:CaptureValidator;fetchImpl?:typeof fetch;userAgent?:string;compression?:'gzip'}
7
7
  export function sha256(bytes:string|Uint8Array):string
8
8
  export function publicSourceUrl(source:CaptureSource,value:string):string
9
9
  export function atomicJson(path:string,value:unknown):Promise<void>
10
10
  export function readCapture(store:string,source:CaptureSource,url:string,validate?:CaptureValidator):Promise<CaptureResult>
11
- export function importCapture(options:Pick<CaptureOptions,'store'|'source'|'url'|'maxBytes'|'validate'>&{input:string}):Promise<CaptureResult>
11
+ export function importCapture(options:Pick<CaptureOptions,'store'|'source'|'url'|'maxBytes'|'validate'|'compression'>&{input:string}):Promise<CaptureResult>
12
+ export function readObject(store:string,hash:string,expectedBytes?:number):Promise<Buffer>
13
+ export function compactObjects(store:string,options?:{minimumSaving?:number}):Promise<{examined:number;compressed:number;kept:number;rawBytes:number;storedBytes:number}>
12
14
  export function capture(options:CaptureOptions):Promise<CaptureResult>
13
15
  export function jsonDocument(bytes:Buffer):Record<string,unknown>|unknown[]
package/source-store.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
- import { mkdir, readFile, writeFile, rename, rm, stat } from 'node:fs/promises'
2
+ import { mkdir, readFile, writeFile, rename, rm, stat, readdir, unlink } from 'node:fs/promises'
3
3
  import { join, dirname } from 'node:path'
4
4
  import { setTimeout as delay } from 'node:timers/promises'
5
+ import { gzipSync, gunzipSync } from 'node:zlib'
5
6
 
6
7
  export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
7
8
 
@@ -35,37 +36,69 @@ function requestKey(source, url) { return sha256(`${source.id}\n${url}`) }
35
36
  export async function readCapture(store, source, url, validate = () => {}) {
36
37
  const record = JSON.parse(await readFile(join(store, 'requests', `${requestKey(source, url)}.json`), 'utf8'))
37
38
  if (record.source.id !== source.id || record.url !== url || !/^[a-f0-9]{64}$/.test(record.sha256)) throw new Error('Capture identity mismatch')
38
- const bytes = await readFile(join(store, 'objects', record.sha256))
39
+ const bytes = await readObject(store, record.sha256, record.bytes)
39
40
  if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) throw new Error('Capture integrity check failed')
40
41
  await validate(bytes)
41
42
  return { bytes, record, cached: true }
42
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
+ }
43
76
 
44
- async function persist(store, source, url, bytes, acquisition, response = {}) {
77
+ async function persist(store, source, url, bytes, acquisition, response = {}, compression) {
45
78
  const hash = sha256(bytes)
79
+ const stored = await writeObject(store, hash, bytes, compression)
46
80
  const record = {
47
81
  schemaVersion: 1, source: { id: source.id, publisher: source.publisher, kind: source.kind, documentation: source.documentation, license: source.license, ...(source.attribution ? { attribution: source.attribution } : {}) },
48
- url, sha256: hash, bytes: bytes.length,
82
+ url, sha256: hash, bytes: bytes.length, storedBytes: stored.storedBytes, encoding: stored.encoding,
49
83
  ...(acquisition === 'http' ? { retrievedAt: new Date().toISOString() } : { retrievedAt: null, importedAt: new Date().toISOString() }),
50
84
  acquisition, validation: 'raw-capture; see adapter audit for fitness', ...response,
51
85
  }
52
- await atomicWrite(join(store, 'objects', hash), bytes)
53
86
  await atomicJson(join(store, 'records', `${randomUUID()}.json`), record)
54
87
  await atomicJson(join(store, 'requests', `${requestKey(source, url)}.json`), record)
55
88
  return { bytes, record, cached: false }
56
89
  }
57
90
 
58
- export async function importCapture({ store, source, url, input, maxBytes = 512 * 1024 * 1024, validate = () => {} }) {
91
+ export async function importCapture({ store, source, url, input, maxBytes = 512 * 1024 * 1024, validate = () => {}, compression }) {
59
92
  url = publicSourceUrl(source, url)
60
93
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Expected a positive byte limit')
61
94
  if ((await stat(input)).size > maxBytes) throw new Error('Source file exceeds byte limit')
62
95
  const bytes = await readFile(input)
63
96
  if (!bytes.length || bytes.length > maxBytes) throw new Error('Source file is empty or exceeds byte limit')
64
97
  await validate(bytes)
65
- return persist(store, source, url, bytes, 'local-import')
98
+ return persist(store, source, url, bytes, 'local-import', {}, compression)
66
99
  }
67
100
 
68
- export async function capture({ store, source, url, offline = false, refresh = false, maxBytes = 32 * 1024 * 1024, timeoutMs = 20000, retries = 2, validate = () => {}, fetchImpl = fetch, userAgent = 'Motion Studies source capture' }) {
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 }) {
69
102
  url = publicSourceUrl(source, url)
70
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')
71
104
  if (offline && refresh) throw new Error('Offline and refresh cannot be combined')
@@ -112,7 +145,7 @@ export async function capture({ store, source, url, offline = false, refresh = f
112
145
  return await persist(store, source, url, bytes, 'http', {
113
146
  httpStatus: response.status, contentType: response.headers.get('content-type'),
114
147
  lastModified: response.headers.get('last-modified'), etag: response.headers.get('etag'),
115
- })
148
+ }, compression)
116
149
  } catch (error) {
117
150
  if (!retryable || attempt === retries) throw error
118
151
  await delay(250 * 2 ** attempt)