catence 0.2.0-beta.21 → 0.2.0-beta.24
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/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/swimming.js +2 -2
- package/dist/elt/ingestion/providers/registry.js +1 -0
- package/dist/elt/normalization/normalizers.js +96 -0
- package/dist/elt/storage/migrations.js +121 -1
- package/dist/interfaces/cli/commands.js +37 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.js +101 -34
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +3 -1
- package/package.json +1 -1
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/worker.py +45 -3
|
@@ -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.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.24";
|
|
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.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.24';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ReadOnlyRepository } from './repository.js';
|
|
2
2
|
export type FindActivitiesRequest = {
|
|
3
3
|
sports?: string[];
|
|
4
|
+
/** Accepted as a convenience alias for `sports: [sport]`. */
|
|
5
|
+
sport?: string;
|
|
4
6
|
distanceKm?: [number, number];
|
|
5
7
|
nameContains?: string[];
|
|
6
8
|
startDate?: string;
|
|
@@ -49,7 +49,7 @@ export class ActivityDiscoveryService {
|
|
|
49
49
|
this.repository = repository;
|
|
50
50
|
}
|
|
51
51
|
async findActivities(request = {}) {
|
|
52
|
-
const sports = [...new Set((request.sports ?? []).map((sport) => sport.trim()).filter(Boolean))];
|
|
52
|
+
const sports = [...new Set((request.sports ?? (request.sport ? [request.sport] : [])).map((sport) => sport.trim()).filter(Boolean))];
|
|
53
53
|
const names = [...new Set((request.nameContains ?? []).map((name) => name.trim()).filter(Boolean))];
|
|
54
54
|
if (sports.length > 12 || names.length > 12)
|
|
55
55
|
throw new QueryValidationError('sports and nameContains each permit at most 12 values.');
|
|
@@ -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;
|