Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ All notable changes to this project are documented here. The format follows
marker; in comment lines and repeat labels they are refused.
- The validation totals say "without duration or distance" instead of "open-ended", and the planned
duration of workouts with distance steps is marked as covering the timed steps only.
- Health markers (`get_recovery_snapshot`, `get_coach_context`): the note from a SICK/INJURED
entry's description (at most 120 characters, unchanged) is shown only while the entry covers the
tool's day or ended at most 2 days before it; later in the 14-day window only category, name and
dates are listed (JSON `note` null and `note_withheld` true), so a medical note is not repeated in
every output for two weeks (review R39-10).
- `get_event_by_id` names the last day of a multi-day event (Intervals.icu stores the midnight
after it as `end_date_local`): `End: 2024-12-28T00:00:00 (local; last day 2024-12-27 (Fri))`,
JSON `last_day`; single-day events are unchanged (review R39-13).
- Text outputs round stored decimals for display (review R39-14; JSON keeps the stored values):
`format_value` shows floats with 4 significant digits instead of up to 6 decimals (whole numbers
are never rounded, e.g. `2.400024` -> `2.4`, `25.666667 h` -> `25.67 h`), and known metrics use
their usual precision: the activity summary (power and HR 0 decimals, intensity, decoupling,
load, CTL/ATL, TRIMP, L/R balance and wind shares 1, VI, Power:HR, EF and stride 2, speeds 3),
the interval and group blocks of `get_activity_intervals` (e.g. `Training Load: 13.399596` ->
`13.4`, `Elevation Gain: 2.400024 meters` -> `2.4`, torque and respiration 1, variability 3),
activity thresholds (eFTP, CP, W', Pmax), wellness (CTL/ATL/ramp 1, eFTP 0, weight 2), the
recovery snapshot (CTL/ATL 1; 7-day and baseline mean, median and SD 1 for the default wellness
metrics, weight, body fat, VO2max and blood pressure, 4 significant digits for others), the
training summary's form and ramp (never `-0.0`), `get_event_by_id`'s other fields and the device
values in the report findings. A stored non-zero is never shown as 0 (it keeps one significant
digit). The activity summary's "Kilojoules" line showed `icu_joules` in J; it is now "Work: 872 kJ".

### Fixed
- `get_activity_intervals` (standard and full) and the interval groups printed a missing value as 0
Expand All @@ -79,6 +100,36 @@ All notable changes to this project are documented here. The format follows
dropped silently), so Intervals.icu exported it with a fixed duration instead of an OPEN step.
- `serverInfo.version` in the MCP `initialize` answer reports this package's version instead of the
MCP SDK version (directories that read the server directly showed `1.30.0`).
- `analyze_workout_execution` (and the plan in `get_activity_report` and
`get_activity_intervals(include_planned_types=true)`) treated the planning estimate of a lap-press
step (`until_lap_press`) as its planned length: a warm-up ended by lap after 16:01 was "-59 s vs
plan", a shorter one "shorter than planned" with every following step "started earlier than the
plan timeline", and a longer one was capped at the estimate with the rest reported as extra time
(review R36-11). Lap-press steps are now open: "ended by lap after 16:01 (planning estimate
17:00)" (JSON `lap_press`), no length deviation, no cap or split, target adherence as before, and
the plan clock restarts at the actual lap so the timed steps after it are judged from there. In
the alignment any length costs little (ties go to the length nearer the estimate). The press
closes one lap, so a lap-press step is that lap preceded only by full device auto-laps (laps of
the auto-lap length, at most 0.5 % short of the setting; without a detected layout a usual
auto-lap distance): it never reaches across a press, a step lap or the lap that starts the
workout, so riding before the workout start and after the last press stays "riding before the
plan" / "additional training after the plan" (review R40-1). With lap-press steps the riding
before or after the plan costs its fixed part once per block, so a long ride home in many
auto-laps is not moved into the plan. Ending on a full auto-lap costs extra only below the cost
of skipping a step, much more when a shorter lap that could be the press follows through laps
that fit the target, so a press lap of about the auto-lap length is matched instead of a step
being reported as not executed (review R40-2); the row then says "ended at a full device
auto-lap". A long lap-press warm-up whose auto-laps are too few for an auto-lap layout is matched
as a whole (review R40-3). Its lap boundaries are never moved, split or extended afterwards, and
its estimate no longer makes auto-laps of the same length look like lap presses. With a
recording pause the text names the elapsed and the moving time. The plan total is marked as
including the estimates (time and distance), and the report's key finding names the lap-press
steps ("length set by the lap press, not judged; targets judged"). With very many auto-laps the
pairwise merging never merges across a lap that may close a lap-press step. Known limitations are listed
in the execution guide (no press at all, a double press in two consecutive lap-press steps or at
the plan edges, riding before the workout ending exactly on an auto-lap at the warm-up's
intensity, more than about 600 auto-laps). Plans without lap-press steps are analysed exactly as
before.

