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
119 changes: 39 additions & 80 deletions garminconnect/workout.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from __future__ import annotations

from contextlib import suppress
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, Self
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self

if TYPE_CHECKING:
from pydantic import BaseModel, ConfigDict, Field, model_validator
Expand Down Expand Up @@ -314,28 +314,12 @@ class StrengthWorkout(BaseWorkout):
)


class IntensityTarget(Protocol):
"""Protocol for power, heart rate, speed and pace targets."""
class _RangeTarget(BaseModel):
"""Base for targets defined by a lower and upper limit."""

target_type: ClassVar[int]
target_type_key: ClassVar[str]
zone_number: ClassVar[Literal[None]] = None
lower_limit: float
upper_limit: float


class ZonedIntensityTarget(Protocol):
"""Protocol for power zone and heart rate zone targets."""

target_type: ClassVar[int]
target_type_key: ClassVar[str]
upper_limit: ClassVar[Literal[None]] = None
lower_limit: ClassVar[Literal[None]] = None
zone_number: int


class _RangeTarget(BaseModel):
zone_number: ClassVar = None
zone_number: ClassVar[None] = None
lower_limit: float
upper_limit: float

Expand All @@ -346,6 +330,16 @@ def _check_order(self) -> Self:
return self


class _ZoneTarget(BaseModel):
"""Base for targets defined by one of the user's configured zones."""

target_type: ClassVar[int]
target_type_key: ClassVar[str]
lower_limit: ClassVar[None] = None
upper_limit: ClassVar[None] = None
zone_number: int


class CadenceTarget(_RangeTarget):
"""Cadence target.

Expand All @@ -354,19 +348,13 @@ class CadenceTarget(_RangeTarget):

target_type: ClassVar[int] = TargetType.CADENCE
target_type_key: ClassVar[str] = "cadence"
zone_number: ClassVar = None
lower_limit: float
upper_limit: float


class PowerZoneTarget(BaseModel):
class PowerZoneTarget(_ZoneTarget):
"""Power Zone target."""

target_type: ClassVar[int] = TargetType.POWER_ZONE
target_type_key: ClassVar[str] = "power.zone"
upper_limit: ClassVar = None
lower_limit: ClassVar = None
zone_number: int


class CustomPowerTarget(_RangeTarget):
Expand All @@ -377,19 +365,13 @@ class CustomPowerTarget(_RangeTarget):

target_type: ClassVar[int] = TargetType.POWER_ZONE
target_type_key: ClassVar[str] = "power.zone"
zone_number: ClassVar = None
lower_limit: float
upper_limit: float


class HeartRateZoneTarget(BaseModel):
class HeartRateZoneTarget(_ZoneTarget):
"""Heart Rate Zone target."""

target_type: ClassVar[int] = TargetType.HEART_RATE_ZONE
target_type_key: ClassVar[str] = "heart.rate.zone"
upper_limit: ClassVar = None
lower_limit: ClassVar = None
zone_number: int


class CustomHeartRateTarget(_RangeTarget):
Expand All @@ -400,9 +382,6 @@ class CustomHeartRateTarget(_RangeTarget):

target_type: ClassVar[int] = TargetType.HEART_RATE_ZONE
target_type_key: ClassVar[str] = "heart.rate.zone"
zone_number: ClassVar = None
lower_limit: float
upper_limit: float


class SpeedTarget(_RangeTarget):
Expand All @@ -413,22 +392,20 @@ class SpeedTarget(_RangeTarget):

target_type: ClassVar[int] = TargetType.SPEED_ZONE
target_type_key: ClassVar[str] = "speed.zone"
zone_number: ClassVar = None
lower_limit: float
upper_limit: float


class PaceTarget(_RangeTarget):
"""Pace target.

