@isparling/engram-coach 0.1.0 → 0.2.0

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.
Files changed (51) hide show
  1. package/README.md +85 -18
  2. package/SETUP.md +559 -0
  3. package/SKILL_PACK.md +75 -0
  4. package/analyses/catalog.md +257 -0
  5. package/analysis-tools/hrv-trend.ts +592 -0
  6. package/analysis-tools/migrate-structured-capture.ts +234 -0
  7. package/analysis-tools/race-context.ts +96 -0
  8. package/analysis-tools/stream-analyze.ts +1008 -0
  9. package/analysis-tools/tsb-predict.ts +117 -0
  10. package/capture-handler.ts +301 -0
  11. package/config.json.example +21 -0
  12. package/engram-coach-ambient-capture.ts +336 -0
  13. package/engram-coach-capture-types.ts +185 -0
  14. package/engram-coach-config.ts +268 -0
  15. package/engram-coach-domain.ts +7 -2
  16. package/engram-coach-keys.ts +189 -0
  17. package/engram-coach-materialization.ts +638 -0
  18. package/engram-coach-migration.ts +1078 -0
  19. package/engram-coach-pack.ts +17 -12
  20. package/engram-coach-presentation.ts +10 -1
  21. package/engram-coach-reconciliation.ts +305 -2
  22. package/engram-coach-structured-capture.ts +622 -0
  23. package/package.json +39 -6
  24. package/personas/aggressive-monitoring.md +121 -0
  25. package/personas/aggressive.json +85 -0
  26. package/personas/conservative-monitoring.md +133 -0
  27. package/personas/conservative.json +93 -0
  28. package/personas/polarized-monitoring.md +112 -0
  29. package/personas/polarized.json +72 -0
  30. package/personas/volume-monitoring.md +85 -0
  31. package/personas/volume.json +108 -0
  32. package/shared/retrieval.md +71 -0
  33. package/shared/setup.md +207 -0
  34. package/skills/.gitkeep +0 -0
  35. package/skills/adapt-plan/SKILL.md +263 -0
  36. package/skills/block-review/SKILL.md +275 -0
  37. package/skills/consult/SKILL.md +176 -0
  38. package/skills/intake/SKILL.md +315 -0
  39. package/skills/lactate-analyze/SKILL.md +230 -0
  40. package/skills/lessons-rollup/SKILL.md +196 -0
  41. package/skills/monitoring-rollup/SKILL.md +208 -0
  42. package/skills/race-analysis/SKILL.md +219 -0
  43. package/skills/season-retrospective/SKILL.md +200 -0
  44. package/skills/set-goal/SKILL.md +297 -0
  45. package/templates/base.md +55 -0
  46. package/templates/build-1.md +57 -0
  47. package/templates/build-2.md +62 -0
  48. package/templates/race-report.md +51 -0
  49. package/templates/race-specificity.md +62 -0
  50. package/templates/season-review.md +40 -0
  51. package/engram-coach-extractor.ts +0 -295
