diff --git a/garminconnect/workout.py b/garminconnect/workout.py index a6c95c82..37b72756 100644 --- a/garminconnect/workout.py +++ b/garminconnect/workout.py @@ -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 @@ -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 @@ -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. @@ -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): @@ -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): @@ -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): @@ -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: @@ -491,7 +468,7 @@ def create_interval_step( stepType={ "stepTypeId": StepType.INTERVAL, "stepTypeKey": "interval", - "displayOrder": 1, + "displayOrder": 3, }, endCondition={ "conditionTypeId": ConditionType.TIME, @@ -504,17 +481,12 @@ 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, @@ -522,8 +494,8 @@ def create_interval_step( 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": { @@ -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( @@ -583,7 +551,7 @@ def create_distance_interval_step( stepType={ "stepTypeId": StepType.INTERVAL, "stepTypeKey": "interval", - "displayOrder": 1, + "displayOrder": 3, }, endCondition={ "conditionTypeId": ConditionType.DISTANCE, @@ -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, @@ -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( diff --git a/test_data/sample_cycling_workout.py b/test_data/sample_cycling_workout.py index 32b70245..bd1bcc93 100644 --- a/test_data/sample_cycling_workout.py +++ b/test_data/sample_cycling_workout.py @@ -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, ) @@ -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, diff --git a/test_data/sample_running_workout.py b/test_data/sample_running_workout.py index 96b83e98..21902944 100644 --- a/test_data/sample_running_workout.py +++ b/test_data/sample_running_workout.py @@ -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, ) @@ -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, diff --git a/tests/test_workout_targets.py b/tests/test_workout_targets.py new file mode 100644 index 00000000..93cf35a7 --- /dev/null +++ b/tests/test_workout_targets.py @@ -0,0 +1,202 @@ +"""Tests for workout intensity targets and targeted interval steps.""" + +import pytest +from pydantic import ValidationError + +from garminconnect.workout import ( + CadenceTarget, + ConditionType, + CustomHeartRateTarget, + CustomPowerTarget, + CyclingWorkout, + HeartRateZoneTarget, + PaceTarget, + PowerZoneTarget, + SpeedTarget, + StepType, + TargetType, + WorkoutSegment, + create_distance_interval_step, + create_interval_step, + create_targeted_distance_interval_step, + create_targeted_interval_step, + pace_to_mps, + speed_to_mps, +) + + +def _dump(step): + return step.model_dump(exclude_none=True, mode="json") + + +def test_untargeted_interval_step_payload_unchanged(): + """Interval steps without targets upload exactly as before targets existed.""" + assert _dump(create_interval_step(300.0, step_order=2)) == { + "type": "ExecutableStepDTO", + "stepOrder": 2, + "stepType": { + "stepTypeId": StepType.INTERVAL, + "stepTypeKey": "interval", + "displayOrder": 3, + }, + "endCondition": { + "conditionTypeId": ConditionType.TIME, + "conditionTypeKey": "time", + "displayOrder": 2, + "displayable": True, + }, + "endConditionValue": 300.0, + "targetType": { + "workoutTargetTypeId": TargetType.NO_TARGET, + "workoutTargetTypeKey": "no.target", + "displayOrder": 1, + }, + } + + +def test_untargeted_distance_interval_step_payload_unchanged(): + """Distance interval steps without targets upload exactly as before.""" + data = _dump(create_distance_interval_step(600.0, step_order=1)) + assert data["stepType"]["displayOrder"] == 3 + assert data["targetType"] == { + "workoutTargetTypeId": TargetType.NO_TARGET, + "workoutTargetTypeKey": "no.target", + "displayOrder": 1, + } + for key in ( + "targetValueOne", + "targetValueTwo", + "zoneNumber", + "secondaryTargetType", + "secondaryTargetValueOne", + "secondaryTargetValueTwo", + "secondaryZoneNumber", + ): + assert key not in data + + +@pytest.mark.parametrize( + ("target", "type_id", "type_key"), + [ + (CadenceTarget(lower_limit=170, upper_limit=180), 3, "cadence"), + (CustomPowerTarget(lower_limit=200, upper_limit=250), 2, "power.zone"), + ( + CustomHeartRateTarget(lower_limit=140, upper_limit=155), + 4, + "heart.rate.zone", + ), + (SpeedTarget(lower_limit=8.0, upper_limit=9.0), 5, "speed.zone"), + (PaceTarget(lower_limit=2.7, upper_limit=2.8), 6, "pace.zone"), + ], +) +def test_range_target_sets_type_and_limits(target, type_id, type_key): + """Range targets send their type plus lower/upper limits, no zone number.""" + data = _dump(create_targeted_interval_step(120.0, step_order=1, target=target)) + assert data["targetType"] == { + "workoutTargetTypeId": type_id, + "workoutTargetTypeKey": type_key, + "displayOrder": 1, + } + assert data["targetValueOne"] == target.lower_limit + assert data["targetValueTwo"] == target.upper_limit + assert "zoneNumber" not in data + assert "secondaryTargetType" not in data + + +@pytest.mark.parametrize( + ("target", "type_id", "type_key"), + [ + (PowerZoneTarget(zone_number=3), 2, "power.zone"), + (HeartRateZoneTarget(zone_number=2), 4, "heart.rate.zone"), + ], +) +def test_zone_target_sets_zone_number_only(target, type_id, type_key): + """Zone targets send the zone number and no limits.""" + data = _dump(create_targeted_interval_step(120.0, step_order=1, target=target)) + assert data["targetType"]["workoutTargetTypeId"] == type_id + assert data["targetType"]["workoutTargetTypeKey"] == type_key + assert data["zoneNumber"] == target.zone_number + assert "targetValueOne" not in data + assert "targetValueTwo" not in data + + +def test_secondary_target(): + """A secondary target is sent in the secondary* fields.""" + step = create_targeted_interval_step( + 120.0, + step_order=1, + target=CustomPowerTarget(lower_limit=200, upper_limit=250), + secondary_target=HeartRateZoneTarget(zone_number=2), + ) + data = _dump(step) + assert data["secondaryTargetType"] == { + "workoutTargetTypeId": TargetType.HEART_RATE_ZONE, + "workoutTargetTypeKey": "heart.rate.zone", + "displayOrder": 2, + } + assert data["secondaryZoneNumber"] == 2 + assert "secondaryTargetValueOne" not in data + + +def test_targeted_distance_interval_step(): + """Targeted distance steps keep the distance end condition.""" + step = create_targeted_distance_interval_step( + 1000.0, step_order=4, target=CadenceTarget(lower_limit=85, upper_limit=95) + ) + data = _dump(step) + assert data["stepOrder"] == 4 + assert data["endCondition"]["conditionTypeId"] == ConditionType.DISTANCE + assert data["endConditionValue"] == 1000.0 + assert data["targetType"]["workoutTargetTypeId"] == TargetType.CADENCE + assert (data["targetValueOne"], data["targetValueTwo"]) == (85, 95) + + +def test_range_target_rejects_reversed_limits(): + with pytest.raises(ValidationError, match="lower_limit must be <= upper_limit"): + SpeedTarget(lower_limit=9.0, upper_limit=8.0) + + +def test_range_target_accepts_equal_limits(): + target = CadenceTarget(lower_limit=180, upper_limit=180) + assert target.lower_limit == target.upper_limit + + +def test_pace_to_mps(): + assert pace_to_mps(5, 0, "km") == pytest.approx(1000 / 300) + assert pace_to_mps(8, 0, "mi") == pytest.approx(1609.344 / 480) + # A slower pace is a lower speed, so it is the lower limit. + assert pace_to_mps(6, 11, "km") < pace_to_mps(6, 0, "km") + + +def test_speed_to_mps(): + assert speed_to_mps(36.0, "kph") == pytest.approx(10.0) + assert speed_to_mps(10.0, "mph") == pytest.approx(4.4704) + + +def test_targets_survive_workout_serialization(): + """Targets end up in the upload payload of a full workout.""" + workout = CyclingWorkout( + workoutName="Sweet spot", + estimatedDurationInSecs=600, + workoutSegments=[ + WorkoutSegment( + segmentOrder=1, + sportType={"sportTypeId": 2, "sportTypeKey": "cycling"}, + workoutSteps=[ + create_targeted_interval_step( + 600.0, + step_order=1, + target=PowerZoneTarget(zone_number=4), + secondary_target=CadenceTarget(lower_limit=85, upper_limit=95), + ) + ], + ) + ], + ) + step = workout.to_dict()["workoutSegments"][0]["workoutSteps"][0] + assert step["zoneNumber"] == 4 + assert step["secondaryTargetType"]["workoutTargetTypeId"] == TargetType.CADENCE + assert (step["secondaryTargetValueOne"], step["secondaryTargetValueTwo"]) == ( + 85, + 95, + )