Skip to content

description: chiptime.metrics API: analyze, detect_structure, pacing, splits, zones, and load estimators with explicit evidence bases.

Python API — metrics

The optional analytics package: from chiptime import metrics. Never imported by the core; everything is a pure function of the parsed model plus optional AthleteSettings — no state, no network, no wall clock, deterministic to the byte.

The mental model

analyze(result, settings) ──► ActivityReport ──► [WorkoutReport per session]
   │   composes public pieces you can also call directly:
   ├─ profile_for(session)          which sport language to speak
   ├─ primary_signal(session)       watts or speed, given what exists
   ├─ distance_splits(session)      km/mile splits
   ├─ detect_structure(session, messages)   intervals, with evidence
   ├─ time_in_zones(...)            only when zone bounds were provided
   └─ workout_load(session, settings)       power+ftp → hr-trimp → omitted

Three rules govern every number that comes out:

  1. Basis strings. Derived values name their evidence — a load is power+ftp or hr-trimp, structure is laps:manual or detected:power-steps. You always know what a number is standing on.
  2. Omissions over guesses. Thresholds (FTP, max HR, zones) come from your AthleteSettings or from messages inside the file — never estimated from the workout. Missing inputs produce an entry in omissions[], not a number.
  3. Compose freely. analyze is a convenience over public parts. Only want interval detection? Call detect_structure(session, result.messages) and skip the rest.

Reports

chiptime.metrics.insights.analyze

analyze(
    result: Any, settings: AthleteSettings | None = None
) -> ActivityReport

Report per session from a ParseResult (uses .activity and .messages).

chiptime.metrics.insights.analyze_session

analyze_session(
    session: Session,
    messages: list[Message] | None = None,
    settings: AthleteSettings | None = None,
) -> WorkoutReport

chiptime.metrics.insights.WorkoutReport dataclass

Everything is optional and null-honest; omissions says what was not computed and why. basis strings mark where derived numbers came from.

chiptime.metrics.insights.Insight dataclass

One notable observation: a stable machine code (see INSIGHT_CODES), a human sentence, and the numbers behind it.

Settings & zones

chiptime.metrics.settings.AthleteSettings dataclass

All fields optional; absent means "don't compute what needs it".

chiptime.metrics.zones.hr_zone_bounds

hr_zone_bounds(
    settings: AthleteSettings | None,
    messages: list[Message] | None = None,
) -> tuple[tuple[float, ...] | None, str | None]

Ascending upper bounds (bpm) + their basis ("settings" | "file:hr_zone").

chiptime.metrics.zones.power_zone_bounds

power_zone_bounds(
    settings: AthleteSettings | None,
    messages: list[Message] | None = None,
) -> tuple[tuple[float, ...] | None, str | None]

Ascending upper bounds (W) + their basis ("settings" | "file:power_zone").

Sport profiles

chiptime.metrics.sports.SportProfile dataclass

How one sport measures itself — profiles are data, not subclasses.

Analytics code branches on these fields, never on sport names, so adding a sport is a table row, not a code path.

Attributes:

Name Type Description
key str

Profile name ("running", "pool_swim", ...).

pace_style PaceStyle

How speed is presented — per_km, per_100m, per_500m, or speed (km/h).

primary Literal['power', 'speed']

Preferred intensity signal when its stream exists ("power" or "speed").

cadence_units str

Display convention (rpm, spm, strokes/min).

cadence_double_if_per_leg bool

Running heuristic — cadence below 130 is per-leg strides on many devices; doubled for display, labeled.

distance_from_lengths bool

Pool truth — distance is lengths x pool size, never GPS.

chiptime.metrics.sports.profile_for

profile_for(session: Session) -> SportProfile

Resolve (sport, sub_sport) → profile; unknown sports get GENERIC (correct-but-shallow beats wrong-but-specific).

chiptime.metrics.sports.primary_signal

primary_signal(session: Session) -> tuple[str, str | None]

The intensity signal actually available: profile preference constrained by which streams exist. Returns (kind, stream_name); kind is "power" | "speed" | "none".

chiptime.metrics.sports.cadence_display

cadence_display(
    avg_cadence: float | None, profile: SportProfile
) -> tuple[float | None, str, str | None]

(value, units, note). The doubling heuristic is labeled, never silent.

Pacing & splits

chiptime.metrics.pacing.pace_seconds

pace_seconds(
    speed_mps: float | None, style: PaceStyle
) -> float | None

Seconds per style unit; None for absent/zero speed or style "speed".

chiptime.metrics.pacing.format_pace

format_pace(
    pace_s: float | None,
    style: PaceStyle,
    *,
    suffix: bool = False,
) -> str | None

"4:20" (/km, /100m) or "1:52.5" (/500m, rowing shows tenths).

Rounding is explicit half-up (int(x + 0.5)) so Python's banker's rounding can never make two runtimes disagree on a boundary value.

chiptime.metrics.pacing.distance_splits

distance_splits(
    session: Session,
    split_m: float = 1000.0,
    *,
    style: PaceStyle = "per_km",
) -> list[Split]

Distance-domain splits from the cumulative distance stream.

Boundary crossings are linearly interpolated between records; each record's samples are attributed to the split where its step started (deterministic). No distance stream → [] (pool swims split by lengths instead — F24). HR/power averages are record-domain means (1 Hz files: time-domain too); altitude ascent/descent from consecutive present values.