## [1.0.0b1] - 2026-10-10

Expand Down
53 changes: 49 additions & 4 deletions src/intervals_mcp_server/guides/methods/execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,49 @@ matching is used by `get_activity_intervals(include_planned_types=true)` and
ride home, extra sprints) with its own metrics, kJ share, estimated load and the extra efforts it
contains (intervals at or above 90 % FTP, up to 5 listed), and is not counted against the plan;
it is reported separately when it lasts at least 120 s. The remainder of an earlier step is extra time inside the plan.
Riding before the first step is reported the same way. Steps without a duration (distance, lap
button) restart the plan clock at their actual end.
Riding before the first step is reported the same way. Steps without a duration (distance) and
lap-press steps restart the plan clock at their actual end.
- **Lap-press steps** (`until_lap_press`, "Press lap" in the workout text): the step ends when the
athlete presses the lap button; its duration or distance is only the planning estimate. The press
closes exactly one lap, so the step is that lap, preceded only by full device auto-laps. A full
auto-lap is a lap of the auto-lap length, at most 0.5 % below the device setting: a lap closed by a
press is usually shorter. Without a detected auto-lap layout, a lap of a usual auto-lap distance
also counts (200 m to 20 km, or 0.1 to 10 miles, up to 1.5 % over), when two laps have it. A single
such lap, or inside the plan any one lap, can be the step's first lap when it brings the length
nearer to the estimate (a double press, devices whose auto-laps do not restart at a press).
- **Never across a press**: the step never reaches across another lap: a press, a step lap, the
lap that starts the workout. Riding before the workout start and after the last press is
reported as riding before the plan or additional training after it. With lap-press steps the
riding before or after the plan costs its fixed part once per block, so a long ride home in
many auto-laps is not moved into the plan. The first step takes all full auto-laps back to the
workout start; a lap well off its target there counts as riding before the plan.
- **Length**: in the alignment any length costs little; lengths nearer the estimate win ties.
- **Ending on a full auto-lap** costs extra: much more when a shorter lap that could be the press
follows through laps that fit the target (the press came later), less otherwise (a press at
about the auto-lap length, or no press). Then the row says "ended at a full device auto-lap".
- **Fixed boundaries**: the step's lap boundaries are never moved, split or extended afterwards.
- **No length deviation**: it is never capped or reported as shorter/longer than planned.
- **Row text**: "ended by lap after <actual> (planning estimate <estimate>)", in elapsed time,
and with a recording pause also the moving time. "Ended with the recording" when it is the last
step and the activity ends there.
- **JSON `lap_press`**: `ended_by` (`lap`, `auto-lap` or `end of recording`), `elapsed_s`,
`moving_time_s`, `estimate_s` or `estimate_m`, and `actual_m`.
- **Metrics**: target adherence, time in target and the other metrics are evaluated as usual.
- **Following steps**: they are judged from the actual lap (start shift and durations), so a
longer or shorter lap-press step never shifts the timed steps after it.
- **Plan total**: marked "≈" with the planning estimates it includes (JSON `lap_press_steps`,
`lap_press_estimate_s`, `lap_press_estimate_m`).
- **Known limitations**:
- Workout not run on the device (auto-laps only, no press): the step ends at some auto-lap near
a fitting length and the neighbouring timed steps can be off.
- Two lap-press steps with an extra lap inside one of them (a double press, or a lap split by
Intervals.icu): the steps can be split at the wrong lap. In the first or last step the extra
lap is reported as riding before or after the plan.
- Riding before the workout that ends exactly on an auto-lap at the first step's intensity: no
lap marks the workout start, and the riding counts as part of the first step.
- With more than about 600 auto-laps, laps are merged pairwise for the matching (never across a
lap that may close a lap-press step), so boundaries can be off by a merged lap and a short step
can be missed.
- **Per step**: planned vs actual duration on moving time (recording pauses excluded; distance
steps on distance), the target range (resolved to W, bpm or pace), the actual average, below / in
/ above target with the offset from the exact range, time within the target range (±5 %), HR at
Expand Down Expand Up @@ -105,6 +146,9 @@ matching is used by `get_activity_intervals(include_planned_types=true)` and
at least 30 s). Each interval gets the planned step it belongs to; the Intervals.icu WORK /
RECOVERY type is kept as stored and marked "type differs from the plan" when the planned step is
work and the interval is not WORK, or the planned step is rest and the interval is not RECOVERY.
- A lap-press step is open: its intervals get `lap_press`, `estimate_s` / `estimate_m` and the
text "until lap press, ended by lap after <actual> (planning estimate <estimate>)", never
`beyond_plan_s`.
- A matched span (one or several consecutive intervals) longer than the planned duration plus the
tolerance gets `beyond_plan_s` on each of its intervals, counted as in `analyze_workout_execution`:
after the last matched step as additional training after the plan, earlier as extra time inside
Expand All @@ -123,5 +167,6 @@ matching is used by `get_activity_intervals(include_planned_types=true)` and
targets in the unit of the targets (power, HR or pace; clearly different targets, e.g. an easy
block and strides, listed separately), work steps within ±5 % and inside the exact range, the
mean time in target, steps with clear deviations, and the additional training after the plan
(duration, work share, extra efforts). When the plan comparison was skipped, the finding says
so instead of "0/0 steps".
(duration, work share, extra efforts). Lap-press steps are named with their actual length and
planning estimate (length set by the lap press, not judged; their targets are judged). When the
plan comparison was skipped, the finding says so instead of "0/0 steps".
10 changes: 8 additions & 2 deletions src/intervals_mcp_server/guides/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ workout format: `intervals://workout-syntax`.
or as a change.
- Health markers (`get_recovery_snapshot`, `get_coach_context`, text and JSON `health_markers`):
calendar entries of the categories SICK and INJURED that lie on the tool's day (the snapshot day,
the coach context's end date) or ended in the 14 days before it, with first and last day, name and
a short note from the description (both on one line, in quotes), and the wellness `injury` score
the coach context's end date) or ended in the 14 days before it, with first and last day and name,
plus a short note from the description (at most 120 characters) only while the entry covers that
day or ended at most 2 days before it (both on one line, in quotes; later JSON `note_withheld`),
and the wellness `injury` score
when it is 3 (Poor) or 4 (Injured), as stored with its 1-4 scale: the day's value or, when the day
has none yet (a subjective score is often entered later), the value of the day before, labelled
with its date. Entries are recognised by their category only, never by the title.
Expand Down Expand Up @@ -193,9 +195,13 @@ Short form in the server instructions; the reasons and details:
weekday (time zone)` and label listed days with weekday and relative day. Intervals.icu stores the
end of a multi-day calendar entry (sick, injured, holiday, plan phase) as the midnight after its
last day (`end_date_local`, shown as stored by `get_events` and `get_event_by_id`);
`get_event_by_id` adds the last day of a multi-day entry (text, and `last_day` in JSON),
`get_training_plan` (text, and `first_day`/`last_day` in JSON) and the health markers show the last
day itself.
- Times are local (time zone name when stored, else the UTC offset) and UTC.
- Text outputs round stored decimals for display: known metrics to their usual precision (e.g.
load, CTL/ATL and decoupling 1 decimal, ratios such as VI or EF 2, speeds in m/s 3), other values
to 4 significant digits; a stored non-zero is never shown as 0. JSON keeps the stored values.
- Run/walk/hike cadence in steps per minute (spm = 2 x the stored per-leg value, shown as
stored), bike cadence in rpm; temperatures in °C.
- "no value" = null/NaN; a 0 in a device-file field may mean the source field was absent;
Expand Down
5 changes: 5 additions & 0 deletions src/intervals_mcp_server/guides/workout-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ For outdoor sessions whose next step should start at a suitable place (a climb,
- `validate_workout`/`preview_workout` count the lap-press steps (each step of a repeat block once,
plus the number of runs) and their estimate; after a write the read-back reports the same count
and warns when Intervals.icu stored a lap-press step as a timed step (or the reverse).
- After the ride, `analyze_workout_execution` (and `get_activity_report`,
`get_activity_intervals(include_planned_types=true)`) treats a lap-press step as open: "ended by
lap after 16:01 (planning estimate 17:00)", no shorter/longer deviation, its targets still
judged, and the following steps are compared from the actual lap. Riding before the workout
start or after the last press stays outside the plan (intervals://methods/execution).

## Native workout text (`description`)

Expand Down
7 changes: 6 additions & 1 deletion src/intervals_mcp_server/tools/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
event_type_label,
format_event_details,
format_event_summary,
multi_day_last_day,
)
from intervals_mcp_server.utils.sports import hms
from intervals_mcp_server.utils.types import WorkoutDoc
Expand Down Expand Up @@ -280,7 +281,11 @@ async def get_event_by_id(
return f"Invalid event format for event {event_id}."

if output_format.strip().lower() == "json":
return json.dumps(_event_json(result, include_workout_doc=True), ensure_ascii=False)
row = _event_json(result, include_workout_doc=True)
last_day = multi_day_last_day(result)
if last_day is not None: # end_date_local is the midnight after the last day
row["last_day"] = last_day.isoformat()
return json.dumps(row, ensure_ascii=False)

return format_event_details(result)

Expand Down
15 changes: 11 additions & 4 deletions src/intervals_mcp_server/tools/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
wprime_summary,
)
from intervals_mcp_server.utils.dates import as_day, athlete_today, dated, relative_day, today_anchor, today_json
from intervals_mcp_server.utils.custom_fields import ACTIVITY_FIELD, ACTIVITY_STREAM, INTERVAL_FIELD, assigned_codes, is_missing
from intervals_mcp_server.utils.execution import analyze, format_execution, plan_steps, target_text
from intervals_mcp_server.utils.custom_fields import ACTIVITY_FIELD, ACTIVITY_STREAM, INTERVAL_FIELD, assigned_codes, format_value, is_missing
from intervals_mcp_server.utils.execution import analyze, format_execution, lap_press_text, plan_steps, target_text
from intervals_mcp_server.utils.field_policy import aggregation_policy, start_end_pairs
from intervals_mcp_server.utils.fueling import activity_fueling
from intervals_mcp_server.utils.params import ActivityId, DetailLevel, OutputFormat
Expand All @@ -52,6 +52,7 @@
CORE_STREAMS = ("time", "watts", "heartrate", "cadence", "velocity_smooth", "distance", "altitude", "secondary_power")
MAX_CLIMBS = 6
MAX_FINDINGS = 5
MAX_LAP_PRESS_FINDINGS = 3 # lap-press steps named in the plan finding
STANDARD_MAX_INTERVALS = 30
MIN_POWER_PAIRS = 300 # five minutes of valid paired samples before two power meters are compared
IDENTICAL_SHARE = 0.99
Expand Down Expand Up @@ -191,6 +192,12 @@ def _execution_findings(execution: dict[str, Any]) -> list[str]:
text += f", mean time in target {summary['work_time_in_target_pct_mean']:.0f}%"
text += ")"
text += f"; steps with clear deviations: {summary.get('steps_with_deviations', 0)}"
lap_rows = [r for r in rows if r.get("lap_press")]
if lap_rows:
listed = ", ".join(f"plan {r['planned']['index']} {r['planned']['kind']} {lap_press_text(r['lap_press'])}"
for r in lap_rows[:MAX_LAP_PRESS_FINDINGS])
more = f" and {len(lap_rows) - MAX_LAP_PRESS_FINDINGS} more" if len(lap_rows) > MAX_LAP_PRESS_FINDINGS else ""
text += f"; lap-press steps (length set by the lap press, not judged; targets judged): {listed}{more}"
findings.append(text)
extension = execution.get("extension")
if extension:
Expand Down Expand Up @@ -230,9 +237,9 @@ def _field_findings(activity: dict[str, Any], field_defs: dict[str, Any], assign
first, last = _num(activity.get(start_code)), _num(activity.get(end_code))
if first is not None and last is not None:
units = defs[start_code].get("units") or ""
findings.append(f"{stem.capitalize()} {first:g} → {last:g} {units}".rstrip())
findings.append(f"{stem.capitalize()} {format_value(first)} → {format_value(last)} {units}".rstrip())
loads = [
f"{d['name']} {activity[c]:g}" for c, d in defs.items()
f"{d['name']} {format_value(activity[c])}" for c, d in defs.items()
if aggregation_policy(d)["policy"] == "device_load_sum" and _num(activity.get(c)) not in (None, 0.0) and "load" in str(d["name"]).lower()
]
if activity.get("icu_training_load") is not None:
Expand Down
Loading
Loading