catence 0.2.0-beta.20 → 0.2.0-beta.23

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.
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.20";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.23";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.20';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.23';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -19,6 +19,12 @@ export type SeriesRequest = {
19
19
  cursor?: string;
20
20
  pageSize?: number;
21
21
  };
22
+ export type DeriveDecouplingRequest = {
23
+ activityId: string;
24
+ };
25
+ export type DeriveGapRequest = {
26
+ activityId: string;
27
+ };
22
28
  export declare function compileFilters(dataset: DatasetDefinition, filters?: DataFilter[], startDate?: string, endDate?: string, activityId?: string): {
23
29
  where: string;
24
30
  values: QueryValues;
@@ -56,4 +62,8 @@ export declare class AnalyticsService {
56
62
  xMetric?: string;
57
63
  yMetric?: string;
58
64
  }): Promise<Record<string, unknown>>;
65
+ deriveDecoupling(request: DeriveDecouplingRequest): Promise<Record<string, unknown>>;
66
+ deriveGap(request: DeriveGapRequest): Promise<Record<string, unknown>>;
67
+ private resolveActivitySource;
68
+ private loadActivitySamples;
59
69
  }
@@ -4,6 +4,19 @@ import { jsonSafe } from './repository.js';
4
4
  const resolutionIntervals = {
5
5
  '1m': 'minute', '5m': 'minute', '15m': 'minute', '1h': 'hour', day: 'day', week: 'week',
6
6
  };
7
+ // Decoupling / GAP thresholds — kept conservative so short or walk-heavy efforts
8
+ // are reported as insufficient rather than silently producing a misleading drift.
9
+ const MIN_TOTAL_SAMPLES = 300;
10
+ const MIN_MOVING_SAMPLES = 360;
11
+ const MIN_VALID_SAMPLES = 360;
12
+ const MOVING_RATIO_THRESHOLD = 0.55;
13
+ const HR_COVERAGE_THRESHOLD = 0.6;
14
+ const METRIC_COVERAGE_THRESHOLD = 0.6;
15
+ const WALK_SPEED_THRESHOLD_MPS = 2.0;
16
+ const CYCLING_MOVING_SPEED_THRESHOLD_MPS = 1.0;
17
+ const RUNNING_MOVING_SPEED_THRESHOLD_MPS = 0.8;
18
+ const MIN_MOVING_DURATION_S = 600;
19
+ const GRADE_CLAMP = 0.3;
7
20
  function quoteIdentifier(identifier) {
8
21
  return `"${identifier.replaceAll('"', '""')}"`;
9
22
  }