chiptime.metrics.pacing.session_pace_s

session_pace_s(
    session: Session, style: PaceStyle
) -> tuple[float, str] | None

Overall pace from totals, preferring the moving denominator (research §0), falling back timer → elapsed. Returns (pace_s, basis).

chiptime.metrics.pacing.split_500m_to_watts

split_500m_to_watts(split_s: float) -> float

Concept2 published relation (see CONCEPT2_COEFF).

chiptime.metrics.pacing.watts_to_split_500m

watts_to_split_500m(watts: float) -> float

Intervals

chiptime.metrics.intervals.detect_structure

detect_structure(
    session: Session,
    messages: list[Message] | None = None,
    settings: AthleteSettings | None = None,
) -> IntervalStructure

Evidence ladder: workout steps → manual laps → swim sets → band detection → none. messages (from ParseResult.messages) unlocks the lap and workout-step rungs and pool length; without it those rungs are skipped (declared honestly in the note).

chiptime.metrics.intervals.IntervalStructure dataclass

The structure reading for one session — always with its evidence.

Attributes:

Name Type Description
basis str

Where the structure came from: steps:workout (structured workout), laps:manual (button presses), lengths:sets (pool grouping), detected:power-steps / detected:speed-steps (band detection), or none.

intervals tuple[Interval, ...]

The segments, in time order (empty for none).

repeats tuple[RepeatGroup, ...]

Grouped "N x ..." patterns among the work intervals.

note str | None

For none: the honest reason no structure was called.

chiptime.metrics.intervals.Interval dataclass

One segment of the workout, in time order.

Attributes:

Name Type Description
index int

1-based position.

kind str

work | recovery | rest | warmup | cooldown | steady.

start_time datetime | None

Segment start.

end_time datetime | None

Segment end.

duration_s float | None

Length in seconds.

distance_m float | None

Distance covered, when a distance stream exists.

avg_primary float | None

Mean of the primary signal (W or m/s) over the segment.

avg_hr float | None

Mean heart rate, when present.

lengths int | None

Pool swims — lengths in this swim; None elsewhere.

step_index int | None

Structured workouts — the wkt_step_index this lap executed; None elsewhere.

chiptime.metrics.intervals.RepeatGroup dataclass

N similar consecutive work intervals, in athlete notation.

Attributes:

Name Type Description
count int

Number of reps.

kind str

What repeats ("work").

mean_duration_s float | None

Mean rep duration.

mean_distance_m float | None

Mean rep distance, when known.

mean_primary float | None

Mean intensity across reps (W or m/s).

mean_rest_s float | None

Mean recovery between reps, when detectable.

label str

The human line — "6 x 0:30 @ 300 W rest 0:30".

first_index int

Interval.index of the first rep.

Load

chiptime.metrics.load.workout_load

workout_load(
    session: Session, settings: AthleteSettings | None
) -> LoadEstimate | None

Estimator ladder: power+ftp -> hr TRIMP -> None. A missing number beats an invented one; the report records the omission reason.

chiptime.metrics.load.LoadEstimate dataclass

A load number that says where it came from (ADR-0008 §5).

chiptime.metrics.load.weighted_avg_power

weighted_avg_power(values: list[object]) -> float | None

4th-power-weighted mean over a 30-sample rolling mean. Zeros are real (coasting) and stay in; nulls (dropouts) are skipped, never zero-filled. None when fewer than one full window of samples is present.

chiptime.metrics.load.trimp

trimp(
    times: list[datetime | None],
    hr_values: list[object],
    *,
    resting_hr: float,
    max_hr: float,
    sex: str | None = None,
) -> float | None

Banister TRIMP. sex picks the published coefficient (1.92 male / 1.67 female); unset uses the male coefficient — callers surface that in the basis string. None if the HR reserve is degenerate or no data.

chiptime.metrics.load.fitness_fatigue_form

fitness_fatigue_form(
    daily_loads: list[tuple[date, float]],
) -> list[FitnessPoint]

Impulse-response over a day series. Missing days count as 0 load. Seeds at 0 (an athlete's true starting fitness is unknowable from one archive slice — stated, not guessed).

chiptime.metrics.load.hr_coverage_fraction

hr_coverage_fraction(session: Session) -> float | None

Fraction of the session duration covered by present-HR sample pairs. None when there is no HR stream or no duration to compare against.

Basics

chiptime.metrics.mean_max

mean_max(
    values: list[Any], windows: list[int]
) -> dict[int, float | None]

Best rolling average per window size, in the RECORD domain.

At 1 Hz recording (the dominant case) record-domain == time-domain; for smart-recording files interpret windows as record counts. Windows with less than 90% data coverage return None — absence is not zero.

chiptime.metrics.time_in_zones

time_in_zones(
    times: list[datetime | None],
    values: list[Any],
    bounds: list[float],
) -> list[float]

Seconds spent per zone. Zones: (-inf, b0], (b0, b1], ..., (bn, inf) — len(bounds)+1 buckets. dt attribution per record, capped at 30 s (a gap is a gap, not an hour in zone 2). None samples contribute nowhere.

chiptime.metrics.swolf

swolf(
    session: Session,
) -> tuple[list[int | None], float | None]

Per-active-length SWOLF (strokes + seconds) and the mean over lengths where both parts are present. Pool swimming only (#73).