@pond-ts/fit 0.31.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.
- package/CHANGELOG.md +3254 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/activity/index.d.ts +254 -0
- package/dist/activity/index.js +652 -0
- package/dist/cjs-fallback.cjs +15 -0
- package/dist/geo/index.d.ts +358 -0
- package/dist/geo/index.js +864 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/intervals.d.ts +10 -0
- package/dist/intervals.js +17 -0
- package/dist/power/index.d.ts +126 -0
- package/dist/power/index.js +279 -0
- package/dist/profile/index.d.ts +125 -0
- package/dist/profile/index.js +217 -0
- package/dist/quantities.d.ts +108 -0
- package/dist/quantities.js +207 -0
- package/dist/summary/index.d.ts +138 -0
- package/dist/summary/index.js +450 -0
- package/dist/track/index.d.ts +44 -0
- package/dist/track/index.js +65 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +10 -0
- package/dist/units.d.ts +41 -0
- package/dist/units.js +69 -0
- package/dist/zones/index.d.ts +32 -0
- package/dist/zones/index.js +61 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type { GeoPoint, ActivitySource, ActivityStreams, ActivityMeta, Lap, ImportedActivity, } from './types.js';
|
|
2
|
+
export { metersToMiles, metersToFeet, formatDuration, formatPace, DEFAULT_UNITS, convertDistance, convertElevation, convertTemperature, convertSpeed, distanceUnitLabel, elevationUnitLabel, temperatureUnitLabel, speedUnitLabel, paceUnitLabel, } from './units.js';
|
|
3
|
+
export type { UnitPreferences, DistanceUnit, ElevationUnit, TemperatureUnit, SpeedPaceUnit, } from './units.js';
|
|
4
|
+
export { Distance, Elevation, Duration, Speed, Pace, Power, HeartRate, Cadence, } from './quantities.js';
|
|
5
|
+
export { Activity, Section, ProfiledActivity, ProfiledSection, } from './activity/index.js';
|
|
6
|
+
export type { Sample, SectionMetrics } from './activity/index.js';
|
|
7
|
+
export { Track } from './track/index.js';
|
|
8
|
+
export { polylineCumulative, interpolateAtDistance, polylineSlice, boundsOf, bestEffortsByDistance, segmentsInRange, } from './geo/index.js';
|
|
9
|
+
export type { Segment } from './geo/index.js';
|
|
10
|
+
export { computePower, powerBestEfforts } from './power/index.js';
|
|
11
|
+
export type { PowerBin, PowerZone, PowerCurvePoint, PowerSummary, PowerEffort, } from './power/index.js';
|
|
12
|
+
export { Profile, hydrateProfile, profileAsOf, hrZonesFrom, paceZonesFrom, powerZonesFrom, } from './profile/index.js';
|
|
13
|
+
export type { AthleteProfileJson, ScalarEntry, HrZoneEntry, PaceThresholdEntry, ZoneDef, ResolvedProfile, HydratedProfile, } from './profile/index.js';
|
|
14
|
+
export { zoneDistributionByValue, hrZoneDistribution, paceZoneDistribution, } from './zones/index.js';
|
|
15
|
+
export type { ZoneTime } from './zones/index.js';
|
|
16
|
+
export type { TrackSeries, TrackPoint, TrackColumns, Split, ProfilePoint, ProfileSample, DistanceEffort, } from './geo/index.js';
|
|
17
|
+
export { computeActivitySummary, prepareActivity, summaryFromPrepared, windowChannels, buildTrackFromStreams, type ActivitySummary, type ActivitySummaryOptions, type PreparedActivity, type WindowChannelOptions, type ChannelKey, type ChannelProfile, type ChannelSample, } from './summary/index.js';
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { metersToMiles, metersToFeet, formatDuration, formatPace, DEFAULT_UNITS, convertDistance, convertElevation, convertTemperature, convertSpeed, distanceUnitLabel, elevationUnitLabel, temperatureUnitLabel, speedUnitLabel, paceUnitLabel, } from './units.js';
|
|
2
|
+
export { Distance, Elevation, Duration, Speed, Pace, Power, HeartRate, Cadence, } from './quantities.js';
|
|
3
|
+
export { Activity, Section, ProfiledActivity, ProfiledSection, } from './activity/index.js';
|
|
4
|
+
export { Track } from './track/index.js';
|
|
5
|
+
// Curated flat surface — the four operator modules (geo / power / zones /
|
|
6
|
+
// profile) are NOT re-exported as blanket `export * as <module>` namespaces.
|
|
7
|
+
// Those published each module's entire internal surface (readColumns, raw
|
|
8
|
+
// schemas, friction-probe helpers) by accident and had no shipping consumer.
|
|
9
|
+
// The named exports below are the deliberate public API. Quantities, the
|
|
10
|
+
// Activity/Section façade, Track, Profile, and the units helpers are kept by
|
|
11
|
+
// intent — the library's headline surface plus the façade's return-type
|
|
12
|
+
// closure — not because any single consumer imports them today.
|
|
13
|
+
export { polylineCumulative, interpolateAtDistance, polylineSlice, boundsOf, bestEffortsByDistance, segmentsInRange, } from './geo/index.js';
|
|
14
|
+
export { computePower, powerBestEfforts } from './power/index.js';
|
|
15
|
+
export { Profile, hydrateProfile, profileAsOf, hrZonesFrom, paceZonesFrom, powerZonesFrom, } from './profile/index.js';
|
|
16
|
+
export { zoneDistributionByValue, hrZoneDistribution, paceZoneDistribution, } from './zones/index.js';
|
|
17
|
+
export { computeActivitySummary, prepareActivity, summaryFromPrepared, windowChannels, buildTrackFromStreams, } from './summary/index.js';
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-sample time interval `dt`, clamped to `[0, maxGap]` so recording pauses
|
|
3
|
+
* don't inflate time-weighted sums (a stop is a gap, not 20 min at the last
|
|
4
|
+
* value). Shared by every time-weighted reduction — power work/zones and the
|
|
5
|
+
* HR/pace zone distributions. Deliberate modelling choice: it engages on
|
|
6
|
+
* real sampling gaps (e.g. one 13 s gap on the Vineman ride), which is why total
|
|
7
|
+
* work still matches Strava. Conservative; not yet caller-tunable.
|
|
8
|
+
*/
|
|
9
|
+
export declare function intervals(timeSec: Float64Array, maxGap?: number): Float64Array;
|
|
10
|
+
//# sourceMappingURL=intervals.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-sample time interval `dt`, clamped to `[0, maxGap]` so recording pauses
|
|
3
|
+
* don't inflate time-weighted sums (a stop is a gap, not 20 min at the last
|
|
4
|
+
* value). Shared by every time-weighted reduction — power work/zones and the
|
|
5
|
+
* HR/pace zone distributions. Deliberate modelling choice: it engages on
|
|
6
|
+
* real sampling gaps (e.g. one 13 s gap on the Vineman ride), which is why total
|
|
7
|
+
* work still matches Strava. Conservative; not yet caller-tunable.
|
|
8
|
+
*/
|
|
9
|
+
export function intervals(timeSec, maxGap = 10) {
|
|
10
|
+
const dt = new Float64Array(timeSec.length);
|
|
11
|
+
for (let i = 1; i < timeSec.length; i++) {
|
|
12
|
+
const d = timeSec[i] - timeSec[i - 1];
|
|
13
|
+
dt[i] = d > 0 ? Math.min(d, maxGap) : 0;
|
|
14
|
+
}
|
|
15
|
+
return dt;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=intervals.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { type ZoneDef } from '../profile/index.js';
|
|
2
|
+
/** Mean of the finite samples. */
|
|
3
|
+
export declare function averagePower(watts: Float64Array): number;
|
|
4
|
+
/** Max finite sample. */
|
|
5
|
+
export declare function maxPower(watts: Float64Array): number;
|
|
6
|
+
/** Total mechanical work in kilojoules: Σ power·dt / 1000. */
|
|
7
|
+
export declare function totalWorkKj(timeSec: Float64Array, watts: Float64Array): number;
|
|
8
|
+
/**
|
|
9
|
+
* Normalized Power: 30 s rolling mean of power, raised to the 4th, averaged,
|
|
10
|
+
* 4th-rooted. The rolling step is pond-native — we build a time-keyed power
|
|
11
|
+
* series and call `rolling('30s', { watts: 'mean' })`, then read the smoothed
|
|
12
|
+
* column back via `toFloat64Array()`. The 4th-power reduction is array-side.
|
|
13
|
+
*/
|
|
14
|
+
export declare function normalizedPower(timeSec: Float64Array, watts: Float64Array): number;
|
|
15
|
+
/** Intensity Factor = NP / FTP. */
|
|
16
|
+
export declare function intensityFactor(normalizedPowerW: number, ftp: number): number;
|
|
17
|
+
/**
|
|
18
|
+
* Training load (TSS): `duration · NP · IF / (FTP · 3600) · 100`. Pass the
|
|
19
|
+
* activity's elapsed seconds (the convention that matches Strava's number).
|
|
20
|
+
*/
|
|
21
|
+
export declare function trainingLoad(normalizedPowerW: number, ftp: number, durationSeconds: number): number;
|
|
22
|
+
/** One bucket of the power histogram. */
|
|
23
|
+
export interface PowerBin {
|
|
24
|
+
/** Inclusive lower edge of the bin, watts. */
|
|
25
|
+
wattsFrom: number;
|
|
26
|
+
/** Seconds spent in this bin. */
|
|
27
|
+
seconds: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Time spent in each `binWatts`-wide power bucket — a histogram over the POWER
|
|
31
|
+
* value axis. Pond-native: `byColumn` buckets rows by the watts value and sums
|
|
32
|
+
* each bin's per-sample seconds. We emit bins from 0 up to the highest occupied
|
|
33
|
+
* (the old grid: low-power bins that
|
|
34
|
+
* happen to be empty still appear as 0 s). Sub-zero samples clamp to bin 0.
|
|
35
|
+
*/
|
|
36
|
+
export declare function powerDistribution(timeSec: Float64Array, watts: Float64Array, binWatts?: number): PowerBin[];
|
|
37
|
+
/** One FTP-based training zone. */
|
|
38
|
+
export interface PowerZone {
|
|
39
|
+
zone: number;
|
|
40
|
+
label: string;
|
|
41
|
+
minWatts: number;
|
|
42
|
+
/** Upper edge, watts; `Infinity` for the top zone. */
|
|
43
|
+
maxWatts: number;
|
|
44
|
+
seconds: number;
|
|
45
|
+
/** Fraction of total in-zone time [0, 1]. */
|
|
46
|
+
fraction: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Time in each of the 7 Coggan power zones for the given FTP. Like
|
|
50
|
+
* {@link powerDistribution} this is value-axis bucketing — here with
|
|
51
|
+
* FTP-relative edges rather than even bins.
|
|
52
|
+
*/
|
|
53
|
+
export declare function zoneDistribution(timeSec: Float64Array, watts: Float64Array, ftp: number): PowerZone[];
|
|
54
|
+
/** The 7 Coggan power zones as a watt-axis {@link ZoneDef} (FTP-relative).
|
|
55
|
+
* Delegates to {@link powerZonesFrom} — the scheme's canonical home is the
|
|
56
|
+
* profile module, alongside the HR + pace zone builders. */
|
|
57
|
+
export declare function powerZoneDef(ftp: number): ZoneDef;
|
|
58
|
+
/** A point on the mean-maximal power curve. */
|
|
59
|
+
export interface PowerCurvePoint {
|
|
60
|
+
durationSeconds: number;
|
|
61
|
+
/** Best average power sustained over any window of this length, watts. */
|
|
62
|
+
watts: number;
|
|
63
|
+
/** Inclusive sample range of the window that achieved it — for focusing the
|
|
64
|
+
* chart/map on where the peak happened. The caller maps these to distance /
|
|
65
|
+
* time via the prepared `cum` / `timeSec`. Absent only if no window qualified. */
|
|
66
|
+
startIndex?: number;
|
|
67
|
+
endIndex?: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A dense, log-spaced set of durations (1 s → `total`, ~`steps` points) so the
|
|
71
|
+
* power curve reads as a smooth line rather than a few segments. Deduped to
|
|
72
|
+
* whole seconds.
|
|
73
|
+
*/
|
|
74
|
+
export declare function logDurations(total: number, steps?: number): number[];
|
|
75
|
+
/**
|
|
76
|
+
* The mean-maximal power curve: for each duration, the highest average power
|
|
77
|
+
* sustained over any window of that length. Computed from a cumulative-work
|
|
78
|
+
* prefix sum with a two-pointer scan per duration — O(n) each. This is the
|
|
79
|
+
* sweep pond's single-window `rolling` doesn't do in one call (see friction).
|
|
80
|
+
*/
|
|
81
|
+
export declare function powerCurve(timeSec: Float64Array, watts: Float64Array, durations?: number[]): PowerCurvePoint[];
|
|
82
|
+
/** Everything the power view needs, computed from a power-equipped activity. */
|
|
83
|
+
export interface PowerSummary {
|
|
84
|
+
averageWatts: number;
|
|
85
|
+
maxWatts: number;
|
|
86
|
+
normalizedWatts: number;
|
|
87
|
+
intensityFactor: number;
|
|
88
|
+
trainingLoad: number;
|
|
89
|
+
totalWorkKj: number;
|
|
90
|
+
ftp: number;
|
|
91
|
+
/** Time per power bucket at the **finest (1 W)** resolution — the canonical
|
|
92
|
+
* base the UI re-aggregates to wider bins (10/15/25 W). Always 1 W so the
|
|
93
|
+
* display contract doesn't depend on a compute-time bin choice. */
|
|
94
|
+
distribution: PowerBin[];
|
|
95
|
+
zones: PowerZone[];
|
|
96
|
+
curve: PowerCurvePoint[];
|
|
97
|
+
}
|
|
98
|
+
/** Compute the full power summary. `elapsedSeconds` drives TSS. */
|
|
99
|
+
export declare function computePower(timeSec: Float64Array, watts: Float64Array, ftp: number, elapsedSeconds: number): PowerSummary;
|
|
100
|
+
/** Canonical durations (s) for the power best-efforts table: 5 s … 1 h. */
|
|
101
|
+
export declare const BEST_EFFORT_DURATIONS: number[];
|
|
102
|
+
/** One row of the power best-efforts table. */
|
|
103
|
+
export interface PowerEffort {
|
|
104
|
+
durationSeconds: number;
|
|
105
|
+
/** Best average power sustained over any window of this length, watts. */
|
|
106
|
+
watts: number;
|
|
107
|
+
/** Power-to-weight, when a body weight (as of the activity date) is known. */
|
|
108
|
+
wattsPerKg?: number;
|
|
109
|
+
/** Inclusive sample range of the window that achieved it — for focusing the
|
|
110
|
+
* chart/map on where the effort happened (maps to distance/time via the
|
|
111
|
+
* prepared `cum` / `timeSec`). Carried through from the {@link powerCurve}
|
|
112
|
+
* point; absent only if no window qualified. */
|
|
113
|
+
startIndex?: number;
|
|
114
|
+
endIndex?: number;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Power best efforts: the mean-maximal power at each canonical duration — the
|
|
118
|
+
* {@link powerCurve} sampled at the table's durations — plus W/kg when a body
|
|
119
|
+
* weight is supplied (resolved from the athlete profile as of the activity
|
|
120
|
+
* date). Same cumulative-work two-pointer as the curve; clamped non-increasing.
|
|
121
|
+
*/
|
|
122
|
+
export declare function powerBestEfforts(timeSec: Float64Array, watts: Float64Array, opts?: {
|
|
123
|
+
weightKg?: number;
|
|
124
|
+
durations?: number[];
|
|
125
|
+
}): PowerEffort[];
|
|
126
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Power analytics for a ride with a real power meter — normalized power, the
|
|
3
|
+
* power distribution + FTP zones, the mean-maximal power curve, work, and
|
|
4
|
+
* training load. How each maps onto pond:
|
|
5
|
+
* - **Normalized power** is a 30 s rolling mean → pond's `rolling` (see
|
|
6
|
+
* `normalizedPower`).
|
|
7
|
+
* - **Power distribution / zones** bucket over the *power value* axis (not
|
|
8
|
+
* time) → pond's `byColumn` over the watts column (see `zones`).
|
|
9
|
+
* - **The power curve** is a rolling-*mean*-then-*max* swept over MANY window
|
|
10
|
+
* sizes — pond's `rolling` is single-window, so the sweep is done here. A
|
|
11
|
+
* multi-window rolling primitive is a candidate pond enhancement.
|
|
12
|
+
*/
|
|
13
|
+
import { TimeSeries } from 'pond-ts';
|
|
14
|
+
import { intervals } from '../intervals.js';
|
|
15
|
+
import { zoneDistributionByValue } from '../zones/index.js';
|
|
16
|
+
import { powerZonesFrom } from '../profile/index.js';
|
|
17
|
+
const POWER_SCHEMA = [
|
|
18
|
+
{ name: 'time', kind: 'time' },
|
|
19
|
+
{ name: 'watts', kind: 'number' },
|
|
20
|
+
];
|
|
21
|
+
/** Schema for the value-axis power histograms (distribution + zones): the
|
|
22
|
+
* power value to bin on, plus the per-sample seconds we sum into each bin.
|
|
23
|
+
* `watts` is optional so a non-finite sample rides as `undefined` and pond's
|
|
24
|
+
* `byColumn` drops it from binning (matches the old `if (!finite) continue`). */
|
|
25
|
+
const POWER_BIN_SCHEMA = [
|
|
26
|
+
{ name: 'time', kind: 'time' },
|
|
27
|
+
{ name: 'watts', kind: 'number', required: false },
|
|
28
|
+
{ name: 'dt', kind: 'number' },
|
|
29
|
+
];
|
|
30
|
+
const finite = (v) => Number.isFinite(v);
|
|
31
|
+
/** Mean of the finite samples. */
|
|
32
|
+
export function averagePower(watts) {
|
|
33
|
+
let sum = 0;
|
|
34
|
+
let n = 0;
|
|
35
|
+
for (const w of watts)
|
|
36
|
+
if (finite(w)) {
|
|
37
|
+
sum += w;
|
|
38
|
+
n += 1;
|
|
39
|
+
}
|
|
40
|
+
return n ? sum / n : 0;
|
|
41
|
+
}
|
|
42
|
+
/** Max finite sample. */
|
|
43
|
+
export function maxPower(watts) {
|
|
44
|
+
let max = 0;
|
|
45
|
+
for (const w of watts)
|
|
46
|
+
if (finite(w) && w > max)
|
|
47
|
+
max = w;
|
|
48
|
+
return max;
|
|
49
|
+
}
|
|
50
|
+
/** Total mechanical work in kilojoules: Σ power·dt / 1000. */
|
|
51
|
+
export function totalWorkKj(timeSec, watts) {
|
|
52
|
+
const dt = intervals(timeSec);
|
|
53
|
+
let j = 0;
|
|
54
|
+
for (let i = 0; i < watts.length; i++)
|
|
55
|
+
if (finite(watts[i]))
|
|
56
|
+
j += watts[i] * dt[i];
|
|
57
|
+
return j / 1000;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Normalized Power: 30 s rolling mean of power, raised to the 4th, averaged,
|
|
61
|
+
* 4th-rooted. The rolling step is pond-native — we build a time-keyed power
|
|
62
|
+
* series and call `rolling('30s', { watts: 'mean' })`, then read the smoothed
|
|
63
|
+
* column back via `toFloat64Array()`. The 4th-power reduction is array-side.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizedPower(timeSec, watts) {
|
|
66
|
+
const rows = [];
|
|
67
|
+
for (let i = 0; i < watts.length; i++) {
|
|
68
|
+
if (finite(watts[i]))
|
|
69
|
+
rows.push([Math.round(timeSec[i] * 1000), watts[i]]);
|
|
70
|
+
}
|
|
71
|
+
if (rows.length === 0)
|
|
72
|
+
return 0;
|
|
73
|
+
const series = new TimeSeries({ name: 'power', schema: POWER_SCHEMA, rows });
|
|
74
|
+
// pond 0.29 added a `'mean'` alias matching the column API's `.mean()` (the
|
|
75
|
+
// old `'avg'`/`.mean()` split — F-reducer-naming — is resolved).
|
|
76
|
+
const rolled = series.rolling('30s', { watts: 'mean' });
|
|
77
|
+
const smoothed = rolled.column('watts').toFloat64Array();
|
|
78
|
+
let sum = 0;
|
|
79
|
+
let n = 0;
|
|
80
|
+
for (const v of smoothed)
|
|
81
|
+
if (finite(v)) {
|
|
82
|
+
sum += v ** 4;
|
|
83
|
+
n += 1;
|
|
84
|
+
}
|
|
85
|
+
return n ? (sum / n) ** 0.25 : 0;
|
|
86
|
+
}
|
|
87
|
+
/** Intensity Factor = NP / FTP. */
|
|
88
|
+
export function intensityFactor(normalizedPowerW, ftp) {
|
|
89
|
+
return ftp > 0 ? normalizedPowerW / ftp : 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Training load (TSS): `duration · NP · IF / (FTP · 3600) · 100`. Pass the
|
|
93
|
+
* activity's elapsed seconds (the convention that matches Strava's number).
|
|
94
|
+
*/
|
|
95
|
+
export function trainingLoad(normalizedPowerW, ftp, durationSeconds) {
|
|
96
|
+
if (ftp <= 0)
|
|
97
|
+
return 0;
|
|
98
|
+
const intensity = normalizedPowerW / ftp;
|
|
99
|
+
return (((durationSeconds * normalizedPowerW * intensity) / (ftp * 3600)) * 100);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Time spent in each `binWatts`-wide power bucket — a histogram over the POWER
|
|
103
|
+
* value axis. Pond-native: `byColumn` buckets rows by the watts value and sums
|
|
104
|
+
* each bin's per-sample seconds. We emit bins from 0 up to the highest occupied
|
|
105
|
+
* (the old grid: low-power bins that
|
|
106
|
+
* happen to be empty still appear as 0 s). Sub-zero samples clamp to bin 0.
|
|
107
|
+
*/
|
|
108
|
+
export function powerDistribution(timeSec, watts, binWatts = 25) {
|
|
109
|
+
const dt = intervals(timeSec);
|
|
110
|
+
const rows = [];
|
|
111
|
+
for (let i = 0; i < watts.length; i++) {
|
|
112
|
+
const w = watts[i];
|
|
113
|
+
rows.push([i, finite(w) ? Math.max(0, w) : undefined, dt[i]]);
|
|
114
|
+
}
|
|
115
|
+
const bins = new TimeSeries({
|
|
116
|
+
name: 'pdist',
|
|
117
|
+
schema: POWER_BIN_SCHEMA,
|
|
118
|
+
rows,
|
|
119
|
+
}).byColumn('watts', { width: binWatts, origin: 0 }, { seconds: { from: 'dt', using: 'sum' } });
|
|
120
|
+
// byColumn emits lowest→highest OCCUPIED bin; the old grid runs from bin 0, so
|
|
121
|
+
// scatter onto 0..maxBin with empty bins as 0 s.
|
|
122
|
+
const maxBin = bins.length
|
|
123
|
+
? Math.round(bins[bins.length - 1].start / binWatts)
|
|
124
|
+
: -1;
|
|
125
|
+
const seconds = new Array(maxBin + 1).fill(0);
|
|
126
|
+
for (const b of bins)
|
|
127
|
+
seconds[Math.round(b.start / binWatts)] = b.seconds ?? 0;
|
|
128
|
+
return seconds.map((s, b) => ({ wattsFrom: b * binWatts, seconds: s }));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Time in each of the 7 Coggan power zones for the given FTP. Like
|
|
132
|
+
* {@link powerDistribution} this is value-axis bucketing — here with
|
|
133
|
+
* FTP-relative edges rather than even bins.
|
|
134
|
+
*/
|
|
135
|
+
export function zoneDistribution(timeSec, watts, ftp) {
|
|
136
|
+
// FTP-relative Coggan zones as a watt-axis ZoneDef, then the shared value-axis
|
|
137
|
+
// engine (the same one HR + pace use — see ../zones). PowerZone keeps its
|
|
138
|
+
// watts-named shape, so the display contract is unchanged.
|
|
139
|
+
const zones = powerZoneDef(ftp);
|
|
140
|
+
return zoneDistributionByValue(watts, intervals(timeSec), zones).map((z) => ({
|
|
141
|
+
zone: z.zone,
|
|
142
|
+
label: z.label,
|
|
143
|
+
minWatts: Math.round(z.lo),
|
|
144
|
+
maxWatts: z.hi === Infinity ? Infinity : Math.round(z.hi),
|
|
145
|
+
seconds: z.seconds,
|
|
146
|
+
fraction: z.fraction,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
/** The 7 Coggan power zones as a watt-axis {@link ZoneDef} (FTP-relative).
|
|
150
|
+
* Delegates to {@link powerZonesFrom} — the scheme's canonical home is the
|
|
151
|
+
* profile module, alongside the HR + pace zone builders. */
|
|
152
|
+
export function powerZoneDef(ftp) {
|
|
153
|
+
return powerZonesFrom(ftp);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A dense, log-spaced set of durations (1 s → `total`, ~`steps` points) so the
|
|
157
|
+
* power curve reads as a smooth line rather than a few segments. Deduped to
|
|
158
|
+
* whole seconds.
|
|
159
|
+
*/
|
|
160
|
+
export function logDurations(total, steps = 140) {
|
|
161
|
+
if (total < 1 || steps < 2)
|
|
162
|
+
return [1];
|
|
163
|
+
const lnMax = Math.log(total);
|
|
164
|
+
const out = [];
|
|
165
|
+
let prev = 0;
|
|
166
|
+
for (let i = 0; i < steps; i++) {
|
|
167
|
+
const d = Math.round(Math.exp((lnMax * i) / (steps - 1)));
|
|
168
|
+
if (d > prev) {
|
|
169
|
+
out.push(d);
|
|
170
|
+
prev = d;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The mean-maximal power curve: for each duration, the highest average power
|
|
177
|
+
* sustained over any window of that length. Computed from a cumulative-work
|
|
178
|
+
* prefix sum with a two-pointer scan per duration — O(n) each. This is the
|
|
179
|
+
* sweep pond's single-window `rolling` doesn't do in one call (see friction).
|
|
180
|
+
*/
|
|
181
|
+
export function powerCurve(timeSec, watts, durations) {
|
|
182
|
+
const n = watts.length;
|
|
183
|
+
// cumulative work and time over finite samples (gap-clamped)
|
|
184
|
+
const dt = intervals(timeSec);
|
|
185
|
+
const cumWork = new Float64Array(n + 1);
|
|
186
|
+
const cumTime = new Float64Array(n + 1);
|
|
187
|
+
for (let i = 0; i < n; i++) {
|
|
188
|
+
const w = finite(watts[i]) ? watts[i] : 0;
|
|
189
|
+
cumWork[i + 1] = cumWork[i] + w * dt[i];
|
|
190
|
+
cumTime[i + 1] = cumTime[i] + dt[i];
|
|
191
|
+
}
|
|
192
|
+
const totalTime = cumTime[n];
|
|
193
|
+
const ds = durations ?? logDurations(totalTime);
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const d of ds) {
|
|
196
|
+
if (d > totalTime)
|
|
197
|
+
break;
|
|
198
|
+
let best = 0;
|
|
199
|
+
let bestLo = -1;
|
|
200
|
+
let bestHi = -1;
|
|
201
|
+
let lo = 0;
|
|
202
|
+
for (let hi = 1; hi <= n; hi++) {
|
|
203
|
+
// shrink to the SMALLEST window ending at hi whose span is still ≥ d
|
|
204
|
+
while (lo + 1 < hi && cumTime[hi] - cumTime[lo + 1] >= d)
|
|
205
|
+
lo++;
|
|
206
|
+
const span = cumTime[hi] - cumTime[lo];
|
|
207
|
+
// only a window that actually lasts ≥ d counts as a d-second effort
|
|
208
|
+
// (no sub-d slack — that would bias short windows upward at gaps)
|
|
209
|
+
if (span >= d) {
|
|
210
|
+
const avg = (cumWork[hi] - cumWork[lo]) / span;
|
|
211
|
+
if (avg > best) {
|
|
212
|
+
best = avg;
|
|
213
|
+
bestLo = lo;
|
|
214
|
+
bestHi = hi;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// mean-maximal power is non-increasing in duration by definition (best over
|
|
219
|
+
// ≥d windows ⊇ best over ≥d' windows for d'>d); clamp to kill the tiny
|
|
220
|
+
// upward blips the discrete window-snapping can produce between close
|
|
221
|
+
// durations. The window still points at THIS duration's own best stretch.
|
|
222
|
+
const prev = out[out.length - 1]?.watts ?? Infinity;
|
|
223
|
+
const point = {
|
|
224
|
+
durationSeconds: d,
|
|
225
|
+
watts: Math.min(best, prev),
|
|
226
|
+
};
|
|
227
|
+
if (bestLo >= 0) {
|
|
228
|
+
// cumWork[hi]-cumWork[lo] sums samples lo..hi-1 → inclusive [lo, hi-1].
|
|
229
|
+
point.startIndex = bestLo;
|
|
230
|
+
point.endIndex = bestHi - 1;
|
|
231
|
+
}
|
|
232
|
+
out.push(point);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
/** Compute the full power summary. `elapsedSeconds` drives TSS. */
|
|
237
|
+
export function computePower(timeSec, watts, ftp, elapsedSeconds) {
|
|
238
|
+
const np = normalizedPower(timeSec, watts);
|
|
239
|
+
return {
|
|
240
|
+
averageWatts: averagePower(watts),
|
|
241
|
+
maxWatts: maxPower(watts),
|
|
242
|
+
normalizedWatts: np,
|
|
243
|
+
intensityFactor: intensityFactor(np, ftp),
|
|
244
|
+
trainingLoad: trainingLoad(np, ftp, elapsedSeconds),
|
|
245
|
+
totalWorkKj: totalWorkKj(timeSec, watts),
|
|
246
|
+
ftp,
|
|
247
|
+
// 1 W base; the UI aggregates to its chosen bin width (see PowerSummary).
|
|
248
|
+
distribution: powerDistribution(timeSec, watts, 1),
|
|
249
|
+
zones: zoneDistribution(timeSec, watts, ftp),
|
|
250
|
+
curve: powerCurve(timeSec, watts),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Canonical durations (s) for the power best-efforts table: 5 s … 1 h. */
|
|
254
|
+
export const BEST_EFFORT_DURATIONS = [
|
|
255
|
+
5, 15, 30, 60, 120, 180, 300, 480, 600, 900, 1200, 1800, 2700, 3600,
|
|
256
|
+
];
|
|
257
|
+
/**
|
|
258
|
+
* Power best efforts: the mean-maximal power at each canonical duration — the
|
|
259
|
+
* {@link powerCurve} sampled at the table's durations — plus W/kg when a body
|
|
260
|
+
* weight is supplied (resolved from the athlete profile as of the activity
|
|
261
|
+
* date). Same cumulative-work two-pointer as the curve; clamped non-increasing.
|
|
262
|
+
*/
|
|
263
|
+
export function powerBestEfforts(timeSec, watts, opts = {}) {
|
|
264
|
+
const ds = opts.durations ?? BEST_EFFORT_DURATIONS;
|
|
265
|
+
return powerCurve(timeSec, watts, ds).map((p) => {
|
|
266
|
+
const e = {
|
|
267
|
+
durationSeconds: p.durationSeconds,
|
|
268
|
+
watts: Math.round(p.watts),
|
|
269
|
+
};
|
|
270
|
+
if (opts.weightKg && opts.weightKg > 0)
|
|
271
|
+
e.wattsPerKg = p.watts / opts.weightKg;
|
|
272
|
+
if (p.startIndex != null)
|
|
273
|
+
e.startIndex = p.startIndex;
|
|
274
|
+
if (p.endIndex != null)
|
|
275
|
+
e.endIndex = p.endIndex;
|
|
276
|
+
return e;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** One effective-dated scalar entry: "from `at`, the value was `value`." */
|
|
2
|
+
export interface ScalarEntry {
|
|
3
|
+
/** ISO 8601 date (or datetime) the value took effect. */
|
|
4
|
+
at: string;
|
|
5
|
+
value: number;
|
|
6
|
+
}
|
|
7
|
+
/** HR-zone basis: either a max HR (zones derived from %s) or explicit bounds
|
|
8
|
+
* (the four Z1/Z2, Z2/Z3, Z3/Z4, Z4/Z5 boundaries in bpm). */
|
|
9
|
+
export type HrZoneEntry = {
|
|
10
|
+
at: string;
|
|
11
|
+
maxHr: number;
|
|
12
|
+
} | {
|
|
13
|
+
at: string;
|
|
14
|
+
bounds: [number, number, number, number];
|
|
15
|
+
};
|
|
16
|
+
/** Pace-zone basis: a recent 5 k time in seconds (zones are relative to it). */
|
|
17
|
+
export interface PaceThresholdEntry {
|
|
18
|
+
at: string;
|
|
19
|
+
fiveKSeconds: number;
|
|
20
|
+
}
|
|
21
|
+
/** The whole athlete profile as stored in the vault. Every series is optional —
|
|
22
|
+
* a fresh vault has none, and each fills in as the athlete records it. */
|
|
23
|
+
export interface AthleteProfileJson {
|
|
24
|
+
weightKg?: ScalarEntry[];
|
|
25
|
+
ftpWatts?: ScalarEntry[];
|
|
26
|
+
hrZone?: HrZoneEntry[];
|
|
27
|
+
paceThreshold?: PaceThresholdEntry[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A zone scheme over a value axis: ascending `edges` (length = nZones + 1, the
|
|
31
|
+
* last a large sentinel for the open top) and the `labels` per zone (Z1 first).
|
|
32
|
+
* The axis is bpm for HR and **m/s for pace** (we bucket speed, not pace, so a
|
|
33
|
+
* stop doesn't blow the reciprocal up — Z1 = slowest). Power reuses the same
|
|
34
|
+
* shape over watts.
|
|
35
|
+
*/
|
|
36
|
+
export interface ZoneDef {
|
|
37
|
+
edges: number[];
|
|
38
|
+
labels: string[];
|
|
39
|
+
}
|
|
40
|
+
/** Resolved athlete settings for one activity date — `undefined` where the
|
|
41
|
+
* athlete hasn't recorded that series yet. */
|
|
42
|
+
export interface ResolvedProfile {
|
|
43
|
+
weightKg?: number;
|
|
44
|
+
ftpWatts?: number;
|
|
45
|
+
hrZones?: ZoneDef;
|
|
46
|
+
paceZones?: ZoneDef;
|
|
47
|
+
}
|
|
48
|
+
/** HR zone scheme (bpm axis) from a max HR or explicit bounds. */
|
|
49
|
+
export declare function hrZonesFrom(basis: {
|
|
50
|
+
maxHr: number;
|
|
51
|
+
} | {
|
|
52
|
+
bounds: number[];
|
|
53
|
+
}): ZoneDef;
|
|
54
|
+
/**
|
|
55
|
+
* Pace zone scheme over the **speed** axis (m/s, ascending) from a 5 k time.
|
|
56
|
+
* 5 k pace → boundary paces (× multiples) → boundary speeds (= dist/pace).
|
|
57
|
+
* Edges ascend in speed, so bin 0 is the slowest (Z1 Recovery) and the top bin
|
|
58
|
+
* the fastest (Z6 Anaerobic) — the labels are ordered to match.
|
|
59
|
+
*/
|
|
60
|
+
export declare function paceZonesFrom(fiveKSeconds: number): ZoneDef;
|
|
61
|
+
/** The 7 Coggan power zones as a watt-axis {@link ZoneDef}, FTP-relative. The
|
|
62
|
+
* canonical home for the scheme (HR + pace zone builders live here too); the
|
|
63
|
+
* power module's `powerZoneDef` delegates to this. */
|
|
64
|
+
export declare function powerZonesFrom(ftp: number): ZoneDef;
|
|
65
|
+
/** A hydrated, date-keyed view of one series, answering "value as of date D". */
|
|
66
|
+
interface AsOf<T> {
|
|
67
|
+
at(dateUtc: string): T | undefined;
|
|
68
|
+
}
|
|
69
|
+
/** A profile hydrated into as-of resolvers — built once per activity render. */
|
|
70
|
+
export interface HydratedProfile {
|
|
71
|
+
weightKg: AsOf<ScalarEntry>;
|
|
72
|
+
ftpWatts: AsOf<ScalarEntry>;
|
|
73
|
+
hrZone: AsOf<HrZoneEntry>;
|
|
74
|
+
paceThreshold: AsOf<PaceThresholdEntry>;
|
|
75
|
+
}
|
|
76
|
+
/** Lift the vault JSON into pond-backed as-of resolvers. */
|
|
77
|
+
export declare function hydrateProfile(json: AthleteProfileJson): HydratedProfile;
|
|
78
|
+
/** Resolve every series to the values in force on `activityDateUtc`, deriving
|
|
79
|
+
* the HR + pace zone schemes. The one call the analytics layer makes.
|
|
80
|
+
* Note: bare-date entries (`"2026-03-01"`) parse to UTC midnight, so an
|
|
81
|
+
* activity timestamped late on the prior day in a positive UTC offset can pick
|
|
82
|
+
* up a same-dated change a local day "early". Immaterial for a step-function
|
|
83
|
+
* config series; use full datetimes in `at` if you need finer alignment. */
|
|
84
|
+
export declare function profileAsOf(json: AthleteProfileJson, activityDateUtc: string): ResolvedProfile;
|
|
85
|
+
/**
|
|
86
|
+
* An athlete's settings resolved to a single activity date — the value object
|
|
87
|
+
* an {@link import('../activity/index.js').Activity} is read against via
|
|
88
|
+
* `activity.usingProfile(bob)`. Wraps the as-of resolution ({@link profileAsOf})
|
|
89
|
+
* and exposes the zone *ranges* (the per-channel {@link ZoneDef}s); the
|
|
90
|
+
* per-activity *data* (time-in-zone) is the profiled activity's `by…Zone()`.
|
|
91
|
+
*
|
|
92
|
+
* Immutable; carries only athlete data, never an activity's evidence — so one
|
|
93
|
+
* `Profile` is reused across every activity on its date.
|
|
94
|
+
*/
|
|
95
|
+
export declare class Profile {
|
|
96
|
+
private readonly resolved;
|
|
97
|
+
/** The activity date this profile was resolved as-of (ISO 8601, UTC);
|
|
98
|
+
* `undefined` for a history-less profile from {@link of}. */
|
|
99
|
+
readonly asOfDate?: string | undefined;
|
|
100
|
+
private constructor();
|
|
101
|
+
/** Resolve the athlete's stored profile to the values in force on the
|
|
102
|
+
* activity's date — the values used for FTP-relative power, W/kg, and zones. */
|
|
103
|
+
static asOf(json: AthleteProfileJson, activityDateUtc: string): Profile;
|
|
104
|
+
/** A profile from explicit settings with **no effective-dated history** — the
|
|
105
|
+
* "I just have an FTP / weight" case (demos, fixtures, a fallback prop, or a
|
|
106
|
+
* settings form with nothing recorded yet). For history-aware resolution use
|
|
107
|
+
* {@link asOf}. Power zones derive from `ftpWatts`; HR / pace zones are absent
|
|
108
|
+
* (they need a basis, which a bare settings object doesn't carry). */
|
|
109
|
+
static of(settings: {
|
|
110
|
+
ftpWatts?: number;
|
|
111
|
+
weightKg?: number;
|
|
112
|
+
}): Profile;
|
|
113
|
+
/** Functional Threshold Power (watts) in force on the date, if recorded. */
|
|
114
|
+
get ftpWatts(): number | undefined;
|
|
115
|
+
/** Body weight (kg) in force on the date, if recorded — drives W/kg. */
|
|
116
|
+
get weightKg(): number | undefined;
|
|
117
|
+
/** Heart-rate zone ranges (bpm axis), if an HR basis is recorded. */
|
|
118
|
+
get heartRateZones(): ZoneDef | undefined;
|
|
119
|
+
/** Pace zone ranges (speed axis, m/s; Z1 slowest), if a threshold is recorded. */
|
|
120
|
+
get paceZones(): ZoneDef | undefined;
|
|
121
|
+
/** Power zone ranges (watt axis, Coggan), derived from FTP if recorded. */
|
|
122
|
+
get powerZones(): ZoneDef | undefined;
|
|
123
|
+
}
|
|
124
|
+
export {};
|
|
125
|
+
//# sourceMappingURL=index.d.ts.map
|