@motionstudies/core 0.1.0-alpha.20 → 0.1.0-alpha.22

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
@@ -255,3 +255,13 @@ Render support as opacity or reach, not as confidence in the underlying measurem
255
255
  `@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.
256
256
 
257
257
  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.
258
+
259
+ ## Recorded power days
260
+
261
+ `@motionstudies/core/domain/power-day` exports `readPowerDay`, `powerPeriod`, `unitPower`, `plannedPower`, `sumPower` and `fuelMixAt`, plus their data types. A complete civil day declares its timezone, UTC bounds, half-hour intervals, MWh settlement quantities and MW plans/aggregates. Missing values remain null; signed quantities remain signed. Interval energy becomes average power only through the explicit interval duration. Declarations are interpolated only within one unambiguous segment; fuel samples expire after five minutes. Callers choose aggregate membership and receive a missing-contributor count. The reader validates source links, settlement run labels, identities, placement coordinates and safe evidence-artifact paths without fetching data. Source adapters, placement policy and the composition stay outside core. See [power ownership](../docs/POWER.md#ownership-and-handoff).
262
+
263
+ ## Activity timeline
264
+
265
+ `@motionstudies/core/timeline` supplies validated interval geometry, lookup, time mapping and service-time formatting. `@motionstudies/web/components/StudyTimeline` renders a controlled activity chart and scrubber; import `@motionstudies/web/study-timeline.css`. Pass `bins: {start, end, value}[]`, the time window, current `time` and `onSeek`. A `null` value means missing evidence; zero is a measured zero. `variant="bars"` (default) or `"line"` shares the same time axis. `maximum` optionally fixes the scale for comparisons. Playback and loading remain consumer-owned. See [the full contract](https://github.com/emmettl/motionstudies/blob/main/docs/STUDY-TIMELINE.md) and the Timeline lab specimen.
266
+
267
+ Canvas editions can import `PausedVehicleFrame` from `@motionstudies/core/render-frame` without loading Three.js. The existing Three.js entry point re-exports the same class. Record only a frame actually submitted; change its visibility revision (or replace the tracker) when data, projection, size or selection changes. Time changes invalidate even while paused.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/core",
3
- "version": "0.1.0-alpha.20",
3
+ "version": "0.1.0-alpha.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Transport contracts and motion primitives for Motion Studies.",
@@ -204,6 +204,21 @@
204
204
  "types": "./domain/daylight.d.ts",
205
205
  "import": "./domain/daylight.js",
206
206
  "default": "./domain/daylight.js"
207
+ },
208
+ "./domain/power-day": {
209
+ "types": "./domain/power-day.d.ts",
210
+ "import": "./domain/power-day.js",
211
+ "default": "./domain/power-day.js"
212
+ },
213
+ "./timeline": {
214
+ "types": "./timeline.d.ts",
215
+ "import": "./timeline.js",
216
+ "default": "./timeline.js"
217
+ },
218
+ "./render-frame": {
219
+ "types": "./render-frame.d.ts",
220
+ "import": "./render-frame.js",
221
+ "default": "./render-frame.js"
207
222
  }
208
223
  },
209
224
  "files": [
@@ -0,0 +1,8 @@
1
+ /** Track the last submitted buffers, not the last animation callback. Create a
2
+ * fresh tracker when geometry, projection, data or selection inputs change. */
3
+ export declare class PausedVehicleFrame {
4
+ private time;
5
+ private visibility;
6
+ needsUpdate(isPlaying: boolean, time: number, visibility: number): boolean;
7
+ record(time: number, visibility: number): void;
8
+ }
@@ -0,0 +1,13 @@
1
+ /** Track the last submitted buffers, not the last animation callback. Create a
2
+ * fresh tracker when geometry, projection, data or selection inputs change. */
3
+ export class PausedVehicleFrame {
4
+ time = NaN;
5
+ visibility = NaN;
6
+ needsUpdate(isPlaying, time, visibility) {
7
+ return isPlaying || this.time !== time || this.visibility !== visibility;
8
+ }
9
+ record(time, visibility) {
10
+ this.time = time;
11
+ this.visibility = visibility;
12
+ }
13
+ }
package/timeline.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** A measured interval. Null means missing evidence; zero is a measured zero. */
2
+ export interface TimelineBin {
3
+ start: number;
4
+ end: number;
5
+ value: number | null;
6
+ }
7
+ export interface TimelineWindow {
8
+ start: number;
9
+ end: number;
10
+ }
11
+ export interface TimelineBar extends TimelineBin {
12
+ x: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+ export interface TimelineModel {
17
+ window: TimelineWindow;
18
+ maximum: number;
19
+ bars: TimelineBar[];
20
+ lines: {
21
+ x: number;
22
+ y: number;
23
+ }[][];
24
+ }
25
+ export declare function timelinePosition(time: number, window: TimelineWindow): number;
26
+ export declare function timelineTime(position: number, window: TimelineWindow, step?: number): number;
27
+ /** Build chart geometry once per series, independently of playback and rendering. */
28
+ export declare function createTimelineModel(bins: readonly TimelineBin[], window: TimelineWindow, maximum?: number): TimelineModel;
29
+ export declare function timelineBinAt(model: TimelineModel, time: number): TimelineBar | undefined;
30
+ /** Service time, including 24:00 and times beyond midnight; no timezone inference. */
31
+ export declare function formatTimelineTime(seconds: number): string;
package/timeline.js ADDED
@@ -0,0 +1,68 @@
1
+ function checkWindow(window) {
2
+ if (!Number.isFinite(window.start) || !Number.isFinite(window.end) || window.end <= window.start)
3
+ throw new Error('Timeline needs a finite, increasing window');
4
+ }
5
+ export function timelinePosition(time, window) {
6
+ checkWindow(window);
7
+ return Number.isFinite(time) ? Math.max(0, Math.min(1, (time - window.start) / (window.end - window.start))) : 0;
8
+ }
9
+ export function timelineTime(position, window, step = 1) {
10
+ checkWindow(window);
11
+ if (!Number.isFinite(step) || step <= 0)
12
+ throw new Error('Timeline step must be positive');
13
+ const fraction = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
14
+ return Math.min(window.end, window.start + Math.round(fraction * (window.end - window.start) / step) * step);
15
+ }
16
+ /** Build chart geometry once per series, independently of playback and rendering. */
17
+ export function createTimelineModel(bins, window, maximum) {
18
+ checkWindow(window);
19
+ if (maximum !== undefined && (!Number.isFinite(maximum) || maximum < 0))
20
+ throw new Error('Timeline maximum must be non-negative');
21
+ const sorted = [...bins].sort((a, b) => a.start - b.start);
22
+ let previous = -Infinity;
23
+ for (const bin of sorted) {
24
+ if (!Number.isFinite(bin.start) || !Number.isFinite(bin.end) || bin.end <= bin.start || bin.start < previous)
25
+ throw new Error('Timeline bins must be finite, increasing and non-overlapping');
26
+ if (bin.value !== null && (!Number.isFinite(bin.value) || bin.value < 0))
27
+ throw new Error('Timeline values must be non-negative or null');
28
+ previous = bin.end;
29
+ }
30
+ const visible = sorted.filter(bin => bin.end > window.start && bin.start < window.end);
31
+ const peak = maximum ?? visible.reduce((value, bin) => Math.max(value, bin.value ?? 0), 0);
32
+ const bars = visible.map(bin => {
33
+ const start = Math.max(window.start, bin.start), end = Math.min(window.end, bin.end);
34
+ return { ...bin, start, end, x: timelinePosition(start, window), width: (end - start) / (window.end - window.start), height: peak > 0 && bin.value !== null ? Math.min(1, bin.value / peak) : 0 };
35
+ });
36
+ const lines = [];
37
+ let line = [], lastEnd = -Infinity;
38
+ for (const bar of bars) {
39
+ if (bar.value === null || bar.start !== lastEnd) {
40
+ if (line.length)
41
+ lines.push(line);
42
+ line = [];
43
+ }
44
+ if (bar.value !== null)
45
+ line.push({ x: bar.x + bar.width / 2, y: 1 - bar.height });
46
+ lastEnd = bar.end;
47
+ }
48
+ if (line.length)
49
+ lines.push(line);
50
+ return { window: { ...window }, maximum: peak, bars, lines };
51
+ }
52
+ export function timelineBinAt(model, time) {
53
+ let lo = 0, hi = model.bars.length;
54
+ while (lo < hi) {
55
+ const mid = (lo + hi) >>> 1;
56
+ if (model.bars[mid].start <= time)
57
+ lo = mid + 1;
58
+ else
59
+ hi = mid;
60
+ }
61
+ const bin = model.bars[lo - 1];
62
+ return bin && time < bin.end ? bin : undefined;
63
+ }
64
+ /** Service time, including 24:00 and times beyond midnight; no timezone inference. */
65
+ export function formatTimelineTime(seconds) {
66
+ const minutes = Math.floor(Math.max(0, Number.isFinite(seconds) ? seconds : 0) / 60);
67
+ return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
68
+ }