upper and lower limits in m/s
upper and lower limits in m/s (use ``pace_to_mps``), so the slower pace is
the lower limit
"""

target_type: ClassVar[int] = TargetType.PACE_ZONE
target_type_key: ClassVar[str] = "pace.zone"
zone_number: ClassVar = None
lower_limit: float
upper_limit: float


IntensityTarget = _RangeTarget | _ZoneTarget


def pace_to_mps(minutes: int, seconds: int, units: Literal["km", "mi"]) -> float:
Expand Down Expand Up @@ -491,7 +468,7 @@ def create_interval_step(
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 1,
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.TIME,
Expand All @@ -504,26 +481,21 @@ def create_interval_step(
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 3,
"displayOrder": 1,
},
targetValueOne=target_value_one,
targetValueTwo=target_value_two,
zoneNumber=zone_number,
secondaryTargetType=secondary_target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 4,
},
secondaryTargetType=secondary_target_type,
secondaryTargetValueOne=secondary_target_value_one,
secondaryTargetValueTwo=secondary_target_value_two,
secondaryZoneNumber=secondary_zone_number,
)


def _target_kwargs(
target: IntensityTarget | ZonedIntensityTarget,
secondary_target: IntensityTarget | ZonedIntensityTarget | None,
target: IntensityTarget,
secondary_target: IntensityTarget | None,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"target_type": {
Expand Down Expand Up @@ -552,17 +524,13 @@ def _target_kwargs(
def create_targeted_interval_step(
duration_seconds: float,
step_order: int,
target: IntensityTarget | ZonedIntensityTarget,
secondary_target: IntensityTarget | ZonedIntensityTarget | None = None,
target: IntensityTarget,
secondary_target: IntensityTarget | None = None,
) -> ExecutableStep:
"""Create a targeted interval step."""
kwargs = {
"duration_seconds": duration_seconds,
"step_order": step_order,
}
kwargs |= _target_kwargs(target, secondary_target)

return create_interval_step(**kwargs) # type: ignore[arg-type]
return create_interval_step(
duration_seconds, step_order, **_target_kwargs(target, secondary_target)
)


def create_distance_interval_step(
Expand All @@ -583,7 +551,7 @@ def create_distance_interval_step(
stepType={
"stepTypeId": StepType.INTERVAL,
"stepTypeKey": "interval",
"displayOrder": 1,
"displayOrder": 3,
},
endCondition={
"conditionTypeId": ConditionType.DISTANCE,
Expand All @@ -596,17 +564,12 @@ def create_distance_interval_step(
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 3,
"displayOrder": 1,
},
targetValueOne=target_value_one,
targetValueTwo=target_value_two,
zoneNumber=zone_number,
secondaryTargetType=secondary_target_type
or {
"workoutTargetTypeId": TargetType.NO_TARGET,
"workoutTargetTypeKey": "no.target",
"displayOrder": 4,
},
secondaryTargetType=secondary_target_type,
secondaryTargetValueOne=secondary_target_value_one,
secondaryTargetValueTwo=secondary_target_value_two,
secondaryZoneNumber=secondary_zone_number,
Expand All @@ -616,17 +579,13 @@ def create_distance_interval_step(
def create_targeted_distance_interval_step(
distance_meters: float,
step_order: int,
target: IntensityTarget | ZonedIntensityTarget,
secondary_target: IntensityTarget | ZonedIntensityTarget | None = None,
target: IntensityTarget,
secondary_target: IntensityTarget | None = None,
) -> ExecutableStep:
"""Create a targeted interval step that ends after a distance in meters."""
kwargs = {
"distance_meters": distance_meters,
"step_order": step_order,
}
kwargs |= _target_kwargs(target, secondary_target)

return create_distance_interval_step(**kwargs) # type: ignore[arg-type]
return create_distance_interval_step(
distance_meters, step_order, **_target_kwargs(target, secondary_target)
)


def create_recovery_step(
Expand Down
16 changes: 14 additions & 2 deletions test_data/sample_cycling_workout.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Sample cycling workout data using typed workout models."""

from garminconnect.workout import (
CadenceTarget,
CustomPowerTarget,
CyclingWorkout,
WorkoutSegment,
create_cooldown_step,
create_interval_step,
create_recovery_step,
create_repeat_group,
create_targeted_interval_step,
create_warmup_step,
)

Expand All @@ -30,7 +32,17 @@ def create_sample_cycling_workout() -> CyclingWorkout:
create_repeat_group(
iterations=5,
workout_steps=[
create_interval_step(300.0, step_order=2), # 5 min interval
# 5 min interval at 220-250 W, 85-95 rpm
create_targeted_interval_step(
300.0,
step_order=2,
target=CustomPowerTarget(
lower_limit=220, upper_limit=250
),
secondary_target=CadenceTarget(
lower_limit=85, upper_limit=95
),
),
create_recovery_step(180.0, step_order=3), # 3 min recovery
],
step_order=2,
Expand Down
15 changes: 13 additions & 2 deletions test_data/sample_running_workout.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Sample running workout data using typed workout models."""

from garminconnect.workout import (
PaceTarget,
RunningWorkout,
WorkoutSegment,
create_cooldown_step,
create_interval_step,
create_recovery_step,
create_repeat_group,
create_targeted_interval_step,
create_warmup_step,
pace_to_mps,
)


Expand All @@ -30,7 +32,16 @@ def create_sample_running_workout() -> RunningWorkout:
create_repeat_group(
iterations=6,
workout_steps=[
create_interval_step(60.0, step_order=2), # 1 min interval
# 1 min interval at 4:30-4:50 min/km (limits in m/s,
# so the slower pace is the lower limit)
create_targeted_interval_step(
60.0,
step_order=2,
target=PaceTarget(
lower_limit=pace_to_mps(4, 50, "km"),
upper_limit=pace_to_mps(4, 30, "km"),
),
),
create_recovery_step(60.0, step_order=3), # 1 min recovery
],
step_order=2,
Expand Down
Loading
Loading