easy-or-not 0.1.0__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ # never commit real activity data or keys
8
+ *.fit
9
+ *.zip
10
+ !tests/fixtures/*.fit
11
+ connect-personal-api-key
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephen Nicholson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: easy-or-not
3
+ Version: 0.1.0
4
+ Summary: Are your easy runs actually easy? Time-in-zone and aerobic efficiency from your own Garmin data.
5
+ Project-URL: Homepage, https://github.com/stephen-a-nicholson/easy-or-not
6
+ Project-URL: Issues, https://github.com/stephen-a-nicholson/easy-or-not/issues
7
+ Author: Stephen Nicholson
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: 80/20,fit,garmin,heart-rate,running,training
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: fitdecode>=0.10
19
+ Requires-Dist: garmin-connect-personal-api-python>=1.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: fit-tool>=0.9; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Requires-Dist: ruff>=0.6; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # easy-or-not
27
+
28
+ Are your easy runs actually easy?
29
+
30
+ Most runners who stop improving are not lazy. They run almost everything at the same medium effort, too hard to recover from and too easy to make them faster. This tool reads your own runs and tells you how much of your time went where, how much of your "easy" running drifted into the middle, whether you are getting faster at the same heart rate, and your training load from week to week.
31
+
32
+ It was built for watches like the Vivoactive 5, which record every heartbeat but leave Training Load to Garmin's more expensive models.
33
+
34
+ It works on FIT files you have exported, or straight from your account with a [Garmin Personal API](https://developer.garmin.com/gc-developer-program/personal/personal-api/) key. Everything runs on your machine. Your data and your key never leave it.
35
+
36
+ ```
37
+ 30 runs, heart-rate bands from your Garmin running zones
38
+ easy below 154 bpm middle 154-172 bpm hard 173+ bpm
39
+
40
+ Where your running time went
41
+ easy 47% ##############................
42
+ middle 27% ########......................
43
+ hard 25% ########......................
44
+ A common target is around 80% easy with the rest mostly hard.
45
+
46
+ Your easy runs (18 of 30, sessions excluded)
47
+ 44% of easy-run time was in the middle band.
48
+ Thu 08 Oct 32 min 68% middle (treadmill)
49
+ Mon 14 Sep 32 min 65% middle (outdoor)
50
+ Sun 04 Oct 32 min 56% middle (outdoor)
51
+
52
+ Same heart rate, faster? (outdoor easy runs only)
53
+ 1.40 -> 1.43 metres per heartbeat (+2.5%).
54
+ Higher means you cover more ground for the same effort.
55
+
56
+ Training load (heart-rate minutes, weighted 1 to 5 by zone)
57
+ Last 7 days to Thu 22 Oct: 412
58
+ Your average week over the last 4: 380
59
+ Ratio 1.08: in line with your recent weeks.
60
+ ```
61
+
62
+ *(Example output from synthetic data.)*
63
+
64
+ ## Install
65
+
66
+ Python 3.12 or newer.
67
+
68
+ ```bash
69
+ uv tool install easy-or-not
70
+ # or: pipx install easy-or-not
71
+ ```
72
+
73
+ ## Use it with your Garmin account
74
+
75
+ ```bash
76
+ easy-or-not setup # once: paste your Personal API key
77
+ easy-or-not # your last 8 weeks of runs
78
+ easy-or-not garmin --days 90
79
+ ```
80
+
81
+ `setup` tells you where to create the key in Garmin Connect ([Settings, Personal API Token](https://connect.garmin.com/app/settings/personalApiToken)), reads it without showing it on screen, saves it to `~/.config/garmin/connect-personal-api-key` readable only by you, and checks it with Garmin.
82
+
83
+ Authentication is handled by Garmin's official [Python client](https://github.com/garmin/connect-personal-api-python), which also reads `GARMIN_PERSONAL_API_KEY` if you prefer an environment variable. Original activity files are cached in `~/.cache/easy-or-not`, so later runs only download new activities. Your heart-rate zones are read from your Garmin settings.
84
+
85
+ ## Use it with exported files
86
+
87
+ In Garmin Connect on the web, open an activity, click the gear icon and choose **Export Original**. Point the tool at the files or the folder:
88
+
89
+ ```bash
90
+ easy-or-not --max-hr 192 files ~/Downloads/runs/
91
+ ```
92
+
93
+ Without the API there are no Garmin zones to read, so pass your max heart rate.
94
+
95
+ ## What it measures
96
+
97
+ **Bands.** Easy, middle and hard come from your heart-rate zones. By default easy is zones 1 and 2, middle is zone 3, hard is zones 4 and 5. Garmin's default zones start zone 3 at 70% of max HR, which is stricter than the roughly 80% easy ceiling used in polarised-training research. If you are on Garmin's defaults and everything looks "middle", try `--easy-up-to 3`. With `--max-hr`, the bands are below 80%, 80 to 90%, and above 90% of max.
98
+
99
+ **Easy runs.** Any run with less than 10% of its time in the hard band. That is a session-free run, whatever you meant it to be.
100
+
101
+ **Efficiency.** Metres covered per heartbeat while in the easy band, ignoring the first five minutes while heart rate catches up. The tool compares the median of your first third of outdoor easy runs with your last third. It needs at least six.
102
+
103
+ **What gets left out.** Activities with under 10 minutes of heart-rate data (a GPS test, a watch started by accident), and walks logged as runs (median cadence under 140 steps per minute). A walk home or a watch left recording after the run is trimmed off the end, so it does not count as easy running. The report says how many were left out and why.
104
+
105
+ **Zones that look too low.** If you held a median heart rate in zone 5 for half an hour or more, the report says your zones are probably set too low rather than telling you that you ran half an hour flat out.
106
+
107
+ **Training load.** Each run's load is its Edwards TRIMP: minutes in each heart-rate zone multiplied by the zone number, so a minute in zone 5 counts five times a minute in zone 1. The report compares the last 7 days with your average week over the last 4 (it needs 4 weeks of runs). Below 0.8 is lighter than usual, 0.8 to 1.3 is in line, 1.3 to 1.5 is a step up, above 1.5 is a big jump. Runs under 10 minutes still count towards load; walks do not. From your Garmin account, load is measured up to today, so a week off shows as a week off. It is a heart-rate estimate, not Garmin's Training Load, and it is only as right as your zones.
108
+
109
+ **Pauses.** Gaps of more than 10 seconds between samples (traffic lights, a stopped watch) are not counted.
110
+
111
+ ## Limits
112
+
113
+ - **Treadmill pace is an estimate.** Indoors, the watch works out speed from your wrist, so treadmill runs count towards time in zone but are left out of the efficiency trend.
114
+ - **Heart rate is only as good as the sensor.** Wrist HR can lock on to cadence, especially in the first few minutes or in the cold. A chest strap gives cleaner numbers.
115
+ - **Your zones decide the answer.** If the max heart rate in your Garmin settings is a guess, the bands are too. If you know your real max (from a race finish or a hard hill effort), pass `--max-hr`.
116
+ - **It describes, it does not coach.** "44% of your easy running was in the middle" is a fact about your data. What to do about it is up to you.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ uv venv && uv pip install -e '.[dev]'
122
+ uv run pytest -q
123
+ uv run ruff check .
124
+ ```
125
+
126
+ The tests build real FIT files in memory, so nothing personal is committed. `*.fit`, `*.zip` and key files are git-ignored.
127
+
128
+ ## Licence
129
+
130
+ MIT. Not affiliated with Garmin.
@@ -0,0 +1,105 @@
1
+ # easy-or-not
2
+
3
+ Are your easy runs actually easy?
4
+
5
+ Most runners who stop improving are not lazy. They run almost everything at the same medium effort, too hard to recover from and too easy to make them faster. This tool reads your own runs and tells you how much of your time went where, how much of your "easy" running drifted into the middle, whether you are getting faster at the same heart rate, and your training load from week to week.
6
+
7
+ It was built for watches like the Vivoactive 5, which record every heartbeat but leave Training Load to Garmin's more expensive models.
8
+
9
+ It works on FIT files you have exported, or straight from your account with a [Garmin Personal API](https://developer.garmin.com/gc-developer-program/personal/personal-api/) key. Everything runs on your machine. Your data and your key never leave it.
10
+
11
+ ```
12
+ 30 runs, heart-rate bands from your Garmin running zones
13
+ easy below 154 bpm middle 154-172 bpm hard 173+ bpm
14
+
15
+ Where your running time went
16
+ easy 47% ##############................
17
+ middle 27% ########......................
18
+ hard 25% ########......................
19
+ A common target is around 80% easy with the rest mostly hard.
20
+
21
+ Your easy runs (18 of 30, sessions excluded)
22
+ 44% of easy-run time was in the middle band.
23
+ Thu 08 Oct 32 min 68% middle (treadmill)
24
+ Mon 14 Sep 32 min 65% middle (outdoor)
25
+ Sun 04 Oct 32 min 56% middle (outdoor)
26
+
27
+ Same heart rate, faster? (outdoor easy runs only)
28
+ 1.40 -> 1.43 metres per heartbeat (+2.5%).
29
+ Higher means you cover more ground for the same effort.
30
+
31
+ Training load (heart-rate minutes, weighted 1 to 5 by zone)
32
+ Last 7 days to Thu 22 Oct: 412
33
+ Your average week over the last 4: 380
34
+ Ratio 1.08: in line with your recent weeks.
35
+ ```
36
+
37
+ *(Example output from synthetic data.)*
38
+
39
+ ## Install
40
+
41
+ Python 3.12 or newer.
42
+
43
+ ```bash
44
+ uv tool install easy-or-not
45
+ # or: pipx install easy-or-not
46
+ ```
47
+
48
+ ## Use it with your Garmin account
49
+
50
+ ```bash
51
+ easy-or-not setup # once: paste your Personal API key
52
+ easy-or-not # your last 8 weeks of runs
53
+ easy-or-not garmin --days 90
54
+ ```
55
+
56
+ `setup` tells you where to create the key in Garmin Connect ([Settings, Personal API Token](https://connect.garmin.com/app/settings/personalApiToken)), reads it without showing it on screen, saves it to `~/.config/garmin/connect-personal-api-key` readable only by you, and checks it with Garmin.
57
+
58
+ Authentication is handled by Garmin's official [Python client](https://github.com/garmin/connect-personal-api-python), which also reads `GARMIN_PERSONAL_API_KEY` if you prefer an environment variable. Original activity files are cached in `~/.cache/easy-or-not`, so later runs only download new activities. Your heart-rate zones are read from your Garmin settings.
59
+
60
+ ## Use it with exported files
61
+
62
+ In Garmin Connect on the web, open an activity, click the gear icon and choose **Export Original**. Point the tool at the files or the folder:
63
+
64
+ ```bash
65
+ easy-or-not --max-hr 192 files ~/Downloads/runs/
66
+ ```
67
+
68
+ Without the API there are no Garmin zones to read, so pass your max heart rate.
69
+
70
+ ## What it measures
71
+
72
+ **Bands.** Easy, middle and hard come from your heart-rate zones. By default easy is zones 1 and 2, middle is zone 3, hard is zones 4 and 5. Garmin's default zones start zone 3 at 70% of max HR, which is stricter than the roughly 80% easy ceiling used in polarised-training research. If you are on Garmin's defaults and everything looks "middle", try `--easy-up-to 3`. With `--max-hr`, the bands are below 80%, 80 to 90%, and above 90% of max.
73
+
74
+ **Easy runs.** Any run with less than 10% of its time in the hard band. That is a session-free run, whatever you meant it to be.
75
+
76
+ **Efficiency.** Metres covered per heartbeat while in the easy band, ignoring the first five minutes while heart rate catches up. The tool compares the median of your first third of outdoor easy runs with your last third. It needs at least six.
77
+
78
+ **What gets left out.** Activities with under 10 minutes of heart-rate data (a GPS test, a watch started by accident), and walks logged as runs (median cadence under 140 steps per minute). A walk home or a watch left recording after the run is trimmed off the end, so it does not count as easy running. The report says how many were left out and why.
79
+
80
+ **Zones that look too low.** If you held a median heart rate in zone 5 for half an hour or more, the report says your zones are probably set too low rather than telling you that you ran half an hour flat out.
81
+
82
+ **Training load.** Each run's load is its Edwards TRIMP: minutes in each heart-rate zone multiplied by the zone number, so a minute in zone 5 counts five times a minute in zone 1. The report compares the last 7 days with your average week over the last 4 (it needs 4 weeks of runs). Below 0.8 is lighter than usual, 0.8 to 1.3 is in line, 1.3 to 1.5 is a step up, above 1.5 is a big jump. Runs under 10 minutes still count towards load; walks do not. From your Garmin account, load is measured up to today, so a week off shows as a week off. It is a heart-rate estimate, not Garmin's Training Load, and it is only as right as your zones.
83
+
84
+ **Pauses.** Gaps of more than 10 seconds between samples (traffic lights, a stopped watch) are not counted.
85
+
86
+ ## Limits
87
+
88
+ - **Treadmill pace is an estimate.** Indoors, the watch works out speed from your wrist, so treadmill runs count towards time in zone but are left out of the efficiency trend.
89
+ - **Heart rate is only as good as the sensor.** Wrist HR can lock on to cadence, especially in the first few minutes or in the cold. A chest strap gives cleaner numbers.
90
+ - **Your zones decide the answer.** If the max heart rate in your Garmin settings is a guess, the bands are too. If you know your real max (from a race finish or a hard hill effort), pass `--max-hr`.
91
+ - **It describes, it does not coach.** "44% of your easy running was in the middle" is a fact about your data. What to do about it is up to you.
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ uv venv && uv pip install -e '.[dev]'
97
+ uv run pytest -q
98
+ uv run ruff check .
99
+ ```
100
+
101
+ The tests build real FIT files in memory, so nothing personal is committed. `*.fit`, `*.zip` and key files are git-ignored.
102
+
103
+ ## Licence
104
+
105
+ MIT. Not affiliated with Garmin.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "easy-or-not"
3
+ version = "0.1.0"
4
+ description = "Are your easy runs actually easy? Time-in-zone and aerobic efficiency from your own Garmin data."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "Stephen Nicholson" }]
8
+ keywords = ["garmin", "running", "heart-rate", "fit", "training", "80/20"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Environment :: Console",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Scientific/Engineering :: Information Analysis",
16
+ ]
17
+ requires-python = ">=3.12"
18
+ dependencies = [
19
+ "fitdecode>=0.10",
20
+ "garmin-connect-personal-api-python>=1.0",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/stephen-a-nicholson/easy-or-not"
25
+ Issues = "https://github.com/stephen-a-nicholson/easy-or-not/issues"
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "ruff>=0.6", "fit-tool>=0.9"]
29
+
30
+ [project.scripts]
31
+ easy-or-not = "easy_or_not.cli:main"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
39
+ exclude = ["CLAUDE.md"]
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/easy_or_not"]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py312"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,3 @@
1
+ """Are your easy runs actually easy?"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,168 @@
1
+ """The three questions the tool answers.
2
+
3
+ 1. How is your running time split between easy, middle and hard?
4
+ 2. Of the runs that were meant to be easy, how much drifted into the middle?
5
+ 3. At the same easy heart rate, are you getting faster over time?
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass, field
12
+ from datetime import date, timedelta
13
+ from statistics import median
14
+
15
+ from easy_or_not.checks import IMPLAUSIBLE_Z5_S, SHORT_REASON, skip_reason, trim_tail
16
+ from easy_or_not.models import Run
17
+ from easy_or_not.zones import Zones
18
+
19
+ # Gaps longer than this between samples are pauses (traffic lights, stopping
20
+ # the watch) and are not counted as time in any zone.
21
+ MAX_GAP_S = 10.0
22
+
23
+ # A run with less hard time than this was not a session, so it counts as a
24
+ # run that was meant to be easy.
25
+ SESSION_HARD_FRACTION = 0.10
26
+
27
+ # Ignore the first few minutes when measuring efficiency: heart rate lags
28
+ # pace while you warm up and would make everyone look efficient.
29
+ WARMUP_S = 300.0
30
+
31
+
32
+ @dataclass
33
+ class RunSummary:
34
+ run: Run
35
+ seconds: dict[str, float] # band -> seconds
36
+ efficiency: float | None # metres per heartbeat while in the easy band
37
+ zone_seconds: dict[int, float] = field(default_factory=dict) # zone 1-5 -> seconds
38
+
39
+ @property
40
+ def load(self) -> float:
41
+ """Edwards TRIMP: minutes in each heart-rate zone times the zone number."""
42
+ return sum(zone * s / 60.0 for zone, s in self.zone_seconds.items())
43
+
44
+ @property
45
+ def total_s(self) -> float:
46
+ return sum(self.seconds.values())
47
+
48
+ def fraction(self, band: str) -> float:
49
+ return self.seconds.get(band, 0.0) / self.total_s if self.total_s else 0.0
50
+
51
+ @property
52
+ def is_session(self) -> bool:
53
+ return self.fraction("hard") >= SESSION_HARD_FRACTION
54
+
55
+
56
+ @dataclass
57
+ class Report:
58
+ zones: Zones
59
+ runs: list[RunSummary]
60
+ skipped: list[tuple[Run, str]] = field(default_factory=list)
61
+ warnings: list[str] = field(default_factory=list)
62
+ # Runs too short for the time-in-zone figures still count towards load.
63
+ load_only: list[RunSummary] = field(default_factory=list)
64
+ as_of: date | None = None
65
+
66
+ def total_fraction(self, band: str) -> float:
67
+ total = sum(r.total_s for r in self.runs)
68
+ return sum(r.seconds.get(band, 0.0) for r in self.runs) / total if total else 0.0
69
+
70
+ @property
71
+ def easy_runs(self) -> list[RunSummary]:
72
+ return [r for r in self.runs if not r.is_session]
73
+
74
+ @property
75
+ def sessions(self) -> list[RunSummary]:
76
+ return [r for r in self.runs if r.is_session]
77
+
78
+ def easy_run_middle_fraction(self) -> float:
79
+ runs = self.easy_runs
80
+ total = sum(r.total_s for r in runs)
81
+ return sum(r.seconds.get("middle", 0.0) for r in runs) / total if total else 0.0
82
+
83
+ def weekly(self) -> list[tuple[date, dict[str, float]]]:
84
+ weeks: dict[date, dict[str, float]] = defaultdict(lambda: defaultdict(float))
85
+ for r in self.runs:
86
+ monday = r.run.start.date() - timedelta(days=r.run.start.weekday())
87
+ for band, s in r.seconds.items():
88
+ weeks[monday][band] += s
89
+ return sorted((k, dict(v)) for k, v in weeks.items())
90
+
91
+ def efficiency_trend(self) -> tuple[float, float] | None:
92
+ """Median efficiency of the first and last third of outdoor easy runs."""
93
+ points = [r.efficiency for r in self.easy_runs if r.efficiency and not r.run.treadmill]
94
+ if len(points) < 6:
95
+ return None
96
+ third = len(points) // 3
97
+ return median(points[:third]), median(points[-third:])
98
+
99
+
100
+ def summarise(run: Run, zones: Zones) -> RunSummary:
101
+ seconds: dict[str, float] = defaultdict(float)
102
+ zone_seconds: dict[int, float] = defaultdict(float)
103
+ metres = 0.0
104
+ beats = 0.0
105
+ samples = run.samples
106
+ for prev, cur in zip(samples, samples[1:], strict=False):
107
+ dt = cur.elapsed_s - prev.elapsed_s
108
+ if dt <= 0 or dt > MAX_GAP_S or prev.heart_rate is None or prev.heart_rate <= 0:
109
+ continue
110
+ band = zones.band(prev.heart_rate)
111
+ seconds[band] += dt
112
+ zone_seconds[zones.zone(prev.heart_rate)] += dt
113
+ if band == "easy" and prev.elapsed_s >= WARMUP_S and prev.speed_mps:
114
+ metres += prev.speed_mps * dt
115
+ beats += prev.heart_rate * dt / 60.0
116
+ efficiency = metres / beats if beats > 60 else None
117
+ return RunSummary(
118
+ run=run, seconds=dict(seconds), efficiency=efficiency, zone_seconds=dict(zone_seconds)
119
+ )
120
+
121
+
122
+ def median_hr(run: Run) -> float | None:
123
+ rates = [s.heart_rate for s in run.samples if s.heart_rate]
124
+ return median(rates) if rates else None
125
+
126
+
127
+ def zone_warnings(runs: list[RunSummary], zones: Zones) -> list[str]:
128
+ """Flag zones that the data says are too low."""
129
+ suspicious = [
130
+ (r, hr)
131
+ for r in runs
132
+ if r.total_s >= IMPLAUSIBLE_Z5_S and (hr := median_hr(r.run)) and hr >= zones.z5
133
+ ]
134
+ if not suspicious:
135
+ return []
136
+ r, hr = max(suspicious, key=lambda pair: pair[1])
137
+ return [
138
+ f"Your zones may be set too low. On {r.run.start:%a %d %b} you held a median of "
139
+ f"{hr:.0f} bpm for {r.total_s / 60:.0f} min, above your zone 5 floor of {zones.z5}. "
140
+ "Zone 5 is meant to be a few minutes at most, so your real max heart rate is "
141
+ "probably higher than your zones assume. If you know it, pass --max-hr."
142
+ ]
143
+
144
+
145
+ def analyse(runs: list[Run], zones: Zones, as_of: date | None = None) -> Report:
146
+ """Summarise runs. `as_of` is the day load is measured to (default: the last run)."""
147
+ kept: list[RunSummary] = []
148
+ load_only: list[RunSummary] = []
149
+ skipped: list[tuple[Run, str]] = []
150
+ for run in sorted(runs, key=lambda r: r.start):
151
+ summary = summarise(trim_tail(run), zones)
152
+ reason = skip_reason(run, summary.total_s)
153
+ if reason:
154
+ skipped.append((run, reason))
155
+ if reason == SHORT_REASON:
156
+ load_only.append(summary)
157
+ else:
158
+ kept.append(summary)
159
+ if as_of is None and runs:
160
+ as_of = max(r.start for r in runs).date()
161
+ return Report(
162
+ zones=zones,
163
+ runs=kept,
164
+ skipped=skipped,
165
+ warnings=zone_warnings(kept, zones),
166
+ load_only=load_only,
167
+ as_of=as_of,
168
+ )
@@ -0,0 +1,65 @@
1
+ """Data sanity checks: catch recordings that would make the numbers misleading.
2
+
3
+ Each check either cleans a run up, drops it with a stated reason, or adds a
4
+ warning to the report. None of them changes the zones.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import replace
10
+ from statistics import median
11
+
12
+ from easy_or_not.models import Run, Sample
13
+
14
+ # Running is almost always above this many steps per minute and walking below it.
15
+ RUNNING_CADENCE_SPM = 140.0
16
+
17
+ # Without cadence, treat anything slower than about 9:15/km as not running.
18
+ RUNNING_SPEED_MPS = 1.8
19
+
20
+ # Runs with less heart-rate data than this are fragments (a GPS test, a
21
+ # watch started by accident) and say nothing about how you train.
22
+ MIN_HR_S = 600.0
23
+
24
+ # Zone 5 is the top few percent of your range. Holding it for this long is
25
+ # not plausible, so it points to zones (or a max HR) set too low.
26
+ IMPLAUSIBLE_Z5_S = 30 * 60.0
27
+
28
+ SHORT_REASON = "under 10 minutes of heart-rate data"
29
+
30
+
31
+ def _is_running(sample: Sample) -> bool | None:
32
+ if sample.cadence_spm is not None:
33
+ return sample.cadence_spm >= RUNNING_CADENCE_SPM
34
+ if sample.speed_mps is not None:
35
+ return sample.speed_mps >= RUNNING_SPEED_MPS
36
+ return None
37
+
38
+
39
+ def trim_tail(run: Run) -> Run:
40
+ """Drop samples after the last running sample.
41
+
42
+ This removes a walk home, or a watch left recording on the sofa, which
43
+ would otherwise count as a long stretch of very easy running.
44
+ """
45
+ flags = [_is_running(s) for s in run.samples]
46
+ if not any(flags):
47
+ return run
48
+ last = max(i for i, f in enumerate(flags) if f)
49
+ if last == len(run.samples) - 1:
50
+ return run
51
+ return replace(run, samples=run.samples[: last + 1])
52
+
53
+
54
+ def skip_reason(run: Run, hr_seconds: float) -> str | None:
55
+ """Why this run should be left out of the analysis, or None to keep it.
56
+
57
+ `run` is the activity as recorded; `hr_seconds` is the heart-rate time
58
+ left after trimming, so a walk is reported as a walk, not as too short.
59
+ """
60
+ cadences = [s.cadence_spm for s in run.samples if s.cadence_spm]
61
+ if len(cadences) > len(run.samples) / 2 and median(cadences) < RUNNING_CADENCE_SPM:
62
+ return "walking cadence"
63
+ if hr_seconds < MIN_HR_S:
64
+ return SHORT_REASON
65
+ return None
@@ -0,0 +1,96 @@
1
+ """easy-or-not: are your easy runs actually easy?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from dataclasses import replace
8
+ from datetime import date
9
+ from pathlib import Path
10
+
11
+ from easy_or_not import __version__
12
+ from easy_or_not.analysis import analyse
13
+ from easy_or_not.fit import NotARun, load_path
14
+ from easy_or_not.models import Run
15
+ from easy_or_not.report import render_json, render_text
16
+ from easy_or_not.zones import Zones, from_max_hr
17
+
18
+ DEFAULT_CACHE = Path("~/.cache/easy-or-not").expanduser()
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ p = argparse.ArgumentParser(prog="easy-or-not", description=__doc__)
23
+ p.add_argument("--version", action="version", version=__version__)
24
+ p.add_argument("--max-hr", type=int, help="derive zones from max HR instead of Garmin's")
25
+ p.add_argument(
26
+ "--easy-up-to",
27
+ type=int,
28
+ choices=(2, 3),
29
+ default=2,
30
+ help="highest heart-rate zone that counts as easy (default 2; try 3 on Garmin defaults)",
31
+ )
32
+ p.add_argument("--json", action="store_true", help="print JSON instead of text")
33
+ p.set_defaults(days=56, cache_dir=DEFAULT_CACHE) # for a bare `easy-or-not`
34
+ sub = p.add_subparsers(dest="source", metavar="{garmin,files,setup}")
35
+
36
+ sub.add_parser("setup", help="save your Garmin Personal API key (run this once)")
37
+
38
+ files = sub.add_parser("files", help="analyse .fit or .zip files you have exported")
39
+ files.add_argument("paths", nargs="+", type=Path)
40
+
41
+ garmin = sub.add_parser(
42
+ "garmin", help="fetch your runs with a Garmin Personal API key (the default)"
43
+ )
44
+ garmin.add_argument("--days", type=int, default=56, help="how far back to look (default 56)")
45
+ garmin.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE)
46
+ return p
47
+
48
+
49
+ def main(argv: list[str] | None = None) -> int:
50
+ args = build_parser().parse_args(argv)
51
+ args.source = args.source or "garmin"
52
+ zones: Zones | None = None
53
+ runs: list[Run] = []
54
+
55
+ if args.source == "setup":
56
+ from easy_or_not.setup import setup
57
+
58
+ return setup()
59
+ if args.source == "files":
60
+ for path in args.paths:
61
+ try:
62
+ runs.extend(load_path(path))
63
+ except NotARun as e:
64
+ print(f"skipping {e}", file=sys.stderr)
65
+ else:
66
+ from easy_or_not.garmin import GarminPersonalApiError, fetch # so `files` works offline
67
+
68
+ try:
69
+ runs, zones = fetch(args.days, args.cache_dir, quiet=args.json)
70
+ except GarminPersonalApiError as e:
71
+ print(f"Could not reach your Garmin account: {e}", file=sys.stderr)
72
+ print("If you have not saved a key yet, run: easy-or-not setup", file=sys.stderr)
73
+ return 2
74
+
75
+ if args.max_hr:
76
+ zones = from_max_hr(args.max_hr)
77
+ if zones is None:
78
+ print(
79
+ "No heart-rate zones: pass --max-hr, or use the garmin source to read yours.",
80
+ file=sys.stderr,
81
+ )
82
+ return 2
83
+ if not runs:
84
+ print("No running activities found.", file=sys.stderr)
85
+ return 1
86
+
87
+ zones = replace(zones, easy_up_to=args.easy_up_to)
88
+ # From the API, load is measured to today, so a week off shows as a week off.
89
+ as_of = date.today() if args.source == "garmin" else None
90
+ report = analyse(runs, zones, as_of=as_of)
91
+ print(render_json(report) if args.json else render_text(report))
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(main())