@@ -115,6 +128,32 @@ function bucketExpression(timestamp, resolution) {
115
128
  function sourceRelation(relation) {
116
129
  return relation.trimStart().toLowerCase().startsWith('select') ? `(${relation})` : quoteIdentifier(relation);
117
130
  }
131
+ function isCyclingSport(sport) {
132
+ if (!sport)
133
+ return false;
134
+ const normalized = sport.toLowerCase();
135
+ return normalized.includes('cycl') || normalized.includes('bik') || normalized.includes('ride') || normalized === 'virtual_ride';
136
+ }
137
+ function isRunningSport(sport) {
138
+ if (!sport)
139
+ return false;
140
+ const normalized = sport.toLowerCase();
141
+ return normalized.includes('run') || normalized.includes('trail');
142
+ }
143
+ function clampGrade(grade) {
144
+ if (grade > GRADE_CLAMP)
145
+ return GRADE_CLAMP;
146
+ if (grade < -GRADE_CLAMP)
147
+ return -GRADE_CLAMP;
148
+ return grade;
149
+ }
150
+ // Minetti et al. 2002 energy cost of running (J/kg/m). grade is rise/run as a
151
+ // fraction (0.10 = 10 %). The polynomial reproduces the U-shaped cost curve:
152
+ // cheaper slightly downhill, markedly more expensive uphill and very steep downhill.
153
+ function runningCost(gradeFraction) {
154
+ const g = clampGrade(gradeFraction);
155
+ return 155.4 * g ** 5 - 30.4 * g ** 4 - 43.3 * g ** 3 + 46.3 * g ** 2 + 19.5 * g + 3.6;
156
+ }
118
157
  export class AnalyticsService {
119
158
  repository;
120
159
  constructor(repository) {
@@ -347,6 +386,461 @@ export class AnalyticsService {
347
386
  const residuals = y.map((item, index) => item - predictions[index]);
348
387
  return jsonSafe({ data: { model: request.model, xMetric: request.xMetric ?? 'observation_index', yMetric, coefficients: fit.coefficients, rSquared: rSquared(y, predictions), residuals: { mean: mean(residuals), standardDeviation: standardDeviation(residuals), min: Math.min(...residuals), max: Math.max(...residuals) } }, provenance: series.provenance, query: series.query, caveats: ['Descriptive fit only; it is not a physiological or sport-performance model.', 'Rows with null x or y values were excluded.', ...(series.nextCursor ? ['Input was capped at the first 5,000 points after the declared resolution.'] : [])], input: { selectedRows: rows.length, fittedRows: pairs.length, excludedRows: rows.length - pairs.length, cappedRows: series.nextCursor ? 'at least 1 additional page' : 0, dateCoverage: coverage(rows) } });
349
388
  }
389
+ // ---- Aerobic decoupling (Pa:Hr / Pw:Hr) and GAP ----
390
+ async deriveDecoupling(request) {
391
+ const activityId = request.activityId?.trim();
392
+ if (!activityId)
393
+ throw new QueryValidationError('activityId is required. Provide an activity_source_id (e.g. garmin:123) or a canonical activity_id.');
394
+ const identity = await this.resolveActivitySource(activityId);
395
+ const samples = await this.loadActivitySamples(identity.activity_source_id);
396
+ const totalSamples = samples.length;
397
+ if (totalSamples === 0) {
398
+ return jsonSafe({
399
+ data: null,
400
+ provenance: { dataset: 'activity_samples', derivation: 'derived', columns: ['heart_rate_bpm', 'power_w', 'speed_mps'], activitySourceId: identity.activity_source_id, sport: identity.sport },
401
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id, sport: identity.sport, provider: identity.provider },
402
+ caveats: [
403
+ 'No activity_samples are available for this activity. The stream may not have been synced or the Parquet lake contains no registered manifest for this source.',
404
+ 'Decoupling is derived only from FIT samples; it cannot be inferred from summary statistics alone.',
405
+ 'Null remains null: insufficient samples produce no decoupling value rather than a synthesized placeholder.',
406
+ ],
407
+ input: { totalSamples },
408
+ });
409
+ }
410
+ if (totalSamples < MIN_TOTAL_SAMPLES) {
411
+ return jsonSafe({
412
+ data: null,
413
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id, sport: identity.sport },
414
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id, sport: identity.sport },
415
+ caveats: [
416
+ `Insufficient samples: ${totalSamples} total points, fewer than the ${MIN_TOTAL_SAMPLES} minimum. Short efforts do not provide stable first/second-half aerobic drift.`,
417
+ 'Derived decoupling is descriptive only; do not treat absence as evidence of good or poor aerobic fitness.',
418
+ ],
419
+ input: { totalSamples },
420
+ });
421
+ }
422
+ const isCycling = isCyclingSport(identity.sport);
423
+ const isRunning = isRunningSport(identity.sport);
424
+ // Fallback when sport label is absent: choose metric with better coverage.
425
+ let metricField;
426
+ if (isCycling)
427
+ metricField = 'power_w';
428
+ else if (isRunning)
429
+ metricField = 'speed_mps';
430
+ else {
431
+ const powerCoverage = samples.filter((row) => numberOrNull(row.power_w) !== null).length / totalSamples;
432
+ const speedCoverage = samples.filter((row) => numberOrNull(row.speed_mps) !== null).length / totalSamples;
433
+ metricField = powerCoverage > speedCoverage && powerCoverage > 0.5 ? 'power_w' : 'speed_mps';
434
+ }
435
+ const movingThreshold = isCycling ? CYCLING_MOVING_SPEED_THRESHOLD_MPS : RUNNING_MOVING_SPEED_THRESHOLD_MPS;
436
+ const movingSamples = samples.filter((row) => {
437
+ const speed = numberOrNull(row.speed_mps);
438
+ const power = numberOrNull(row.power_w);
439
+ if (speed !== null)
440
+ return speed > movingThreshold;
441
+ if (isCycling && power !== null)
442
+ return power > 10;
443
+ return false;
444
+ });
445
+ const movingRatio = movingSamples.length / totalSamples;
446
+ const movingDurationS = (() => {
447
+ const elapsedValues = movingSamples.map((row) => numberOrNull(row.elapsed_s)).filter((value) => value !== null);
448
+ if (elapsedValues.length >= 2)
449
+ return Math.max(...elapsedValues) - Math.min(...elapsedValues);
450
+ return movingSamples.length;
451
+ })();
452
+ if (movingSamples.length < MIN_MOVING_SAMPLES) {
453
+ return jsonSafe({
454
+ data: null,
455
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id, metricField, sport: identity.sport },
456
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id, metricField },
457
+ caveats: [
458
+ `Insufficient moving samples: ${movingSamples.length} moving points < ${MIN_MOVING_SAMPLES} minimum (total ${totalSamples}, moving ratio ${(movingRatio * 100).toFixed(1)} %).`,
459
+ 'Stopped or highly interrupted efforts cannot support a stable first-half vs second-half drift comparison.',
460
+ 'Guard: filtered by speed threshold; walk-heavy and stop-heavy activities are excluded by design.',
461
+ ],
462
+ input: { totalSamples, movingSamples: movingSamples.length, movingRatio, movingDurationS },
463
+ });
464
+ }
465
+ if (movingRatio < MOVING_RATIO_THRESHOLD) {
466
+ return jsonSafe({
467
+ data: null,
468
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id, metricField },
469
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
470
+ caveats: [
471
+ `Moving-time ratio too low: ${(movingRatio * 100).toFixed(1)} % < ${(MOVING_RATIO_THRESHOLD * 100).toFixed(0)} % threshold (moving ${movingSamples.length} / total ${totalSamples}).`,
472
+ 'Stop-heavy or trail activities with prolonged pauses bias a naive first/second-half split. Paused portions are excluded and the activity is reported as insufficient for decoupling.',
473
+ ],
474
+ input: { totalSamples, movingSamples: movingSamples.length, movingRatio },
475
+ });
476
+ }
477
+ if (movingDurationS < MIN_MOVING_DURATION_S) {
478
+ return jsonSafe({
479
+ data: null,
480
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
481
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
482
+ caveats: [
483
+ `Insufficient moving duration: ~${Math.round(movingDurationS)} s < ${MIN_MOVING_DURATION_S} s minimum for a steady aerobic drift comparison.`,
484
+ ],
485
+ input: { movingDurationS, movingSamples: movingSamples.length },
486
+ });
487
+ }
488
+ // Walk-heavy guard for running: median moving speed should look like running, not walking.
489
+ if (!isCycling) {
490
+ const speeds = movingSamples.map((row) => numberOrNull(row.speed_mps)).filter((value) => value !== null);
491
+ if (speeds.length) {
492
+ const medianSpeed = median(speeds);
493
+ if (medianSpeed < WALK_SPEED_THRESHOLD_MPS) {
494
+ return jsonSafe({
495
+ data: null,
496
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id, sport: identity.sport },
497
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
498
+ caveats: [
499
+ `Walk-heavy effort: median moving speed ${medianSpeed.toFixed(2)} m/s < ${WALK_SPEED_THRESHOLD_MPS.toFixed(1)} m/s threshold. Aerobic decoupling on walking segments is misleading; the activity is reported as insufficient.`,
500
+ 'This guards against the false-low the agent noted for trail ultras where hiking dominates moving time.',
501
+ ],
502
+ input: { medianSpeedMps: medianSpeed, movingSamples: movingSamples.length },
503
+ });
504
+ }
505
+ }
506
+ }
507
+ const validSamples = movingSamples.filter((row) => {
508
+ const hr = numberOrNull(row.heart_rate_bpm);
509
+ const metric = numberOrNull(row[metricField]);
510
+ if (hr === null || metric === null)
511
+ return false;
512
+ if (hr < 30 || hr > 220)
513
+ return false;
514
+ if (metric <= 0)
515
+ return false;
516
+ return true;
517
+ });
518
+ const hrCoverage = validSamples.length / movingSamples.length;
519
+ const metricCoverage = validSamples.length / movingSamples.length;
520
+ if (validSamples.length < MIN_VALID_SAMPLES) {
521
+ return jsonSafe({
522
+ data: null,
523
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id, metricField },
524
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
525
+ caveats: [
526
+ `Insufficient valid paired HR/${metricField} samples: ${validSamples.length} < ${MIN_VALID_SAMPLES} (HR coverage ${(hrCoverage * 100).toFixed(1)} %, metric coverage ${(metricCoverage * 100).toFixed(1)} %).`,
527
+ 'Decoupling requires steady paired heart-rate and power (cycling) or speed (running) across the effort. Missing or sparse sensor streams cannot be inferred.',
528
+ ],
529
+ input: { validSamples: validSamples.length, movingSamples: movingSamples.length, hrCoverage, metricCoverage },
530
+ });
531
+ }
532
+ if (hrCoverage < HR_COVERAGE_THRESHOLD || metricCoverage < METRIC_COVERAGE_THRESHOLD) {
533
+ return jsonSafe({
534
+ data: null,
535
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id, metricField },
536
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
537
+ caveats: [
538
+ `Sparse paired coverage: HR ${(hrCoverage * 100).toFixed(1)} % / ${metricField} ${(metricCoverage * 100).toFixed(1)} % below thresholds ${(HR_COVERAGE_THRESHOLD * 100).toFixed(0)} % / ${(METRIC_COVERAGE_THRESHOLD * 100).toFixed(0)} %.`,
539
+ ],
540
+ input: { validSamples: validSamples.length, hrCoverage, metricCoverage },
541
+ });
542
+ }
543
+ // Steady first/second halves: chronological split of the valid moving samples.
544
+ const half = Math.floor(validSamples.length / 2);
545
+ const firstHalf = validSamples.slice(0, half);
546
+ const secondHalf = validSamples.slice(half);
547
+ if (firstHalf.length < 60 || secondHalf.length < 60) {
548
+ return jsonSafe({
549
+ data: null,
550
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
551
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
552
+ caveats: ['Split halves are too small for a stable comparison.'],
553
+ input: { firstHalf: firstHalf.length, secondHalf: secondHalf.length },
554
+ });
555
+ }
556
+ const avgHrFirst = mean(firstHalf.map((row) => numberOrNull(row.heart_rate_bpm)));
557
+ const avgHrSecond = mean(secondHalf.map((row) => numberOrNull(row.heart_rate_bpm)));
558
+ const avgMetricFirst = mean(firstHalf.map((row) => numberOrNull(row[metricField])));
559
+ const avgMetricSecond = mean(secondHalf.map((row) => numberOrNull(row[metricField])));
560
+ if (avgHrFirst === 0 || avgMetricFirst === 0) {
561
+ return jsonSafe({
562
+ data: null,
563
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
564
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
565
+ caveats: ['First-half averages are degenerate (zero divisor); cannot form a drift ratio.'],
566
+ input: { avgHrFirst, avgMetricFirst },
567
+ });
568
+ }
569
+ const efficiencyFirst = avgMetricFirst / avgHrFirst;
570
+ const efficiencySecond = avgMetricSecond / avgHrSecond;
571
+ if (efficiencyFirst === 0) {
572
+ return jsonSafe({
573
+ data: null,
574
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
575
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
576
+ caveats: ['Efficiency ratio is degenerate; cannot compute decoupling.'],
577
+ input: { efficiencyFirst },
578
+ });
579
+ }
580
+ const decouplingPct = (1 - efficiencySecond / efficiencyFirst) * 100;
581
+ const hrDriftPct = avgHrFirst === 0 ? null : ((avgHrSecond - avgHrFirst) / avgHrFirst) * 100;
582
+ const metricDriftPct = avgMetricFirst === 0 ? null : ((avgMetricSecond - avgMetricFirst) / avgMetricFirst) * 100;
583
+ const medianSpeedMps = (() => {
584
+ const speeds = movingSamples.map((row) => numberOrNull(row.speed_mps)).filter((value) => value !== null);
585
+ return speeds.length ? median(speeds) : null;
586
+ })();
587
+ return jsonSafe({
588
+ data: {
589
+ activityId: identity.activity_id,
590
+ activitySourceId: identity.activity_source_id,
591
+ provider: identity.provider,
592
+ sport: identity.sport,
593
+ startedAtUtc: identity.started_at_utc,
594
+ metricUsed: metricField,
595
+ decouplingPct,
596
+ efficiencyFirst,
597
+ efficiencySecond,
598
+ avgHrFirst,
599
+ avgHrSecond,
600
+ avgMetricFirst,
601
+ avgMetricSecond,
602
+ hrDriftPct,
603
+ metricDriftPct,
604
+ samples: { totalSamples, movingSamples: movingSamples.length, validSamples: validSamples.length, firstHalf: firstHalf.length, secondHalf: secondHalf.length, movingRatio, movingDurationS, medianSpeedMps },
605
+ interpretation: {
606
+ decouplingPct,
607
+ direction: decouplingPct > 5 ? 'hr_drift_positive' : decouplingPct < -5 ? 'negative_drift' : 'stable',
608
+ note: 'Positive decoupling means HR rose relative to power/speed in the second half ( cardiac drift at similar output).',
609
+ },
610
+ },
611
+ provenance: {
612
+ dataset: 'activity_samples',
613
+ derivation: 'derived',
614
+ method: 'Pw:Hr or Pa:Hr drift across steady first/second moving halves (chronological split of valid HR+metric samples). Efficiency = metric / HR; decouplingPct = (1 - EF_second / EF_first) * 100.',
615
+ metricField,
616
+ columns: ['heart_rate_bpm', metricField, 'speed_mps', 'timestamp_utc', 'elapsed_s'],
617
+ activitySourceId: identity.activity_source_id,
618
+ },
619
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id, metricField, sport: identity.sport },
620
+ caveats: [
621
+ 'Derived decoupling is descriptive only; it is not a physiological model or training prescription.',
622
+ 'Derived values never overwrite provider-supplied decoupling (e.g. Intervals decoupling in metrics_json); provider values remain authoritative when present.',
623
+ 'First/second halves are chronological moving halves after walk/stop filtering; warm-up and cool-down are not separately excluded — steady-state drift should be interpreted with that limit in mind.',
624
+ 'Null remains null: when samples are sparse, walk-heavy, or stop-heavy the tool returns data:null with explanatory caveats rather than a fabricated value.',
625
+ `Thresholds: total ≥${MIN_TOTAL_SAMPLES}, moving ≥${MIN_MOVING_SAMPLES}, valid ≥${MIN_VALID_SAMPLES}, movingRatio ≥${(MOVING_RATIO_THRESHOLD * 100).toFixed(0)} %, movingDuration ≥${MIN_MOVING_DURATION_S}s, HR/metric coverage ≥${(HR_COVERAGE_THRESHOLD * 100).toFixed(0)} %.`,
626
+ ],
627
+ input: { totalSamples, movingSamples: movingSamples.length, validSamples: validSamples.length, movingRatio, medianSpeedMps, metricField },
628
+ });
629
+ }
630
+ async deriveGap(request) {
631
+ const activityId = request.activityId?.trim();
632
+ if (!activityId)
633
+ throw new QueryValidationError('activityId is required. Provide an activity_source_id (e.g. garmin:123) or a canonical activity_id.');
634
+ const identity = await this.resolveActivitySource(activityId);
635
+ const samples = await this.loadActivitySamples(identity.activity_source_id);
636
+ const totalSamples = samples.length;
637
+ if (totalSamples === 0) {
638
+ return jsonSafe({
639
+ data: null,
640
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
641
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
642
+ caveats: ['No activity_samples are available for this activity; GAP cannot be derived.'],
643
+ input: { totalSamples },
644
+ });
645
+ }
646
+ const isRunning = isRunningSport(identity.sport);
647
+ const isCycling = isCyclingSport(identity.sport);
648
+ if (isCycling) {
649
+ return jsonSafe({
650
+ data: null,
651
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id, sport: identity.sport },
652
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id, sport: identity.sport },
653
+ caveats: [
654
+ 'GAP (grade-adjusted pace) is a running metric. This activity is cycling; grade-adjusted pace is not applicable. Use power/HR decoupling for cycling aerobic assessment instead.',
655
+ ],
656
+ input: { sport: identity.sport },
657
+ });
658
+ }
659
+ // Non-running, non-cycling: still attempt GAP but flag as generic.
660
+ const expectedRunning = isRunning || (!identity.sport);
661
+ // Moving filter for GAP: requires speed; altitude or grade for the grade model.
662
+ const movingSamples = samples
663
+ .filter((row) => {
664
+ const speed = numberOrNull(row.speed_mps);
665
+ if (speed === null || speed <= RUNNING_MOVING_SPEED_THRESHOLD_MPS)
666
+ return false;
667
+ return true;
668
+ })
669
+ .map((row, index, array) => {
670
+ // Grades are ordered by timestamp; compute fallback grade when grade_pct is missing.
671
+ const gradePct = numberOrNull(row.grade_pct);
672
+ if (gradePct !== null && Number.isFinite(gradePct))
673
+ return { row, grade: gradePct / 100 };
674
+ // Fallback: derive from altitude/distance delta over a short window.
675
+ const previous = array[Math.max(0, index - 1)];
676
+ const altitude = numberOrNull(row.altitude_m);
677
+ const previousAltitude = previous ? numberOrNull(previous.altitude_m) : null;
678
+ const distance = numberOrNull(row.distance_m);
679
+ const previousDistance = previous ? numberOrNull(previous.distance_m) : null;
680
+ if (altitude !== null && previousAltitude !== null && distance !== null && previousDistance !== null) {
681
+ const distanceDelta = distance - previousDistance;
682
+ if (distanceDelta > 5)
683
+ return { row, grade: (altitude - previousAltitude) / distanceDelta };
684
+ }
685
+ return { row, grade: 0 };
686
+ });
687
+ const gapEligibleSamples = movingSamples.filter(({ row }) => {
688
+ const speed = numberOrNull(row.speed_mps);
689
+ return speed !== null && speed > 0;
690
+ });
691
+ const totalMovingForGap = gapEligibleSamples.length;
692
+ if (totalMovingForGap < MIN_MOVING_SAMPLES) {
693
+ return jsonSafe({
694
+ data: null,
695
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
696
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id, sport: identity.sport },
697
+ caveats: [
698
+ `Insufficient moving samples for GAP: ${totalMovingForGap} < ${MIN_MOVING_SAMPLES}. GAP requires steady moving running samples with speed and grade/altitude.`,
699
+ ],
700
+ input: { gapEligibleSamples: totalMovingForGap, totalSamples },
701
+ });
702
+ }
703
+ const gaps = gapEligibleSamples.map(({ row, grade }) => {
704
+ const speed = numberOrNull(row.speed_mps);
705
+ const cost = runningCost(grade);
706
+ const factor = cost / 3.6;
707
+ const gapSpeed = speed * factor;
708
+ const gapPaceSPerKm = gapSpeed > 0 ? 1000 / gapSpeed : null;
709
+ const paceSPerKm = speed > 0 ? 1000 / speed : null;
710
+ return { speed, grade, cost, factor, gapSpeed, gapPaceSPerKm, paceSPerKm, altitude: numberOrNull(row.altitude_m), distance: numberOrNull(row.distance_m) };
711
+ }).filter((item) => item.gapPaceSPerKm !== null && Number.isFinite(item.gapPaceSPerKm));
712
+ if (gaps.length < MIN_VALID_SAMPLES) {
713
+ return jsonSafe({
714
+ data: null,
715
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
716
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
717
+ caveats: ['Insufficient valid GAP points after grade modeling.'],
718
+ input: { gaps: gaps.length },
719
+ });
720
+ }
721
+ const avgSpeed = mean(gaps.map((item) => item.speed));
722
+ const avgGapSpeed = mean(gaps.map((item) => item.gapSpeed));
723
+ const avgGrade = mean(gaps.map((item) => item.grade));
724
+ const avgPaceSPerKm = 1000 / avgSpeed;
725
+ const avgGapPaceSPerKm = 1000 / avgGapSpeed;
726
+ const minPace = Math.min(...gaps.map((item) => item.paceSPerKm));
727
+ const maxPace = Math.max(...gaps.map((item) => item.paceSPerKm));
728
+ const minGapPace = Math.min(...gaps.map((item) => item.gapPaceSPerKm));
729
+ const maxGapPace = Math.max(...gaps.map((item) => item.gapPaceSPerKm));
730
+ // Elevation gain/loss from altitude when available
731
+ const altitudes = samples.map((row) => numberOrNull(row.altitude_m)).filter((value) => value !== null);
732
+ let elevationGainM = null;
733
+ let elevationLossM = null;
734
+ if (altitudes.length >= 2) {
735
+ let gain = 0;
736
+ let loss = 0;
737
+ for (let index = 1; index < altitudes.length; index++) {
738
+ const delta = altitudes[index] - altitudes[index - 1];
739
+ if (delta > 0)
740
+ gain += delta;
741
+ else
742
+ loss += Math.abs(delta);
743
+ }
744
+ elevationGainM = gain;
745
+ elevationLossM = loss;
746
+ }
747
+ const distanceM = (() => {
748
+ const distances = samples.map((row) => numberOrNull(row.distance_m)).filter((value) => value !== null);
749
+ if (distances.length < 2)
750
+ return null;
751
+ return Math.max(...distances) - Math.min(...distances);
752
+ })();
753
+ // Guard walk-heavy for GAP as well.
754
+ const medianSpeed = median(gaps.map((item) => item.speed));
755
+ if (medianSpeed < WALK_SPEED_THRESHOLD_MPS) {
756
+ return jsonSafe({
757
+ data: null,
758
+ provenance: { dataset: 'activity_samples', derivation: 'derived', activitySourceId: identity.activity_source_id },
759
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id },
760
+ caveats: [
761
+ `Walk-heavy effort: median speed ${medianSpeed.toFixed(2)} m/s < ${WALK_SPEED_THRESHOLD_MPS.toFixed(1)} m/s. GAP on predominantly walking segments is not meaningful and is suppressed.`,
762
+ ],
763
+ input: { medianSpeedMps: medianSpeed, gapSamples: gaps.length },
764
+ });
765
+ }
766
+ return jsonSafe({
767
+ data: {
768
+ activityId: identity.activity_id,
769
+ activitySourceId: identity.activity_source_id,
770
+ provider: identity.provider,
771
+ sport: identity.sport,
772
+ startedAtUtc: identity.started_at_utc,
773
+ method: 'Minetti 2002 cost model',
774
+ samples: { totalSamples, movingSamplesForGap: gaps.length, expectedRunning },
775
+ averages: {
776
+ speed_mps: avgSpeed,
777
+ gap_speed_mps: avgGapSpeed,
778
+ pace_s_per_km: avgPaceSPerKm,
779
+ gap_pace_s_per_km: avgGapPaceSPerKm,
780
+ grade_pct: avgGrade * 100,
781
+ },
782
+ ranges: {
783
+ pace_s_per_km: { min: minPace, max: maxPace },
784
+ gap_pace_s_per_km: { min: minGapPace, max: maxGapPace },
785
+ },
786
+ elevation: { gain_m: elevationGainM, loss_m: elevationLossM, distance_m: distanceM },
787
+ interpretation: {
788
+ note: 'GAP re-expresses each moving second as its flat-equivalent pace for the same metabolic cost. Uphill → GAP faster than raw pace; downhill → GAP slower than raw pace.',
789
+ },
790
+ },
791
+ provenance: {
792
+ dataset: 'activity_samples',
793
+ derivation: 'derived',
794
+ model: 'Minetti et al. 2002: C(g) = 155.4 g^5 -30.4 g^4 -43.3 g^3 +46.3 g^2 +19.5 g +3.6; GAP factor = C(g)/C(0); GAP speed = speed * factor',
795
+ columns: ['speed_mps', 'altitude_m', 'grade_pct', 'distance_m', 'timestamp_utc'],
796
+ activitySourceId: identity.activity_source_id,
797
+ },
798
+ query: { activityId, resolvedActivitySourceId: identity.activity_source_id, sport: identity.sport },
799
+ caveats: [
800
+ 'Derived GAP is descriptive only; it is not a certified replacement for Strava or device GAP and is not a physiological performance model.',
801
+ 'Derived GAP never overwrites a provider-supplied grade_adjusted_pace when present; treat provider values as authoritative.',
802
+ 'Grades are clamped to ±30 % and smoothed only lightly; noisy altitude and GPS can still bias short rolling segments — prefer the averaged GAP for the whole activity over per-second values.',
803
+ ...(expectedRunning ? [] : ['Sport is not running; GAP is reported but should be interpreted cautiously outside running/trail.']),
804
+ ],
805
+ input: { totalSamples, gapSamples: gaps.length, avgGradePct: avgGrade * 100 },
806
+ });
807
+ }
808
+ async resolveActivitySource(activityId) {
809
+ const trimmed = activityId.trim();
810
+ if (!trimmed)
811
+ throw new QueryValidationError('activityId is required.');
812
+ const bySource = await this.repository.rows(`
813
+ SELECT source.activity_source_id, source.provider, activity.sport, source.activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_at_utc
814
+ FROM activity_sources AS source
815
+ JOIN activities AS activity USING (activity_id)
816
+ WHERE source.activity_source_id = $activityId
817
+ LIMIT 1
818
+ `, { activityId: trimmed });
819
+ if (bySource[0])
820
+ return bySource[0];
821
+ const byLogical = await this.repository.rows(`
822
+ SELECT source.activity_source_id, source.provider, activity.sport, source.activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_at_utc
823
+ FROM activity_sources AS source
824
+ JOIN activities AS activity USING (activity_id)
825
+ WHERE source.activity_id = $activityId
826
+ ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, source.activity_source_id ASC
827
+ LIMIT 1
828
+ `, { activityId: trimmed });
829
+ if (byLogical[0])
830
+ return byLogical[0];
831
+ throw new QueryValidationError(`No activity source matched ${trimmed}. Use find_activities to locate a valid activityId or activity_source_id.`);
832
+ }
833
+ async loadActivitySamples(activitySourceId) {
834
+ const relation = await this.repository.datasetRelation('activity_samples');
835
+ const source = sourceRelation(relation);
836
+ const rows = await this.repository.rows(`
837
+ SELECT timestamp_utc, elapsed_s, distance_m, altitude_m, heart_rate_bpm, power_w, speed_mps, grade_pct
838
+ FROM ${source} AS source
839
+ WHERE activity_source_id = $activitySourceId
840
+ ORDER BY timestamp_utc ASC NULLS LAST, elapsed_s ASC NULLS LAST
841
+ `, { activitySourceId });
842
+ return rows;
843
+ }
350
844
  }