@@ -0,0 +1,1008 @@
1
+ import { fileURLToPath } from 'url';
2
+ import { resolve, dirname } from 'path';
3
+ import { readFile } from 'fs/promises';
4
+
5
+ // --- Interfaces ---
6
+
7
+ export interface IntervalsIcuConfig {
8
+ api_key: string;
9
+ athlete_id: string;
10
+ }
11
+
12
+ export interface IntervalBoundary {
13
+ index: number;
14
+ type: string;
15
+ startIndex: number;
16
+ endIndex: number;
17
+ }
18
+
19
+ export interface DecouplingResult {
20
+ half1: { avg_watts: number; avg_hr: number; ef: number };
21
+ half2: { avg_watts: number; avg_hr: number; ef: number };
22
+ decoupling_pct: number;
23
+ }
24
+
25
+ export interface HrRecoveryInterval {
26
+ index: number;
27
+ end_hr: number;
28
+ drop_60s: number | null;
29
+ drop_120s: number | null;
30
+ }
31
+
32
+ export interface HrRecoveryResult {
33
+ intervals: HrRecoveryInterval[];
34
+ trend: 'improving' | 'stable' | 'declining';
35
+ }
36
+
37
+ export interface IntervalCvEntry {
38
+ index: number;
39
+ avg_watts: number;
40
+ cv_pct: number;
41
+ }
42
+
43
+ export interface IntervalCvResult {
44
+ intervals: IntervalCvEntry[];
45
+ }
46
+
47
+ export interface FadeSegment {
48
+ label: string;
49
+ np: number;
50
+ avg_hr: number;
51
+ ef: number;
52
+ duration_sec: number;
53
+ }
54
+
55
+ export interface FadeResult {
56
+ segments: FadeSegment[];
57
+ np_slope_w_per_hr: number;
58
+ ef_slope_per_hr: number;
59
+ largest_drop: { from: string; to: string; np_delta_pct: number } | null;
60
+ }
61
+
62
+ export interface SegmentBoundary {
63
+ start: number;
64
+ end: number;
65
+ label: string;
66
+ }
67
+
68
+ export interface ZoneThreshold {
69
+ zone: string;
70
+ max_pct_ftp: number;
71
+ }
72
+
73
+ export interface ZoneEntry {
74
+ zone: string;
75
+ pct: number;
76
+ seconds: number;
77
+ }
78
+
79
+ export interface TimeInZoneResult {
80
+ zones: ZoneEntry[];
81
+ }
82
+
83
+ export interface NpDistributionResult {
84
+ np: number;
85
+ avg: number;
86
+ vi: number;
87
+ p20: number;
88
+ p50: number;
89
+ p80: number;
90
+ p95: number;
91
+ }
92
+
93
+ export interface LapBoundary {
94
+ n: number;
95
+ type: string;
96
+ startIndex: number;
97
+ endIndex: number;
98
+ }
99
+
100
+ export interface LapEntry {
101
+ n: number;
102
+ sec: number;
103
+ avg_w: number;
104
+ np: number;
105
+ avg_hr: number;
106
+ max_hr: number;
107
+ is_rest: boolean;
108
+ }
109
+
110
+ export interface RegimeBreak {
111
+ after_lap: number;
112
+ delta_w: number;
113
+ note: string;
114
+ }
115
+
116
+ export interface LapTrendsResult {
117
+ laps: LapEntry[];
118
+ power_slope_w_per_lap: number;
119
+ hr_drift_bpm_per_lap: number;
120
+ fastest_lap: { n: number; avg_w: number };
121
+ slowest_lap: { n: number; avg_w: number };
122
+ first_third_avg_w: number;
123
+ middle_third_avg_w: number;
124
+ last_third_avg_w: number;
125
+ regime_breaks: RegimeBreak[];
126
+ }
127
+
128
+ export interface ActivitySummaryForCompare {
129
+ activity_id: string;
130
+ date: string;
131
+ np: number;
132
+ avg_hr: number;
133
+ decoupling_pct: number;
134
+ duration_sec: number;
135
+ }
136
+
137
+ export interface SimComparison {
138
+ sim_activity_id: string;
139
+ sim_date: string;
140
+ np_delta_pct: number;
141
+ hr_delta_bpm: number;
142
+ decoupling_delta_pct: number;
143
+ duration_delta_min: number;
144
+ }
145
+
146
+ export interface SimCompareResult {
147
+ comparisons: SimComparison[];
148
+ }
149
+
150
+ export interface StreamAnalysisOutput {
151
+ activity_id: string;
152
+ duration_sec: number;
153
+ analyses: {
154
+ decoupling?: DecouplingResult;
155
+ hr_recovery?: HrRecoveryResult;
156
+ interval_cv?: IntervalCvResult;
157
+ fade?: FadeResult;
158
+ time_in_zone?: TimeInZoneResult;
159
+ np_distribution?: NpDistributionResult;
160
+ lap_trends?: LapTrendsResult;
161
+ sim_compare?: SimCompareResult;
162
+ };
163
+ errors: Record<string, string>;
164
+ }
165
+
166
+ // --- Helpers ---
167
+
168
+ function round(n: number, decimals: number): number {
169
+ const factor = 10 ** decimals;
170
+ return Math.round(n * factor) / factor;
171
+ }
172
+
173
+ function mean(arr: number[]): number {
174
+ return arr.reduce((sum, v) => sum + v, 0) / arr.length;
175
+ }
176
+
177
+ function stddev(arr: number[]): number {
178
+ const avg = mean(arr);
179
+ const variance = arr.reduce((sum, v) => sum + (v - avg) ** 2, 0) / arr.length;
180
+ return Math.sqrt(variance);
181
+ }
182
+
183
+ export function linearSlope(xs: number[], ys: number[]): number {
184
+ const n = xs.length;
185
+ if (n < 2) return 0;
186
+ const xMean = mean(xs);
187
+ const yMean = mean(ys);
188
+ let num = 0;
189
+ let den = 0;
190
+ for (let i = 0; i < n; i++) {
191
+ num += (xs[i] - xMean) * (ys[i] - yMean);
192
+ den += (xs[i] - xMean) ** 2;
193
+ }
194
+ return den > 0 ? num / den : 0;
195
+ }
196
+
197
+ function stripNulls(
198
+ ...arrays: (number | null)[][]
199
+ ): number[][] {
200
+ const len = arrays[0].length;
201
+ const indices: number[] = [];
202
+ for (let i = 0; i < len; i++) {
203
+ if (arrays.every((arr) => arr[i] !== null && arr[i] !== undefined)) {
204
+ indices.push(i);
205
+ }
206
+ }
207
+ return arrays.map((arr) => indices.map((i) => arr[i] as number));
208
+ }
209
+
210
+ function formatSec(sec: number): string {
211
+ const h = Math.floor(sec / 3600);
212
+ const m = Math.floor((sec % 3600) / 60);
213
+ return `${h}:${m.toString().padStart(2, '0')}`;
214
+ }
215
+
216
+ function normalizedPower(watts: number[]): number {
217
+ if (watts.length < 30) return mean(watts);
218
+ const rolling: number[] = [];
219
+ for (let i = 29; i < watts.length; i++) {
220
+ let sum = 0;
221
+ for (let j = i - 29; j <= i; j++) sum += watts[j];
222
+ rolling.push(sum / 30);
223
+ }
224
+ const fourthPowers = rolling.map((v) => v ** 4);
225
+ return Math.pow(mean(fourthPowers), 0.25);
226
+ }
227
+
228
+ function percentile(sorted: number[], p: number): number {
229
+ if (sorted.length === 0) return 0;
230
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
231
+ return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
232
+ }
233
+
234
+ export function computeNpDistribution(rawWatts: (number | null)[]): NpDistributionResult {
235
+ const watts = rawWatts.filter((v): v is number => v !== null && v !== undefined);
236
+ if (watts.length === 0) {
237
+ throw new Error('No watts data for NP distribution');
238
+ }
239
+ const np = normalizedPower(watts);
240
+ const avg = mean(watts);
241
+ const vi = avg > 0 ? np / avg : 0;
242
+ const sorted = [...watts].sort((a, b) => a - b);
243
+ return {
244
+ np: round(np, 0),
245
+ avg: round(avg, 1),
246
+ vi: round(vi, 3),
247
+ p20: round(percentile(sorted, 20), 0),
248
+ p50: round(percentile(sorted, 50), 0),
249
+ p80: round(percentile(sorted, 80), 0),
250
+ p95: round(percentile(sorted, 95), 0),
251
+ };
252
+ }
253
+
254
+ export function resolveFadeSegments(
255
+ durationSec: number,
256
+ mode: 'default' | number | number[],
257
+ ): SegmentBoundary[] {
258
+ if (Array.isArray(mode)) {
259
+ const result: SegmentBoundary[] = [];
260
+ for (let i = 0; i < mode.length - 1; i++) {
261
+ result.push({
262
+ start: mode[i],
263
+ end: mode[i + 1],
264
+ label: `${formatSec(mode[i])}–${formatSec(mode[i + 1])}`,
265
+ });
266
+ }
267
+ return result;
268
+ }
269
+ let count: number;
270
+ if (mode === 'default') {
271
+ count = durationSec > 6 * 3600 ? Math.ceil(durationSec / 3600) : 4;
272
+ } else {
273
+ count = mode;
274
+ }
275
+ const segLen = Math.floor(durationSec / count);
276
+ const result: SegmentBoundary[] = [];
277
+ for (let i = 0; i < count; i++) {
278
+ const start = i * segLen;
279
+ const end = i === count - 1 ? durationSec : (i + 1) * segLen;
280
+ result.push({ start, end, label: `${formatSec(start)}–${formatSec(end)}` });
281
+ }
282
+ return result;
283
+ }
284
+
285
+ export function computeFade(
286
+ rawWatts: (number | null)[],
287
+ rawHr: (number | null)[],
288
+ segmentation: 'default' | number | number[] = 'default',
289
+ ): FadeResult {
290
+ const [watts, hr] = stripNulls(rawWatts, rawHr);
291
+ const duration = watts.length;
292
+ const boundaries = resolveFadeSegments(duration, segmentation);
293
+ if (boundaries.length === 0 || boundaries.some((b) => b.end - b.start < 60)) {
294
+ throw new Error(
295
+ `Activity too short for fade segmentation (${duration}s, ${boundaries.length} segments)`,
296
+ );
297
+ }
298
+ // Compute unrounded per-segment values
299
+ const raw: { label: string; np: number; avgHr: number; ef: number; durationSec: number; midpointHr: number }[] = [];
300
+ for (const b of boundaries) {
301
+ const segWatts = watts.slice(b.start, b.end);
302
+ const segHr = hr.slice(b.start, b.end);
303
+ if (segWatts.length === 0) continue;
304
+ const np = normalizedPower(segWatts);
305
+ const avgHr = mean(segHr);
306
+ const ef = avgHr > 0 ? np / avgHr : 0;
307
+ raw.push({
308
+ label: b.label,
309
+ np,
310
+ avgHr,
311
+ ef,
312
+ durationSec: segWatts.length,
313
+ midpointHr: (b.start + b.end) / 2 / 3600,
314
+ });
315
+ }
316
+ // Slopes via linear regression over midpoint hours
317
+ const xs = raw.map((r) => r.midpointHr);
318
+ const npSlope = linearSlope(xs, raw.map((r) => r.np));
319
+ const efSlope = linearSlope(xs, raw.map((r) => r.ef));
320
+ // Largest drop tracker — null until a negative delta is observed
321
+ let largestDrop: { from: string; to: string; np_delta_pct: number } | null = null;
322
+ for (let i = 1; i < raw.length; i++) {
323
+ const prev = raw[i - 1];
324
+ const cur = raw[i];
325
+ const deltaPct = prev.np > 0 ? ((cur.np - prev.np) / prev.np) * 100 : 0;
326
+ if (deltaPct < 0 && (largestDrop === null || deltaPct < largestDrop.np_delta_pct)) {
327
+ largestDrop = { from: prev.label, to: cur.label, np_delta_pct: round(deltaPct, 1) };
328
+ }
329
+ }
330
+ // Round at the final return
331
+ const segments: FadeSegment[] = raw.map((r) => ({
332
+ label: r.label,
333
+ np: round(r.np, 0),
334
+ avg_hr: round(r.avgHr, 0),
335
+ ef: round(r.ef, 3),
336
+ duration_sec: r.durationSec,
337
+ }));
338
+ return {
339
+ segments,
340
+ np_slope_w_per_hr: round(npSlope, 1),
341
+ ef_slope_per_hr: round(efSlope, 4),
342
+ largest_drop: largestDrop,
343
+ };
344
+ }
345
+
346
+ export const DEFAULT_ZONE_THRESHOLDS: ZoneThreshold[] = [
347
+ { zone: 'Z1', max_pct_ftp: 0.55 },
348
+ { zone: 'Z2', max_pct_ftp: 0.75 },
349
+ { zone: 'Z3', max_pct_ftp: 0.90 },
350
+ { zone: 'Z4', max_pct_ftp: 1.05 },
351
+ { zone: 'Z5', max_pct_ftp: 1.20 },
352
+ { zone: 'Z6', max_pct_ftp: 1.50 },
353
+ { zone: 'Z7', max_pct_ftp: Infinity },
354
+ ];
355
+
356
+ export function computeTimeInZone(
357
+ rawWatts: (number | null)[],
358
+ ftp: number,
359
+ zones: ZoneThreshold[] = DEFAULT_ZONE_THRESHOLDS,
360
+ ): TimeInZoneResult {
361
+ if (!Number.isFinite(ftp) || ftp <= 0) {
362
+ throw new Error('FTP not available — cannot compute time in zone');
363
+ }
364
+ const watts = rawWatts.filter((v): v is number => v !== null && v !== undefined);
365
+ const counts = new Map<string, number>();
366
+ for (const z of zones) counts.set(z.zone, 0);
367
+ for (const w of watts) {
368
+ const pct = w / ftp;
369
+ for (const z of zones) {
370
+ if (pct <= z.max_pct_ftp) {
371
+ counts.set(z.zone, (counts.get(z.zone) ?? 0) + 1);
372
+ break;
373
+ }
374
+ }
375
+ }
376
+ const total = watts.length;
377
+ const result: ZoneEntry[] = zones.map((z) => {
378
+ const seconds = counts.get(z.zone) ?? 0;
379
+ return {
380
+ zone: z.zone,
381
+ pct: total > 0 ? round((seconds / total) * 100, 1) : 0,
382
+ seconds,
383
+ };
384
+ });
385
+ return { zones: result };
386
+ }
387
+
388
+ // --- Config ---
389
+
390
+ export async function loadConfig(configPath: string): Promise<IntervalsIcuConfig> {
391
+ const raw = await readFile(configPath, 'utf-8');
392
+ const config = JSON.parse(raw);
393
+ const icu = config.intervals_icu;
394
+ if (!icu || typeof icu.api_key !== 'string' || typeof icu.athlete_id !== 'string') {
395
+ throw new Error(
396
+ `Missing or invalid intervals_icu config in ${configPath}. ` +
397
+ 'Expected { api_key: string, athlete_id: string }',
398
+ );
399
+ }
400
+ if (icu.api_key === 'REPLACE_WITH_YOUR_API_KEY') {
401
+ throw new Error(
402
+ `intervals_icu.api_key in ${configPath} is still the placeholder value. ` +
403
+ 'Replace it with your actual Intervals.icu API key.',
404
+ );
405
+ }
406
+ return { api_key: icu.api_key, athlete_id: icu.athlete_id };
407
+ }
408
+
409
+ // --- API ---
410
+
411
+ function authHeader(apiKey: string): string {
412
+ return 'Basic ' + Buffer.from(`API_KEY:${apiKey}`).toString('base64');
413
+ }
414
+
415
+ export async function fetchStreams(
416
+ config: IntervalsIcuConfig,
417
+ activityId: string,
418
+ ): Promise<{ watts: (number | null)[]; heartrate: (number | null)[] }> {
419
+ const url = `https://intervals.icu/api/v1/activity/${activityId}/streams?types=watts,heartrate`;
420
+ const resp = await fetch(url, {
421
+ headers: { Authorization: authHeader(config.api_key), Accept: 'application/json' },
422
+ });
423
+ if (!resp.ok) {
424
+ throw new Error(`Intervals.icu streams API returned ${resp.status}: ${resp.statusText}`);
425
+ }
426
+ const data = await resp.json();
427
+ // Intervals.icu returns either an array of {type, data} stream objects,
428
+ // or an object keyed by stream type. Handle both.
429
+ const extract = (key: string): (number | null)[] => {
430
+ if (Array.isArray(data)) {
431
+ const entry = data.find((s: { type?: string }) => s?.type === key);
432
+ if (!entry) return [];
433
+ return Array.isArray(entry) ? entry : (entry.data ?? []);
434
+ }
435
+ const stream = data[key];
436
+ if (!stream) return [];
437
+ return Array.isArray(stream) ? stream : (stream.data ?? []);
438
+ };
439
+ return { watts: extract('watts'), heartrate: extract('heartrate') };
440
+ }
441
+
442
+ export async function fetchIntervals(
443
+ config: IntervalsIcuConfig,
444
+ activityId: string,
445
+ ): Promise<IntervalBoundary[]> {
446
+ const url = `https://intervals.icu/api/v1/activity/${activityId}/intervals`;
447
+ const resp = await fetch(url, {
448
+ headers: { Authorization: authHeader(config.api_key), Accept: 'application/json' },
449
+ });
450
+ if (!resp.ok) {
451
+ throw new Error(`Intervals.icu intervals API returned ${resp.status}: ${resp.statusText}`);
452
+ }
453
+ const raw = await resp.json();
454
+ const data: unknown[] = Array.isArray(raw) ? raw : (raw?.icu_intervals ?? []);
455
+ let workIndex = 0;
456
+ const intervals: IntervalBoundary[] = [];
457
+ for (const item of data) {
458
+ const rec = item as Record<string, unknown>;
459
+ const type = String(rec.type ?? rec.label ?? '');
460
+ if (type === 'WORK' || type === 'Work' || type === 'work') {
461
+ workIndex++;
462
+ intervals.push({
463
+ index: workIndex,
464
+ type: 'WORK',
465
+ startIndex: Number(rec.start_index ?? rec.startIndex ?? 0),
466
+ endIndex: Number(rec.end_index ?? rec.endIndex ?? 0),
467
+ });
468
+ }
469
+ }
470
+ return intervals;
471
+ }
472
+
473
+ export async function fetchAllIntervals(
474
+ config: IntervalsIcuConfig,
475
+ activityId: string,
476
+ ): Promise<LapBoundary[]> {
477
+ const url = `https://intervals.icu/api/v1/activity/${activityId}/intervals`;
478
+ const resp = await fetch(url, {
479
+ headers: { Authorization: authHeader(config.api_key), Accept: 'application/json' },
480
+ });
481
+ if (!resp.ok) {
482
+ throw new Error(`Intervals.icu intervals API returned ${resp.status}: ${resp.statusText}`);
483
+ }
484
+ const raw = await resp.json();
485
+ const data: unknown[] = Array.isArray(raw) ? raw : (raw?.icu_intervals ?? []);
486
+ return data.map((item, i) => {
487
+ const rec = item as Record<string, unknown>;
488
+ return {
489
+ n: i + 1,
490
+ type: String(rec.type ?? rec.label ?? 'WORK').toUpperCase(),
491
+ startIndex: Number(rec.start_index ?? rec.startIndex ?? 0),
492
+ endIndex: Number(rec.end_index ?? rec.endIndex ?? 0),
493
+ };
494
+ });
495
+ }
496
+
497
+ export function classifyRestLaps(
498
+ rawWatts: (number | null)[],
499
+ rawHr: (number | null)[],
500
+ laps: LapBoundary[],
501
+ ftp: number,
502
+ ): boolean[] {
503
+ return laps.map((lap) => {
504
+ const wSlice = rawWatts
505
+ .slice(lap.startIndex, lap.endIndex + 1)
506
+ .filter((v): v is number => v !== null && v !== undefined);
507
+ const hrSlice = rawHr
508
+ .slice(lap.startIndex, lap.endIndex + 1)
509
+ .filter((v): v is number => v !== null && v !== undefined);
510
+ const avgW = wSlice.length > 0 ? mean(wSlice) : 0;
511
+ const explicitRest = lap.type === 'REST' || lap.type === 'RECOVERY';
512
+ let autoRest = false;
513
+ if (Number.isFinite(ftp) && ftp > 0 && avgW / ftp < 0.4 && hrSlice.length >= 60) {
514
+ const firstHalf = mean(hrSlice.slice(0, Math.floor(hrSlice.length / 2)));
515
+ const secondHalf = mean(hrSlice.slice(Math.floor(hrSlice.length / 2)));
516
+ autoRest = secondHalf < firstHalf - 5;
517
+ }
518
+ return explicitRest || autoRest;
519
+ });
520
+ }
521
+
522
+ export function filterStreamsByRestLaps(
523
+ rawWatts: (number | null)[],
524
+ rawHr: (number | null)[],
525
+ laps: LapBoundary[],
526
+ isRestFlags: boolean[],
527
+ ): { watts: (number | null)[]; hr: (number | null)[] } {
528
+ const workingIndices: number[] = [];
529
+ for (let i = 0; i < laps.length; i++) {
530
+ if (!isRestFlags[i]) {
531
+ for (let j = laps[i].startIndex; j <= laps[i].endIndex; j++) {
532
+ workingIndices.push(j);
533
+ }
534
+ }
535
+ }
536
+ return {
537
+ watts: workingIndices.map((i) => rawWatts[i] ?? null),
538
+ hr: workingIndices.map((i) => rawHr[i] ?? null),
539
+ };
540
+ }
541
+
542
+ export function computeLapTrends(
543
+ rawWatts: (number | null)[],
544
+ rawHr: (number | null)[],
545
+ laps: LapBoundary[],
546
+ ftp: number,
547
+ ): LapTrendsResult {
548
+ if (laps.length === 0) {
549
+ throw new Error('No laps found for lap-trends analysis');
550
+ }
551
+ const isRestFlags = classifyRestLaps(rawWatts, rawHr, laps, ftp);
552
+ const lapEntries: LapEntry[] = [];
553
+ for (let i = 0; i < laps.length; i++) {
554
+ const lap = laps[i];
555
+ const wSlice = rawWatts
556
+ .slice(lap.startIndex, lap.endIndex + 1)
557
+ .filter((v): v is number => v !== null && v !== undefined);
558
+ const hrSlice = rawHr
559
+ .slice(lap.startIndex, lap.endIndex + 1)
560
+ .filter((v): v is number => v !== null && v !== undefined);
561
+ if (wSlice.length === 0) continue;
562
+ const avgW = mean(wSlice);
563
+ const np = normalizedPower(wSlice);
564
+ const avgHr = hrSlice.length > 0 ? mean(hrSlice) : 0;
565
+ const maxHr = hrSlice.length > 0 ? Math.max(...hrSlice) : 0;
566
+ lapEntries.push({
567
+ n: lap.n,
568
+ sec: lap.endIndex - lap.startIndex + 1,
569
+ avg_w: round(avgW, 0),
570
+ np: round(np, 0),
571
+ avg_hr: round(avgHr, 0),
572
+ max_hr: round(maxHr, 0),
573
+ is_rest: isRestFlags[i],
574
+ });
575
+ }
576
+ const workLaps = lapEntries.filter((l) => !l.is_rest);
577
+ if (workLaps.length === 0) {
578
+ throw new Error('No non-rest laps found for trends');
579
+ }
580
+ const xs = workLaps.map((_, i) => i);
581
+ const powerSlope = linearSlope(xs, workLaps.map((l) => l.avg_w));
582
+ const hrSlope = linearSlope(xs, workLaps.map((l) => l.avg_hr));
583
+ const fastest = workLaps.reduce((a, b) => (a.avg_w > b.avg_w ? a : b));
584
+ const slowest = workLaps.reduce((a, b) => (a.avg_w < b.avg_w ? a : b));
585
+ const totalSec = workLaps.reduce((s, l) => s + l.sec, 0);
586
+ const thirdSec = totalSec / 3;
587
+ const thirds: number[][] = [[], [], []];
588
+ let cumSec = 0;
589
+ for (const lap of workLaps) {
590
+ const tIdx = cumSec < thirdSec ? 0 : cumSec < 2 * thirdSec ? 1 : 2;
591
+ thirds[tIdx].push(lap.avg_w);
592
+ cumSec += lap.sec;
593
+ }
594
+ const regimeBreaks: RegimeBreak[] = [];
595
+ for (let i = 3; i < workLaps.length; i++) {
596
+ const trailing = (workLaps[i - 1].avg_w + workLaps[i - 2].avg_w + workLaps[i - 3].avg_w) / 3;
597
+ const cur = workLaps[i].avg_w;
598
+ if (trailing > 0 && (cur - trailing) / trailing < -0.1) {
599
+ regimeBreaks.push({
600
+ after_lap: workLaps[i - 1].n,
601
+ delta_w: round(cur - trailing, 0),
602
+ note: 'sustained drop',
603
+ });
604
+ }
605
+ }
606
+ return {
607
+ laps: lapEntries,
608
+ power_slope_w_per_lap: round(powerSlope, 1),
609
+ hr_drift_bpm_per_lap: round(hrSlope, 2),
610
+ fastest_lap: { n: fastest.n, avg_w: fastest.avg_w },
611
+ slowest_lap: { n: slowest.n, avg_w: slowest.avg_w },
612
+ first_third_avg_w: thirds[0].length > 0 ? round(mean(thirds[0]), 0) : 0,
613
+ middle_third_avg_w: thirds[1].length > 0 ? round(mean(thirds[1]), 0) : 0,
614
+ last_third_avg_w: thirds[2].length > 0 ? round(mean(thirds[2]), 0) : 0,
615
+ regime_breaks: regimeBreaks,
616
+ };
617
+ }
618
+
619
+ // --- Compute functions ---
620
+
621
+ const MIN_DECOUPLING_SAMPLES = 2700; // 45 min at 1 Hz
622
+
623
+ export function computeDecoupling(
624
+ rawWatts: (number | null)[],
625
+ rawHr: (number | null)[],
626
+ ): DecouplingResult {
627
+ const [watts, hr] = stripNulls(rawWatts, rawHr);
628
+ if (watts.length < MIN_DECOUPLING_SAMPLES) {
629
+ throw new Error(
630
+ `Session too short for decoupling: ${watts.length}s (need ${MIN_DECOUPLING_SAMPLES}s / 45 min)`,
631
+ );
632
+ }
633
+ const half = Math.floor(watts.length / 2);
634
+ const w1 = watts.slice(0, half);
635
+ const hr1 = hr.slice(0, half);
636
+ const w2 = watts.slice(half);
637
+ const hr2 = hr.slice(half);
638
+
639
+ const avgW1 = mean(w1);
640
+ const avgHr1 = mean(hr1);
641
+ const avgW2 = mean(w2);
642
+ const avgHr2 = mean(hr2);
643
+
644
+ const ef1 = avgHr1 > 0 ? avgW1 / avgHr1 : 0;
645
+ const ef2 = avgHr2 > 0 ? avgW2 / avgHr2 : 0;
646
+ const decoupling = ef1 > 0 ? ((ef1 - ef2) / ef1) * 100 : 0;
647
+
648
+ return {
649
+ half1: { avg_watts: round(avgW1, 1), avg_hr: round(avgHr1, 1), ef: round(ef1, 3) },
650
+ half2: { avg_watts: round(avgW2, 1), avg_hr: round(avgHr2, 1), ef: round(ef2, 3) },
651
+ decoupling_pct: round(decoupling, 2),
652
+ };
653
+ }
654
+
655
+ export function computeHrRecovery(
656
+ rawHr: (number | null)[],
657
+ intervals: IntervalBoundary[],
658
+ ): HrRecoveryResult {
659
+ if (intervals.length === 0) {
660
+ throw new Error('No work intervals found for HR recovery analysis');
661
+ }
662
+
663
+ const results: HrRecoveryInterval[] = [];
664
+ for (const interval of intervals) {
665
+ const endIdx = interval.endIndex;
666
+ const endHr = rawHr[endIdx];
667
+ if (endHr === null || endHr === undefined) continue;
668
+
669
+ const hr60 = endIdx + 60 < rawHr.length ? rawHr[endIdx + 60] : null;
670
+ const hr120 = endIdx + 120 < rawHr.length ? rawHr[endIdx + 120] : null;
671
+
672
+ results.push({
673
+ index: interval.index,
674
+ end_hr: round(endHr, 0),
675
+ drop_60s: hr60 !== null && hr60 !== undefined ? round(endHr - hr60, 0) : null,
676
+ drop_120s: hr120 !== null && hr120 !== undefined ? round(endHr - hr120, 0) : null,
677
+ });
678
+ }
679
+
680
+ let trend: 'improving' | 'stable' | 'declining' = 'stable';
681
+ if (results.length >= 2) {
682
+ const first = results[0].drop_60s;
683
+ const last = results[results.length - 1].drop_60s;
684
+ if (first !== null && last !== null) {
685
+ const diff = last - first;
686
+ if (diff > 3) trend = 'improving';
687
+ else if (diff < -3) trend = 'declining';
688
+ }
689
+ }
690
+
691
+ return { intervals: results, trend };
692
+ }
693
+
694
+ export function computeIntervalCv(
695
+ rawWatts: (number | null)[],
696
+ intervals: IntervalBoundary[],
697
+ ): IntervalCvResult {
698
+ if (intervals.length === 0) {
699
+ throw new Error('No work intervals found for interval CV analysis');
700
+ }
701
+
702
+ const results: IntervalCvEntry[] = [];
703
+ for (const interval of intervals) {
704
+ const slice = rawWatts
705
+ .slice(interval.startIndex, interval.endIndex + 1)
706
+ .filter((v): v is number => v !== null && v !== undefined);
707
+
708
+ if (slice.length === 0) continue;
709
+
710
+ const avg = mean(slice);
711
+ const cv = avg > 0 ? (stddev(slice) / avg) * 100 : 0;
712
+
713
+ results.push({
714
+ index: interval.index,
715
+ avg_watts: round(avg, 1),
716
+ cv_pct: round(cv, 1),
717
+ });
718
+ }
719
+
720
+ return { intervals: results };
721
+ }
722
+
723
+ // --- Orchestrator ---
724
+
725
+ const KNOWN_ANALYSES = [
726
+ 'decoupling',
727
+ 'hr_recovery',
728
+ 'interval_cv',
729
+ 'fade',
730
+ 'time_in_zone',
731
+ 'np_distribution',
732
+ 'lap_trends',
733
+ 'sim_compare',
734
+ ] as const;
735
+ type AnalysisKey = (typeof KNOWN_ANALYSES)[number];
736
+
737
+ export interface AnalyzeOptions {
738
+ fadeSegmentation?: 'default' | number | number[];
739
+ ftp?: number;
740
+ zones?: ZoneThreshold[];
741
+ laps?: LapBoundary[];
742
+ simCompareTarget?: ActivitySummaryForCompare;
743
+ simCompareSims?: ActivitySummaryForCompare[];
744
+ movingOnly?: boolean;
745
+ }
746
+
747
+ export async function analyzeStreams(
748
+ config: IntervalsIcuConfig,
749
+ activityId: string,
750
+ analyses: string[],
751
+ options: AnalyzeOptions = {},
752
+ ): Promise<StreamAnalysisOutput> {
753
+ const output: StreamAnalysisOutput = {
754
+ activity_id: activityId,
755
+ duration_sec: 0,
756
+ analyses: {},
757
+ errors: {},
758
+ };
759
+
760
+ const needsStreams = analyses.some((a) =>
761
+ ['decoupling', 'hr_recovery', 'interval_cv', 'fade', 'time_in_zone', 'np_distribution', 'lap_trends'].includes(a),
762
+ );
763
+ const needsIntervals = analyses.some((a) => ['hr_recovery', 'interval_cv'].includes(a));
764
+ const needsAllLaps = analyses.includes('lap_trends') || options.movingOnly;
765
+
766
+ let streams: { watts: (number | null)[]; heartrate: (number | null)[] } | null = null;
767
+ let filteredStreams: { watts: (number | null)[]; hr: (number | null)[] } | null = null;
768
+ let intervals: IntervalBoundary[] | null = null;
769
+ let allLaps: LapBoundary[] | null = options.laps ?? null;
770
+
771
+ if (needsStreams) {
772
+ try {
773
+ streams = await fetchStreams(config, activityId);
774
+ output.duration_sec = Math.max(streams.watts.length, streams.heartrate.length);
775
+ } catch (err) {
776
+ const msg = err instanceof Error ? err.message : String(err);
777
+ for (const a of analyses) output.errors[a] = `Stream fetch failed: ${msg}`;
778
+ return output;
779
+ }
780
+ }
781
+
782
+ if (needsIntervals) {
783
+ try {
784
+ intervals = await fetchIntervals(config, activityId);
785
+ } catch (err) {
786
+ const msg = err instanceof Error ? err.message : String(err);
787
+ for (const a of analyses) {
788
+ if (['hr_recovery', 'interval_cv'].includes(a)) {
789
+ output.errors[a] = `Interval fetch failed: ${msg}`;
790
+ }
791
+ }
792
+ }
793
+ }
794
+
795
+ if (needsAllLaps && !allLaps) {
796
+ try {
797
+ allLaps = await fetchAllIntervals(config, activityId);
798
+ } catch (err) {
799
+ const msg = err instanceof Error ? err.message : String(err);
800
+ if (analyses.includes('lap_trends')) output.errors['lap_trends'] = `Lap fetch failed: ${msg}`;
801
+ if (options.movingOnly) {
802
+ for (const a of analyses) {
803
+ if (['decoupling', 'fade', 'time_in_zone', 'np_distribution'].includes(a)) {
804
+ output.errors[a] = `Lap fetch failed (required for --moving-only): ${msg}`;
805
+ }
806
+ }
807
+ }
808
+ }
809
+ }
810
+
811
+ if (options.movingOnly && streams && allLaps && !filteredStreams) {
812
+ const isRestFlags = classifyRestLaps(streams.watts, streams.heartrate, allLaps, options.ftp ?? 0);
813
+ filteredStreams = filterStreamsByRestLaps(streams.watts, streams.heartrate, allLaps, isRestFlags);
814
+ if (filteredStreams.watts.length === 0) {
815
+ for (const a of analyses) {
816
+ if (['decoupling', 'fade', 'time_in_zone', 'np_distribution'].includes(a)) {
817
+ output.errors[a] = 'No non-rest data found for --moving-only';
818
+ }
819
+ }
820
+ }
821
+ }
822
+
823
+ for (const analysis of analyses as AnalysisKey[]) {
824
+ if (output.errors[analysis]) continue;
825
+
826
+ try {
827
+ switch (analysis) {
828
+ case 'decoupling': {
829
+ const s = options.movingOnly ? filteredStreams : (streams ? { watts: streams.watts, hr: streams.heartrate } : null);
830
+ if (!s) { output.errors[analysis] = 'No stream data'; break; }
831
+ output.analyses.decoupling = computeDecoupling(s.watts, s.hr);
832
+ break;
833
+ }
834
+
835
+ case 'hr_recovery':
836
+ if (!streams) { output.errors[analysis] = 'No stream data'; break; }
837
+ if (!intervals) { output.errors[analysis] = 'No interval data'; break; }
838
+ output.analyses.hr_recovery = computeHrRecovery(streams.heartrate, intervals);
839
+ break;
840
+
841
+ case 'interval_cv':
842
+ if (!streams) { output.errors[analysis] = 'No stream data'; break; }
843
+ if (!intervals) { output.errors[analysis] = 'No interval data'; break; }
844
+ output.analyses.interval_cv = computeIntervalCv(streams.watts, intervals);
845
+ break;
846
+
847
+ case 'fade': {
848
+ const s = options.movingOnly ? filteredStreams : (streams ? { watts: streams.watts, hr: streams.heartrate } : null);
849
+ if (!s) { output.errors[analysis] = 'No stream data'; break; }
850
+ output.analyses.fade = computeFade(s.watts, s.hr, options.fadeSegmentation);
851
+ break;
852
+ }
853
+
854
+ case 'time_in_zone': {
855
+ const s = options.movingOnly ? filteredStreams : (streams ? { watts: streams.watts, hr: streams.heartrate } : null);
856
+ if (!s) { output.errors[analysis] = 'No stream data'; break; }
857
+ if (!options.ftp || options.ftp <= 0) { output.errors[analysis] = 'ftp not available'; break; }
858
+ output.analyses.time_in_zone = computeTimeInZone(s.watts, options.ftp, options.zones);
859
+ break;
860
+ }
861
+
862
+ case 'np_distribution': {
863
+ const s = options.movingOnly ? filteredStreams : (streams ? { watts: streams.watts, hr: streams.heartrate } : null);
864
+ if (!s) { output.errors[analysis] = 'No stream data'; break; }
865
+ output.analyses.np_distribution = computeNpDistribution(s.watts);
866
+ break;
867
+ }
868
+
869
+ case 'lap_trends':
870
+ if (!streams) { output.errors[analysis] = 'No stream data'; break; }
871
+ if (!allLaps) { output.errors[analysis] = 'No lap data'; break; }
872
+ if (!options.ftp || options.ftp <= 0) { output.errors[analysis] = 'ftp not available'; break; }
873
+ output.analyses.lap_trends = computeLapTrends(streams.watts, streams.heartrate, allLaps, options.ftp);
874
+ break;
875
+
876
+ case 'sim_compare':
877
+ if (!options.simCompareTarget) { output.errors[analysis] = 'sim compare target not provided'; break; }
878
+ output.analyses.sim_compare = computeSimCompare(options.simCompareTarget, options.simCompareSims ?? []);
879
+ break;
880
+ }
881
+ } catch (err) {
882
+ output.errors[analysis] = err instanceof Error ? err.message : String(err);
883
+ }
884
+ }
885
+
886
+ return output;
887
+ }
888
+
889
+ // --- computeSimCompare ---
890
+
891
+ export function computeSimCompare(
892
+ target: ActivitySummaryForCompare,
893
+ sims: ActivitySummaryForCompare[],
894
+ ): SimCompareResult {
895
+ const comparisons: SimComparison[] = sims.map((sim) => ({
896
+ sim_activity_id: sim.activity_id,
897
+ sim_date: sim.date,
898
+ np_delta_pct: sim.np > 0 ? round(((target.np - sim.np) / sim.np) * 100, 1) : 0,
899
+ hr_delta_bpm: round(target.avg_hr - sim.avg_hr, 0),
900
+ decoupling_delta_pct: round(target.decoupling_pct - sim.decoupling_pct, 1),
901
+ duration_delta_min: round((target.duration_sec - sim.duration_sec) / 60, 0),
902
+ }));
903
+ return { comparisons };
904
+ }
905
+
906
+ // --- CLI ---
907
+
908
+ export type StreamAnalyzeCliArgs = {
909
+ activityId: string;
910
+ analyses: string[];
911
+ configPath: string;
912
+ fadeSegmentation: 'default' | number | number[];
913
+ ftp?: number;
914
+ movingOnly: boolean;
915
+ simCompareTargetPath?: string;
916
+ simCompareSimsPath?: string;
917
+ };
918
+
919
+ export function parseStreamAnalyzeArgs(args: string[]): StreamAnalyzeCliArgs {
920
+ const flagValue = (name: string): string | undefined => {
921
+ const i = args.indexOf(name);
922
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
923
+ };
924
+
925
+ const activityId = flagValue('--activity-id');
926
+ const analysesRaw = flagValue('--analyses');
927
+
928
+ if (!activityId || !analysesRaw) {
929
+ process.stderr.write(
930
+ 'Usage: stream-analyze --activity-id <id> --analyses <a1,a2,...> [--config <path>]\n' +
931
+ ' [--fade-segments <N|t1,t2,...>] [--ftp-override <watts>] [--moving-only]\n' +
932
+ ' [--sim-compare-target <path.json>] [--sim-compare-sims <path.json>]\n' +
933
+ ` Available analyses: ${KNOWN_ANALYSES.join(', ')}\n`,
934
+ );
935
+ process.exit(1);
936
+ }
937
+
938
+ const analyses = analysesRaw.split(',');
939
+ const unknown = analyses.filter((a) => !(KNOWN_ANALYSES as readonly string[]).includes(a));
940
+ if (unknown.length > 0) {
941
+ process.stderr.write(
942
+ `Unknown analyses: ${unknown.join(', ')}\n Available: ${KNOWN_ANALYSES.join(', ')}\n`,
943
+ );
944
+ process.exit(1);
945
+ }
946
+
947
+ const toolDir = dirname(fileURLToPath(import.meta.url));
948
+ const configPath = flagValue('--config') ?? resolve(toolDir, '..', 'config.json');
949
+
950
+ const fadeRaw = flagValue('--fade-segments');
951
+ let fadeSegmentation: 'default' | number | number[] = 'default';
952
+ if (fadeRaw) {
953
+ if (fadeRaw.includes(',')) {
954
+ fadeSegmentation = fadeRaw.split(',').map((s) => Number(s));
955
+ } else {
956
+ const n = Number(fadeRaw);
957
+ if (!Number.isNaN(n)) fadeSegmentation = n;
958
+ }
959
+ }
960
+
961
+ const ftpRaw = flagValue('--ftp-override');
962
+ const ftp = ftpRaw ? Number(ftpRaw) : undefined;
963
+
964
+ const movingOnly = args.includes('--moving-only');
965
+
966
+ return {
967
+ activityId,
968
+ analyses,
969
+ configPath,
970
+ fadeSegmentation,
971
+ ftp,
972
+ movingOnly,
973
+ simCompareTargetPath: flagValue('--sim-compare-target'),
974
+ simCompareSimsPath: flagValue('--sim-compare-sims'),
975
+ };
976
+ }
977
+
978
+ // Only run CLI when executed directly (not imported)
979
+ const isDirectExecution =
980
+ process.argv[1] !== undefined &&
981
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
982
+
983
+ if (isDirectExecution) {
984
+ const parsed = parseStreamAnalyzeArgs(process.argv.slice(2));
985
+ (async () => {
986
+ try {
987
+ const config = await loadConfig(parsed.configPath);
988
+ const options: AnalyzeOptions = {
989
+ fadeSegmentation: parsed.fadeSegmentation,
990
+ ftp: parsed.ftp,
991
+ movingOnly: parsed.movingOnly,
992
+ };
993
+ if (parsed.simCompareTargetPath) {
994
+ const raw = await readFile(parsed.simCompareTargetPath, 'utf-8');
995
+ options.simCompareTarget = JSON.parse(raw);
996
+ }
997
+ if (parsed.simCompareSimsPath) {
998
+ const raw = await readFile(parsed.simCompareSimsPath, 'utf-8');
999
+ options.simCompareSims = JSON.parse(raw);
1000
+ }
1001
+ const result = await analyzeStreams(config, parsed.activityId, parsed.analyses, options);
1002
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1003
+ } catch (err) {
1004
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
1005
+ process.exit(1);
1006
+ }
1007
+ })();
1008
+ }