@motionstudies/core 0.1.0-alpha.20 → 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 CHANGED
@@ -255,3 +255,7 @@ 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).
@@ -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.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Transport contracts and motion primitives for Motion Studies.",
@@ -204,6 +204,11 @@
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"
207
212
  }
208
213
  },
209
214
  "files": [