351
845
  function numberOrNull(value) { return typeof value === 'number' && Number.isFinite(value) ? value : null; }
352
846
  function timestampText(value) { return value instanceof Date ? value.toISOString() : String(value); }
@@ -1,4 +1,4 @@
1
- export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear';
1
+ export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear';
2
2
  export type CatalogColumn = {
3
3
  name: string;
4
4
  type: string;
@@ -5,8 +5,8 @@ export const DATASET_CATALOG = {
5
5
  columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('started_at_local', 'VARCHAR'), column('timezone', 'VARCHAR'), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('link_state', 'VARCHAR', { filterable: true, groupable: true })],
6
6
  },
7
7
  canonical_activities: {
8
- name: 'canonical_activities', relation: 'canonical_activity_training', description: 'One training interpretation per linked activity; Garmin is canonical when present, while Intervals analysis values remain separately provenance-tagged.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'activity_source_id', 'provider'], permittedGroupings: ['provider', 'sport'],
9
- columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR'), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
8
+ name: 'canonical_activities', relation: 'canonical_activity_training', description: 'One training interpretation per linked activity; Garmin is canonical when present, while Intervals analysis values remain separately provenance-tagged. A NULL avg_power here does not mean no power was recorded: running/cycling power lives in power_best_facts (Garmin FIT-derived). Use power_coverage_report or read_series on activity_samples rather than inferring absence. derived_avg_power_w is the FIT-derived mean per activity (avg of power_best_facts best_power_w) when summary avg_power is sparse; prefer coalesce(avg_power, derived_avg_power_w) for coverage-aware aggregates. NULL avg_power here does not mean no power; running/cycling power is in power_best_facts (FIT-derived). Use power_coverage_report / read_series.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'activity_source_id', 'provider'], permittedGroupings: ['provider', 'sport'],
9
+ columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR'), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('derived_avg_power_w', 'DOUBLE', { unit: 'W', metric: true, description: 'FIT-derived mean power per activity (avg of power_best_facts best_power_w); complement for sparse avg_power.' }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
10
10
  },
