@pond-ts/fit 0.52.0 → 0.53.1
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 +123 -1
- package/dist/activity/index.d.ts +7 -5
- package/dist/activity/index.js +8 -6
- package/dist/index.d.ts +1 -1
- package/dist/power/index.d.ts +58 -10
- package/dist/power/index.js +32 -12
- package/dist/zones/index.d.ts +30 -5
- package/dist/zones/index.js +45 -9
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
|
|
|
8
8
|
tag, so this file covers them all. Pre-1.0: minor bumps may include new features
|
|
9
9
|
and type-level changes; patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.53.1...HEAD
|
|
12
|
+
[0.53.1]: https://github.com/pond-ts/pond/compare/v0.53.0...v0.53.1
|
|
13
|
+
[0.53.0]: https://github.com/pond-ts/pond/compare/v0.52.0...v0.53.0
|
|
12
14
|
[0.52.0]: https://github.com/pond-ts/pond/compare/v0.51.0...v0.52.0
|
|
13
15
|
[0.51.0]: https://github.com/pond-ts/pond/compare/v0.50.0...v0.51.0
|
|
14
16
|
[0.50.0]: https://github.com/pond-ts/pond/compare/v0.49.0...v0.50.0
|
|
@@ -52,6 +54,126 @@ and type-level changes; patch bumps are strictly additive.
|
|
|
52
54
|
|
|
53
55
|
## [Unreleased]
|
|
54
56
|
|
|
57
|
+
## [0.53.1] — 2026-07-25
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **charts:** **An `<XAxis format>` again owns its own cursor / marker pills**
|
|
62
|
+
under a container with `origin` set. The elapsed axis supplies a _default_
|
|
63
|
+
finer readout (`00:05:12` under `00:05` ticks), but it was being delivered
|
|
64
|
+
through the same frame field as an explicit `cursorFormat` — so it outranked
|
|
65
|
+
an axis-level `format`, inverting the documented pill precedence
|
|
66
|
+
(`cursorFormat → axis format → container`). The visible symptom was the
|
|
67
|
+
two-strip pattern the docs recommend: a wall-clock strip declared as
|
|
68
|
+
`<XAxis format="%H:%M">` labelled its ticks `11:33` and pilled them
|
|
69
|
+
`00:05:12`. A real `cursorFormat` still outranks an axis `format`, unchanged.
|
|
70
|
+
A container **`timeFormat`** was inverted the same way one rung down — its
|
|
71
|
+
documented back-compat is to shape the readout when no `cursorFormat` is set,
|
|
72
|
+
and the elapsed default was overruling it. Fixed with the same precedence.
|
|
73
|
+
|
|
74
|
+
- **charts:** **A duration axis no longer stacks ticks on one pixel across a
|
|
75
|
+
collapsed session.** The duration ladder strides in wall-clock time, so on a
|
|
76
|
+
trading axis several ticks could land inside closed time — where the scale
|
|
77
|
+
maps all of them to the same seam pixel, stroking labels over labels and
|
|
78
|
+
gridlines over gridlines. Coinciding ticks are now dropped, so a seam shows
|
|
79
|
+
one label rather than four — and the one kept is the **last** of the group,
|
|
80
|
+
the session open that genuinely sits on that pixel (`1d 00:00`), rather than
|
|
81
|
+
the first, which falls inside the collapsed night (`12:00` = 21:30, market
|
|
82
|
+
shut). Continuous axes are unaffected (their ticks are tens of pixels apart
|
|
83
|
+
by construction).
|
|
84
|
+
|
|
85
|
+
Both found by an adversarial review of the v0.53.0 duration axis
|
|
86
|
+
([#540](https://github.com/pond-ts/pond/issues/540)), which also corrected the
|
|
87
|
+
duration-axis docs: the trading-calendar caveat described uneven spacing where
|
|
88
|
+
the real behaviour is thinning around seams, a far-off `origin` (`origin={0}`
|
|
89
|
+
on a 2026 axis ⇒ `20468d 10:00` on every tick) was undocumented, and one row of
|
|
90
|
+
the label-shape table quoted a sample spacing where it meant a tick step.
|
|
91
|
+
|
|
92
|
+
## [0.53.0] — 2026-07-25
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- **fit (breaking):** **Power bins and zones now use pond's canonical bin
|
|
97
|
+
edges**, so they feed `@pond-ts/charts` with no mapping step:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
<BarChart bins={power.distribution} column="seconds" />
|
|
101
|
+
<BarChart bins={power.zones} column="seconds" orientation="horizontal" ordinal />
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each type previously spoke its own dialect for the same concept —
|
|
105
|
+
`PowerBin.wattsFrom` (with **no upper edge at all**), `ZoneTime.lo`/`hi`, and
|
|
106
|
+
`PowerZone.minWatts`/`maxWatts` — while core's `byColumn` and charts' `BinRecord`
|
|
107
|
+
both use `{ start, end, …aggregates }`. Every caller had to hand-map before
|
|
108
|
+
drawing, even though the internals already computed the canonical shape and
|
|
109
|
+
discarded it.
|
|
110
|
+
|
|
111
|
+
**Migration** (pre-1.0, so the old names are gone rather than deprecated):
|
|
112
|
+
|
|
113
|
+
| Was | Now |
|
|
114
|
+
| --------------------- | -------------------------------------- |
|
|
115
|
+
| `PowerBin.wattsFrom` | `PowerBin.start` (+ new `end`) |
|
|
116
|
+
| `ZoneTime.lo` / `.hi` | `ZoneTime.start` / `.end`, `openEnded` |
|
|
117
|
+
| `PowerZone.minWatts` | `PowerZone.start` |
|
|
118
|
+
| `PowerZone.maxWatts` | `PowerZone.end`, `openEnded` |
|
|
119
|
+
|
|
120
|
+
Only **`PowerZone.maxWatts`** — a zone's upper edge — is affected. The
|
|
121
|
+
identically-named `PowerSummary.maxWatts` and the per-lap / per-section peak
|
|
122
|
+
power are a different concept and are unchanged.
|
|
123
|
+
|
|
124
|
+
`end` is now **always finite and always `> start`** — the guarantee core
|
|
125
|
+
enforces (`byColumn` throws on a zero-width bin) and charts need (an infinite
|
|
126
|
+
edge blows up an axis domain). The open-ended top band, which previously
|
|
127
|
+
carried only `Infinity`, gets a **drawable stand-in** edge: wide enough to
|
|
128
|
+
cover the highest value observed, and at least as wide as the band below it.
|
|
129
|
+
Treat it as a drawing bound rather than data, and test for the band with the
|
|
130
|
+
new **`openEnded`** flag rather than comparing an edge against `Infinity`
|
|
131
|
+
(`openEnded` is now also strictly positional — only the final band can carry
|
|
132
|
+
it). Rounding zone edges to whole watts no longer collapses bands at very low
|
|
133
|
+
FTPs.
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
|
|
137
|
+
- **charts:** **Duration (elapsed) x axis** — `<ChartContainer origin>` labels
|
|
138
|
+
the shared x axis as offsets from a zero point instead of absolute values, so
|
|
139
|
+
a workout / lab run / load test reads `00:00 00:05 00:10` rather than
|
|
140
|
+
`10:35 10:40 10:45`:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
<ChartContainer width={620} origin="data">
|
|
144
|
+
…
|
|
145
|
+
<XAxis label="Elapsed" />
|
|
146
|
+
</ChartContainer>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`'data'` zeroes at the start of the data (and stays there as you pan); a
|
|
150
|
+
**number** sets an explicit zero point — a gun, a trigger, a lap — with ticks
|
|
151
|
+
before it reading negative (`-00:05`). Ticks are placed at round durations
|
|
152
|
+
**measured from the origin** (a ride starting at 10:33:17 ticks 10:33:17,
|
|
153
|
+
10:38:17, …), off a clock ladder (…15s, 30s, 1m, 2m, 5m, …, 12h, then whole
|
|
154
|
+
days) rather than the 1-2-5 ladder — the part a formatter alone can't do.
|
|
155
|
+
Labels pick their shape from the step and the axis's magnitude
|
|
156
|
+
(`00:00.500` · `00:15` · `01:01:30` · `1d 12:00` · `5d`), gridlines follow the
|
|
157
|
+
same ticks, and the cursor / marker pills read one grain finer (`00:05:12`).
|
|
158
|
+
|
|
159
|
+
It's a **labelling** mode, not a data transform: `range`, `<Marker at>`,
|
|
160
|
+
`onRegionSelect`, `trackerPosition` all stay in absolute axis units. The same
|
|
161
|
+
prop works on a **value** x axis (distance travelled, not distance recorded).
|
|
162
|
+
An explicit format still wins — on a time axis a d3 _time_ specifier can only
|
|
163
|
+
describe an instant, so it labels the wall clock, which is the lever for
|
|
164
|
+
stacking a wall-clock strip under a duration strip on one shared tick set; on
|
|
165
|
+
a value axis a number specifier formats the offset. Ignored on a category
|
|
166
|
+
axis; on a trading calendar the durations are wall-clock, so ticks spanning a
|
|
167
|
+
collapsed session gap sit unevenly.
|
|
168
|
+
|
|
169
|
+
- **fit:** `computePower` takes an options object — **`{ binWatts }`** sets the
|
|
170
|
+
width of the `distribution` buckets (default `1`, unchanged). 1 W bins draw as
|
|
171
|
+
hairlines, so pass the width you intend to render rather than re-bucketing the
|
|
172
|
+
output yourself. It throws `RangeError` on a non-positive or non-finite
|
|
173
|
+
`binWatts`. New exported type `ComputePowerOptions`, also accepted by the
|
|
174
|
+
activity façade: `Activity.power(ftp, options)` and
|
|
175
|
+
`ProfiledActivity.power(options)`.
|
|
176
|
+
|
|
55
177
|
## [0.52.0] — 2026-07-23
|
|
56
178
|
|
|
57
179
|
### Changed
|
package/dist/activity/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import type { ActivityMeta, ImportedActivity } from '../types.js';
|
|
20
20
|
import type { TrackSeries, DistanceEffort } from '../geo/index.js';
|
|
21
21
|
import { type PreparedActivity, type ActivitySummary, type ActivitySummaryOptions, type WindowChannelOptions, type ChannelProfile } from '../summary/index.js';
|
|
22
|
-
import { type PowerSummary, type PowerCurvePoint, type PowerEffort, type PowerZone } from '../power/index.js';
|
|
22
|
+
import { type PowerSummary, type PowerCurvePoint, type PowerEffort, type PowerZone, type ComputePowerOptions } from '../power/index.js';
|
|
23
23
|
import { type ZoneTime } from '../zones/index.js';
|
|
24
24
|
import { Profile, type ZoneDef } from '../profile/index.js';
|
|
25
25
|
import { Distance, Elevation, Duration, Speed, Pace, Power, HeartRate, Cadence } from '../quantities.js';
|
|
@@ -145,8 +145,9 @@ export declare class Activity {
|
|
|
145
145
|
* computed from the series over that window. Clamped to the activity. */
|
|
146
146
|
range(from: Duration, to: Duration, label?: string): Section;
|
|
147
147
|
/** Power summary (NP, IF, TSS, distribution, zones, curve) at the given FTP —
|
|
148
|
-
* `undefined` when no power was recorded. `
|
|
149
|
-
|
|
148
|
+
* `undefined` when no power was recorded. `options.binWatts` sets the
|
|
149
|
+
* histogram bucket width (default 1 W). */
|
|
150
|
+
power(ftp: number, options?: ComputePowerOptions): PowerSummary | undefined;
|
|
150
151
|
/** Mean-maximal power curve; `[]` when no power. */
|
|
151
152
|
powerCurve(durations?: number[]): PowerCurvePoint[];
|
|
152
153
|
/** Power best efforts at the canonical durations (+ W/kg if `weightKg`); `[]`
|
|
@@ -190,8 +191,9 @@ export declare class ProfiledActivity {
|
|
|
190
191
|
private readonly profile;
|
|
191
192
|
constructor(activity: Activity, profile: Profile);
|
|
192
193
|
/** Full power summary (NP, IF, TSS, distribution, zones, curve) at the
|
|
193
|
-
* profile's FTP. `undefined` when no power was recorded or no FTP is known.
|
|
194
|
-
|
|
194
|
+
* profile's FTP. `undefined` when no power was recorded or no FTP is known.
|
|
195
|
+
* `options.binWatts` sets the histogram bucket width (default 1 W). */
|
|
196
|
+
power(options?: ComputePowerOptions): PowerSummary | undefined;
|
|
195
197
|
/** Time in each Coggan power zone (FTP-relative); `[]` with no power or no FTP. */
|
|
196
198
|
byPowerZone(): PowerZone[];
|
|
197
199
|
/** Time in each heart-rate zone; `[]` with no HR or no HR zones on the profile. */
|
package/dist/activity/index.js
CHANGED
|
@@ -262,15 +262,16 @@ export class Activity {
|
|
|
262
262
|
return new Section(this.metricsForRange(lo, hi, label, cols, step, timeRel));
|
|
263
263
|
}
|
|
264
264
|
/** Power summary (NP, IF, TSS, distribution, zones, curve) at the given FTP —
|
|
265
|
-
* `undefined` when no power was recorded. `
|
|
266
|
-
|
|
265
|
+
* `undefined` when no power was recorded. `options.binWatts` sets the
|
|
266
|
+
* histogram bucket width (default 1 W). */
|
|
267
|
+
power(ftp, options) {
|
|
267
268
|
const watts = this.watts();
|
|
268
269
|
if (!watts)
|
|
269
270
|
return undefined;
|
|
270
271
|
// elapsed = last − first sample, straight off the prepared relative-time axis
|
|
271
272
|
// (timeRel[n−1]); avoids forcing the full journey summary just to read TSS.
|
|
272
273
|
const elapsed = this.prep.n > 0 ? (this.prep.timeRel[this.prep.n - 1] ?? 0) : 0;
|
|
273
|
-
return computePower(this.prep.cols.timeSec, watts, ftp, elapsed);
|
|
274
|
+
return computePower(this.prep.cols.timeSec, watts, ftp, elapsed, options);
|
|
274
275
|
}
|
|
275
276
|
/** Mean-maximal power curve; `[]` when no power. */
|
|
276
277
|
powerCurve(durations) {
|
|
@@ -467,10 +468,11 @@ export class ProfiledActivity {
|
|
|
467
468
|
this.profile = profile;
|
|
468
469
|
}
|
|
469
470
|
/** Full power summary (NP, IF, TSS, distribution, zones, curve) at the
|
|
470
|
-
* profile's FTP. `undefined` when no power was recorded or no FTP is known.
|
|
471
|
-
|
|
471
|
+
* profile's FTP. `undefined` when no power was recorded or no FTP is known.
|
|
472
|
+
* `options.binWatts` sets the histogram bucket width (default 1 W). */
|
|
473
|
+
power(options) {
|
|
472
474
|
const ftp = this.profile.ftpWatts;
|
|
473
|
-
return ftp == null ? undefined : this.activity.power(ftp);
|
|
475
|
+
return ftp == null ? undefined : this.activity.power(ftp, options);
|
|
474
476
|
}
|
|
475
477
|
/** Time in each Coggan power zone (FTP-relative); `[]` with no power or no FTP. */
|
|
476
478
|
byPowerZone() {
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { Track } from './track/index.js';
|
|
|
8
8
|
export { polylineCumulative, interpolateAtDistance, polylineSlice, boundsOf, bestEffortsByDistance, segmentsInRange, } from './geo/index.js';
|
|
9
9
|
export type { Segment } from './geo/index.js';
|
|
10
10
|
export { computePower, powerBestEfforts } from './power/index.js';
|
|
11
|
-
export type { PowerBin, PowerZone, PowerCurvePoint, PowerSummary, PowerEffort, } from './power/index.js';
|
|
11
|
+
export type { PowerBin, PowerZone, PowerCurvePoint, PowerSummary, PowerEffort, ComputePowerOptions, } from './power/index.js';
|
|
12
12
|
export { Profile, hydrateProfile, profileAsOf, hrZonesFrom, paceZonesFrom, powerZonesFrom, } from './profile/index.js';
|
|
13
13
|
export type { AthleteProfileJson, ScalarEntry, HrZoneEntry, PaceThresholdEntry, ZoneDef, ResolvedProfile, HydratedProfile, } from './profile/index.js';
|
|
14
14
|
export { zoneDistributionByValue, hrZoneDistribution, paceZoneDistribution, } from './zones/index.js';
|
package/dist/power/index.d.ts
CHANGED
|
@@ -19,10 +19,22 @@ export declare function intensityFactor(normalizedPowerW: number, ftp: number):
|
|
|
19
19
|
* activity's elapsed seconds (the convention that matches Strava's number).
|
|
20
20
|
*/
|
|
21
21
|
export declare function trainingLoad(normalizedPowerW: number, ftp: number, durationSeconds: number): number;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* One bucket of the power histogram.
|
|
24
|
+
*
|
|
25
|
+
* Carries pond's canonical bin edges (`start` / `end`, watts) — the same
|
|
26
|
+
* `{ start, end, …aggregates }` shape core's `byColumn` returns — so the array
|
|
27
|
+
* feeds `@pond-ts/charts` directly:
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <BarChart bins={power.distribution} column="seconds" />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
23
33
|
export interface PowerBin {
|
|
24
34
|
/** Inclusive lower edge of the bin, watts. */
|
|
25
|
-
|
|
35
|
+
start: number;
|
|
36
|
+
/** Exclusive upper edge of the bin, watts (`start + binWatts`). */
|
|
37
|
+
end: number;
|
|
26
38
|
/** Seconds spent in this bin. */
|
|
27
39
|
seconds: number;
|
|
28
40
|
}
|
|
@@ -34,13 +46,37 @@ export interface PowerBin {
|
|
|
34
46
|
* happen to be empty still appear as 0 s). Sub-zero samples clamp to bin 0.
|
|
35
47
|
*/
|
|
36
48
|
export declare function powerDistribution(timeSec: Float64Array, watts: Float64Array, binWatts?: number): PowerBin[];
|
|
37
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* One FTP-based training zone.
|
|
51
|
+
*
|
|
52
|
+
* Like {@link PowerBin}, carries pond's canonical `start` / `end` edges so the
|
|
53
|
+
* array feeds `@pond-ts/charts` unmapped. Zone widths are very unequal, so a
|
|
54
|
+
* zone chart usually wants uniform slots rather than true watt widths:
|
|
55
|
+
*
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <BarChart bins={power.zones} column="seconds" orientation="horizontal" ordinal />
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
38
60
|
export interface PowerZone {
|
|
39
61
|
zone: number;
|
|
40
62
|
label: string;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Lower edge, watts (whole watts). Zones are **inclusive-upper**, so this
|
|
65
|
+
* edge belongs to the zone below — except on Z1, whose floor is inclusive.
|
|
66
|
+
*/
|
|
67
|
+
start: number;
|
|
68
|
+
/**
|
|
69
|
+
* Upper edge, watts — **always finite, always `> start`**. Z1–Z6 report their
|
|
70
|
+
* real edge. Z7 is open-ended and has none, so its `end` is a **drawable
|
|
71
|
+
* stand-in**, never `Infinity`: wide enough to cover the ride's peak wattage,
|
|
72
|
+
* and at least as wide as Z6. Treat it as a drawing bound, not as data.
|
|
73
|
+
*/
|
|
74
|
+
end: number;
|
|
75
|
+
/**
|
|
76
|
+
* `true` on Z7, whose real upper edge is unbounded — `end` is a drawable
|
|
77
|
+
* stand-in. Label it `"375+ W"` rather than as a range.
|
|
78
|
+
*/
|
|
79
|
+
openEnded: boolean;
|
|
44
80
|
seconds: number;
|
|
45
81
|
/** Fraction of total in-zone time [0, 1]. */
|
|
46
82
|
fraction: number;
|
|
@@ -88,15 +124,27 @@ export interface PowerSummary {
|
|
|
88
124
|
trainingLoad: number;
|
|
89
125
|
totalWorkKj: number;
|
|
90
126
|
ftp: number;
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
127
|
+
/**
|
|
128
|
+
* Time per power bucket, `binWatts` wide (default **1 W** — the finest base,
|
|
129
|
+
* which a caller can re-aggregate). Pass `binWatts` to get display-ready
|
|
130
|
+
* buckets straight out: 1 W bins draw as hairlines, so a chart usually wants
|
|
131
|
+
* 10–25 W.
|
|
132
|
+
*/
|
|
94
133
|
distribution: PowerBin[];
|
|
95
134
|
zones: PowerZone[];
|
|
96
135
|
curve: PowerCurvePoint[];
|
|
97
136
|
}
|
|
137
|
+
/** Options for {@link computePower}. */
|
|
138
|
+
export interface ComputePowerOptions {
|
|
139
|
+
/**
|
|
140
|
+
* Width of each {@link PowerSummary.distribution} bucket, watts. Defaults to
|
|
141
|
+
* `1` — the finest base. Set it to the width you intend to draw (10 / 15 /
|
|
142
|
+
* 25) rather than re-bucketing the 1 W output yourself.
|
|
143
|
+
*/
|
|
144
|
+
binWatts?: number;
|
|
145
|
+
}
|
|
98
146
|
/** Compute the full power summary. `elapsedSeconds` drives TSS. */
|
|
99
|
-
export declare function computePower(timeSec: Float64Array, watts: Float64Array, ftp: number, elapsedSeconds: number): PowerSummary;
|
|
147
|
+
export declare function computePower(timeSec: Float64Array, watts: Float64Array, ftp: number, elapsedSeconds: number, options?: ComputePowerOptions): PowerSummary;
|
|
100
148
|
/** Canonical durations (s) for the power best-efforts table: 5 s … 1 h. */
|
|
101
149
|
export declare const BEST_EFFORT_DURATIONS: number[];
|
|
102
150
|
/** One row of the power best-efforts table. */
|
package/dist/power/index.js
CHANGED
|
@@ -125,7 +125,11 @@ export function powerDistribution(timeSec, watts, binWatts = 25) {
|
|
|
125
125
|
const seconds = new Array(maxBin + 1).fill(0);
|
|
126
126
|
for (const b of bins)
|
|
127
127
|
seconds[Math.round(b.start / binWatts)] = b.seconds ?? 0;
|
|
128
|
-
return seconds.map((s, b) => ({
|
|
128
|
+
return seconds.map((s, b) => ({
|
|
129
|
+
start: b * binWatts,
|
|
130
|
+
end: (b + 1) * binWatts,
|
|
131
|
+
seconds: s,
|
|
132
|
+
}));
|
|
129
133
|
}
|
|
130
134
|
/**
|
|
131
135
|
* Time in each of the 7 Coggan power zones for the given FTP. Like
|
|
@@ -137,14 +141,25 @@ export function zoneDistribution(timeSec, watts, ftp) {
|
|
|
137
141
|
// engine (the same one HR + pace use — see ../zones). PowerZone keeps its
|
|
138
142
|
// watts-named shape, so the display contract is unchanged.
|
|
139
143
|
const zones = powerZoneDef(ftp);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// Report whole watts (Coggan edges land on halves at most FTPs), but keep the
|
|
145
|
+
// edges strictly ascending as we round: at a tiny FTP several zones round to
|
|
146
|
+
// the same integer, which would collapse a band to zero width. Each zone
|
|
147
|
+
// starts where the previous ended, so the set stays contiguous and drawable.
|
|
148
|
+
let prevEnd = -Infinity;
|
|
149
|
+
return zoneDistributionByValue(watts, intervals(timeSec), zones).map((z) => {
|
|
150
|
+
const start = prevEnd === -Infinity ? Math.round(z.start) : prevEnd;
|
|
151
|
+
const end = Math.max(Math.round(z.end), start + 1);
|
|
152
|
+
prevEnd = end;
|
|
153
|
+
return {
|
|
154
|
+
zone: z.zone,
|
|
155
|
+
label: z.label,
|
|
156
|
+
start,
|
|
157
|
+
end,
|
|
158
|
+
openEnded: z.openEnded,
|
|
159
|
+
seconds: z.seconds,
|
|
160
|
+
fraction: z.fraction,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
148
163
|
}
|
|
149
164
|
/** The 7 Coggan power zones as a watt-axis {@link ZoneDef} (FTP-relative).
|
|
150
165
|
* Delegates to {@link powerZonesFrom} — the scheme's canonical home is the
|
|
@@ -234,7 +249,13 @@ export function powerCurve(timeSec, watts, durations) {
|
|
|
234
249
|
return out;
|
|
235
250
|
}
|
|
236
251
|
/** Compute the full power summary. `elapsedSeconds` drives TSS. */
|
|
237
|
-
export function computePower(timeSec, watts, ftp, elapsedSeconds) {
|
|
252
|
+
export function computePower(timeSec, watts, ftp, elapsedSeconds, options = {}) {
|
|
253
|
+
const { binWatts = 1 } = options;
|
|
254
|
+
if (!Number.isFinite(binWatts) || binWatts <= 0) {
|
|
255
|
+
// Without this the failure surfaces from `byColumn` as a complaint about
|
|
256
|
+
// `width` — an internal the caller never named.
|
|
257
|
+
throw new RangeError(`computePower: binWatts must be a positive finite number; got ${binWatts}`);
|
|
258
|
+
}
|
|
238
259
|
const np = normalizedPower(timeSec, watts);
|
|
239
260
|
return {
|
|
240
261
|
averageWatts: averagePower(watts),
|
|
@@ -244,8 +265,7 @@ export function computePower(timeSec, watts, ftp, elapsedSeconds) {
|
|
|
244
265
|
trainingLoad: trainingLoad(np, ftp, elapsedSeconds),
|
|
245
266
|
totalWorkKj: totalWorkKj(timeSec, watts),
|
|
246
267
|
ftp,
|
|
247
|
-
|
|
248
|
-
distribution: powerDistribution(timeSec, watts, 1),
|
|
268
|
+
distribution: powerDistribution(timeSec, watts, binWatts),
|
|
249
269
|
zones: zoneDistribution(timeSec, watts, ftp),
|
|
250
270
|
curve: powerCurve(timeSec, watts),
|
|
251
271
|
};
|
package/dist/zones/index.d.ts
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
import type { ZoneDef } from '../profile/index.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* One zone's time + share.
|
|
4
|
+
*
|
|
5
|
+
* Carries pond's canonical bin edges (`start` / `end`) so the array feeds
|
|
6
|
+
* `@pond-ts/charts` (`<BarChart bins>` / `stacksFromBins`) with no mapping
|
|
7
|
+
* step — the same `{ start, end, …aggregates }` shape core's `byColumn`
|
|
8
|
+
* returns, and the same guarantee core enforces: **finite, with
|
|
9
|
+
* `end > start`.** (An infinite edge blows up a chart's axis domain; a
|
|
10
|
+
* zero-width bin is what `byColumn` itself rejects as unrepresentable.)
|
|
11
|
+
*/
|
|
3
12
|
export interface ZoneTime {
|
|
4
13
|
/** 1-based zone number (Z1 = the lowest band). */
|
|
5
14
|
zone: number;
|
|
6
15
|
label: string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Lower edge, in the value axis (watts / bpm / m·s⁻¹). Bands are
|
|
18
|
+
* **inclusive-upper** (`(start, end]`), so this edge belongs to the band
|
|
19
|
+
* below — except on Z1, whose floor is inclusive.
|
|
20
|
+
*/
|
|
21
|
+
start: number;
|
|
22
|
+
/**
|
|
23
|
+
* Upper edge — **always finite, always `> start`**. On a closed band this is
|
|
24
|
+
* the real edge. The open-ended band has none, so `end` is a **drawable
|
|
25
|
+
* stand-in**, never `Infinity`: wide enough to cover the highest value
|
|
26
|
+
* observed, and at least as wide as the band below. Treat it as a drawing
|
|
27
|
+
* bound, not as data — it can exceed anything actually recorded (and a
|
|
28
|
+
* device's out-of-range sentinel sample will stretch it).
|
|
29
|
+
*/
|
|
30
|
+
end: number;
|
|
31
|
+
/**
|
|
32
|
+
* `true` on the open-ended top band, whose real upper edge is unbounded —
|
|
33
|
+
* `end` is a drawable stand-in. Label it `"{start}+"` rather than as a range.
|
|
34
|
+
*/
|
|
35
|
+
openEnded: boolean;
|
|
11
36
|
seconds: number;
|
|
12
37
|
/** Share of total in-zone time, [0, 1]. */
|
|
13
38
|
fraction: number;
|
package/dist/zones/index.js
CHANGED
|
@@ -28,9 +28,15 @@ const SENTINEL = 1e9; // the open-top edge ZoneDef carries
|
|
|
28
28
|
export function zoneDistributionByValue(values, dt, zones) {
|
|
29
29
|
const { edges, labels } = zones;
|
|
30
30
|
const rows = [];
|
|
31
|
+
// Track the highest value actually seen (clamped the same way it's binned) —
|
|
32
|
+
// it's the finite upper edge we give the open-top zone so it can be drawn.
|
|
33
|
+
let observedMax = -Infinity;
|
|
31
34
|
for (let i = 0; i < values.length; i++) {
|
|
32
35
|
const v = values[i];
|
|
33
|
-
|
|
36
|
+
const clamped = Number.isFinite(v) ? Math.max(0, v) : undefined;
|
|
37
|
+
if (clamped !== undefined && clamped > observedMax)
|
|
38
|
+
observedMax = clamped;
|
|
39
|
+
rows.push([i, clamped, dt[i] ?? 0]);
|
|
34
40
|
}
|
|
35
41
|
const bins = new TimeSeries({
|
|
36
42
|
name: 'zones',
|
|
@@ -39,14 +45,44 @@ export function zoneDistributionByValue(values, dt, zones) {
|
|
|
39
45
|
}).byColumn('val', { edges, inclusive: '(]' }, { seconds: { from: 'dt', using: 'sum' } });
|
|
40
46
|
const secs = bins.map((b) => b.seconds ?? 0);
|
|
41
47
|
const total = secs.reduce((a, b) => a + b, 0) || 1;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const last = labels.length - 1;
|
|
49
|
+
return labels.map((label, z) => {
|
|
50
|
+
const start = edges[z];
|
|
51
|
+
const rawEnd = edges[z + 1] ?? SENTINEL;
|
|
52
|
+
// Only the FINAL band can be open-ended. The sentinel is a magnitude, so a
|
|
53
|
+
// caller whose real edges reach past it would otherwise flag an interior
|
|
54
|
+
// band open too — and then overlap the band above it.
|
|
55
|
+
const openEnded = z === last && rawEnd >= SENTINEL;
|
|
56
|
+
return {
|
|
57
|
+
zone: z + 1,
|
|
58
|
+
label,
|
|
59
|
+
start,
|
|
60
|
+
end: openEnded ? openTopEnd(start, edges, z, observedMax) : rawEnd,
|
|
61
|
+
openEnded,
|
|
62
|
+
seconds: secs[z] ?? 0,
|
|
63
|
+
fraction: (secs[z] ?? 0) / total,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A finite, drawable upper edge for the open-ended top band, which has no real
|
|
69
|
+
* one. Wide enough to cover the highest value observed, and never narrower than
|
|
70
|
+
* the band below it — a zero-width bin is what `byColumn` rejects as
|
|
71
|
+
* unrepresentable, and it would vanish from a chart even while holding time.
|
|
72
|
+
*/
|
|
73
|
+
function openTopEnd(start, edges, z, observedMax) {
|
|
74
|
+
const below = z > 0 ? start - edges[z - 1] : 0;
|
|
75
|
+
const end = Math.max(observedMax, start + (below > 0 ? below : 1));
|
|
76
|
+
// At absurd magnitudes (≥2^53) adding a width is a no-op, so fall back to the
|
|
77
|
+
// next representable double — `end > start` is a guarantee, not a best effort.
|
|
78
|
+
return end > start ? end : nextUp(start);
|
|
79
|
+
}
|
|
80
|
+
/** The smallest double strictly greater than `x` (`x >= 0`). */
|
|
81
|
+
function nextUp(x) {
|
|
82
|
+
if (x === 0)
|
|
83
|
+
return Number.MIN_VALUE;
|
|
84
|
+
const up = x * (1 + Number.EPSILON);
|
|
85
|
+
return up > x ? up : x + Math.abs(x) * Number.EPSILON * 2;
|
|
50
86
|
}
|
|
51
87
|
/** Time in each HR zone (bpm axis). `hrZones` from `profile.profileAsOf`. */
|
|
52
88
|
export function hrZoneDistribution(timeSec, heartrate, hrZones) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/fit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Fitness & activity domain library on pond-ts — quantities, canonical activity series, and analytics (geo, power, zones, splits)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"verify": "npm run format:check && npm run build && npm test"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"pond-ts": "^0.
|
|
40
|
+
"pond-ts": "^0.53.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"typescript": "^5.6.3",
|