@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.
@@ -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