11
11
  canonical_activity_facts: {
12
12
  name: 'canonical_activity_facts', relation: 'canonical_activity_facts', description: 'Field-level canonical activity values. Garmin is preferred when available; the original Garmin and Intervals values, selected source, disagreement, and quality flags remain visible.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'garmin_activity_source_id', 'intervals_activity_source_id', 'distance_source', 'quality_flags'], permittedGroupings: ['sport', 'link_state', 'distance_source'],
@@ -17,7 +17,7 @@ export const DATASET_CATALOG = {
17
17
  columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_activity_id', 'VARCHAR', { filterable: true }), column('external_id', 'VARCHAR', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
18
18
  },
19
19
  activity_summaries: {
20
- name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
20
+ name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance. A NULL avg_power does not mean no power; FIT-derived power is in power_best_facts. Prefer power_coverage_report or read_series over inferring absence. NULL avg_power here does not mean no power; running/cycling power is in power_best_facts (FIT-derived). Use power_coverage_report / read_series.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
21
21
  columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
22
22
  },
23
23
  activity_intervals: {
@@ -60,6 +60,10 @@ export const DATASET_CATALOG = {
60
60
  name: 'power_bests', relation: 'power_best_facts', description: 'Power-duration bests derived from Garmin FIT streams; they are labelled derived while retaining the Garmin activity provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'source_type', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'duration_s', 'source_type'],
61
61
  columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('duration_s', 'INTEGER', { filterable: true, groupable: true }), column('best_power_w', 'DOUBLE', { unit: 'W', metric: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
62
62
  },
63
+ activity_decoupling: {
64
+ name: 'activity_decoupling', relation: 'activity_decoupling_facts', description: 'Persisted per-activity aerobic decoupling (Pa:Hr, pct) and grade-adjusted pace (s/km) with provenance. source_type provider means the provider payload supplied the value (authoritative); derived values are tagged locally. Values never overwrite provider output. Not a physiological performance model.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'metric', 'source_type'],
65
+ columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('metric', 'VARCHAR', { filterable: true, groupable: true }), column('value_number', 'DOUBLE', { metric: true }), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
66
+ },
63
67
  nutrition_days: {
64
68
  name: 'nutrition_days', relation: 'nutrition_days', description: 'Daily nutrition totals, never a replacement for the item-level food log.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider'],
65
69
  columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('hydration_ml', 'DOUBLE', { unit: 'ml', metric: true }), column('raw_object_hash', 'VARCHAR')],