garmin-golf-analytics 0.0.4__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.
- garmin_golf_analytics-0.0.4/LICENSE +21 -0
- garmin_golf_analytics-0.0.4/PKG-INFO +178 -0
- garmin_golf_analytics-0.0.4/README.md +150 -0
- garmin_golf_analytics-0.0.4/pyproject.toml +56 -0
- garmin_golf_analytics-0.0.4/setup.cfg +4 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/__init__.py +5 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/cli.py +44 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/club_report.py +171 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/garmin_golf_fetch.py +280 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/hole_sync.py +50 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/round_report.py +166 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/rounds_common.py +85 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/scoring.py +70 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/PKG-INFO +178 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/SOURCES.txt +25 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/dependency_links.txt +1 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/entry_points.txt +5 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/requires.txt +5 -0
- garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/top_level.txt +1 -0
- garmin_golf_analytics-0.0.4/tests/test_cli.py +48 -0
- garmin_golf_analytics-0.0.4/tests/test_club_report.py +216 -0
- garmin_golf_analytics-0.0.4/tests/test_garmin_golf_fetch.py +297 -0
- garmin_golf_analytics-0.0.4/tests/test_hole_sync.py +69 -0
- garmin_golf_analytics-0.0.4/tests/test_putts.py +62 -0
- garmin_golf_analytics-0.0.4/tests/test_round_report.py +120 -0
- garmin_golf_analytics-0.0.4/tests/test_rounds_common.py +107 -0
- garmin_golf_analytics-0.0.4/tests/test_scoring.py +102 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Etienne Roulland
|
|
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,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: garmin-golf-analytics
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Turn Garmin Connect golf rounds into per-club distances and a score debrief that isn't fooled by Garmin's defaults.
|
|
5
|
+
Author: Etienne Troulland
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/etroulland/garmin-golf-analytics
|
|
8
|
+
Project-URL: Changelog, https://github.com/etroulland/garmin-golf-analytics/blob/main/CHANGELOG.md
|
|
9
|
+
Keywords: garmin,golf,garmin-connect,sports-analytics
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Games/Entertainment
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: garminconnect<0.3,>=0.2.19
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# garmin-golf-analytics
|
|
30
|
+
|
|
31
|
+
[](https://github.com/etroulland/garmin-golf-analytics/actions/workflows/tests.yml)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
Pull your Garmin Connect golf rounds and turn them into numbers you can act on: measured shot
|
|
35
|
+
distances per club, split by lie, plus a round debrief that isn't fooled by Garmin's own
|
|
36
|
+
defaults.
|
|
37
|
+
|
|
38
|
+
Garmin already stores every shot its sensors caught. This repo reads that data through the
|
|
39
|
+
`garminconnect` API instead of asking you to download a `DI_CONNECT` zip by hand, then applies
|
|
40
|
+
the parsing rules that make the numbers trustworthy.
|
|
41
|
+
|
|
42
|
+
## Why this exists
|
|
43
|
+
|
|
44
|
+
The Garmin golf data looks clean and isn't. Things that silently corrupt a naive analysis:
|
|
45
|
+
|
|
46
|
+
- **No putt detection without the sensors.** The watch only counts putts with the Garmin club
|
|
47
|
+
sensors on the putter. Without them nothing is measured: any `putts` value is whatever was typed
|
|
48
|
+
in by hand, and Garmin's own default fill is `putts: 2` on every hole. Averaging that gives a
|
|
49
|
+
perfect two-putt game you don't have, and `sensorOnPutter` is your only way to know which is
|
|
50
|
+
which.
|
|
51
|
+
- **No per-hole par on the hole itself.** `holes[].par` is always `null`, but the pars of the
|
|
52
|
+
course are in `courseSnapshots[].holePars`, a digit string ordered 1..18, in the same response.
|
|
53
|
+
Garmin's own `score` field is a NET score and uses `handicappedStrokes`, not the course handicap.
|
|
54
|
+
- **Uneven sampling.** One club can read 185 m off the tee and 108 m from the fairway, because
|
|
55
|
+
most of its fairway shots are punch-outs. Pooling the two produces a number that describes no
|
|
56
|
+
shot you ever hit.
|
|
57
|
+
- **Short-game pollution.** A gap wedge chips from 20 m all day long. Include those in its average
|
|
58
|
+
and it looks 40 m shorter than it plays with a full swing.
|
|
59
|
+
- **Club identity gaps.** `clubId: 0` marks swings the watch could not attribute to a club. They
|
|
60
|
+
are not a club and must never appear as one.
|
|
61
|
+
- **Partial rounds.** Abandoned rounds and range sessions land in the same feed as real ones.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pipx install garmin-golf-analytics
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or from a clone, with the test suite:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone https://github.com/etroulland/garmin-golf-analytics
|
|
73
|
+
cd garmin-golf-analytics
|
|
74
|
+
python -m venv .venv && source .venv/bin/activate
|
|
75
|
+
pip install -e '.[dev]'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Log in once. `garminconnect` writes a token store, so no credentials ever live in this repo:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python -c "from garminconnect import Garmin; Garmin().login()"
|
|
82
|
+
# answer the prompts once; the session is cached in ~/.garminconnect
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Fetch rounds
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
export GARMINTOKENS=~/.garminconnect
|
|
89
|
+
gga-fetch # latest round only
|
|
90
|
+
gga-fetch --days 30 # every round played in the window
|
|
91
|
+
gga-fetch --scid 383676990
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The same commands are available as subcommands of a single `gga` dispatcher:
|
|
95
|
+
`gga fetch`, `gga clubs`, `gga rounds`.
|
|
96
|
+
|
|
97
|
+
Each round becomes `rounds/round_<YYYY-MM-DD>_<scid>.json`:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"scorecard": { "startTime": "...", "strokes": 46, "handicappedStrokes": 9, "holes": [...] },
|
|
102
|
+
"course": { "name": "Golf Club St. Meard", "roundPar": 32, "pars": { "1": 3, "2": 3, "...": 0 } },
|
|
103
|
+
"shots": { "1": [{"clubId": 788036363, "meters": 181.9, "startLoc": {"lie": "TeeBox"}}] },
|
|
104
|
+
"playedHoles": 9
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`course` comes from `courseSnapshots`, a sibling of `scorecardDetails` in the same API response.
|
|
109
|
+
It carries the per-hole par that `holes[].par` never does. `pars` is keyed by hole **number**: a
|
|
110
|
+
`BACK_9` round lists holes 10..18, so join on the number, never on the position in the array.
|
|
111
|
+
|
|
112
|
+
Club names need `Golf-CLUB.json` and `Golf-CLUB_TYPES.json`, which only ship inside a Garmin
|
|
113
|
+
data export. Point `--zipref` (or `$GARMIN_ZIPREF`) at a `DI-GOLF` directory to get them joined in.
|
|
114
|
+
Club names come from the API (`get_golf_club_stats`), which returns every club with its
|
|
115
|
+
`clubTypeId` but never the label. `--zipref` is still accepted and overrides it, because only a
|
|
116
|
+
data export carries the loft per club. Shots whose club cannot be named are excluded from the
|
|
117
|
+
distance report rather than shown as `clubId:<n>`, which no one can act on.
|
|
118
|
+
|
|
119
|
+
## Club distances
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
gga-clubs rounds/
|
|
123
|
+
gga-clubs rounds/ --lie TeeBox # first shot of a hole (see caveat below)
|
|
124
|
+
gga-clubs rounds/ --min-meters 40 # raise the floor to drop chips
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Median, p75, p90 and max per club **per lie**, because that is how the numbers are actually usable
|
|
128
|
+
on the course. Clubs under 5 tracked shots are flagged `(low n)`.
|
|
129
|
+
|
|
130
|
+
Every distance is a **total** distance: Garmin stores one `meters` value per shot, from the start
|
|
131
|
+
point to the end point, roll included. Carry is not in the data and is not reported.
|
|
132
|
+
`startLoc.lie == "TeeBox"` means "first shot played on that hole", not "hit from the tee": a par 4
|
|
133
|
+
played 5-iron then wedge tags the 5-iron TeeBox and the wedge Fairway.
|
|
134
|
+
|
|
135
|
+
Garmin records only a fraction of the swings you take, so n is small for a long time. Treat
|
|
136
|
+
anything under 5 shots as noise and never let one low-n club outrank a measured one.
|
|
137
|
+
|
|
138
|
+
## Round debrief
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
gga-rounds rounds/
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Strokes per hole distribution, blow-up rate, penalties per round, and three-putt rate. Rounds with
|
|
145
|
+
fewer than 9 played holes, or under 35 strokes on 9 / 60 on 18, are dropped as practice or
|
|
146
|
+
abandoned before anything is averaged.
|
|
147
|
+
|
|
148
|
+
## Layout
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
src/garmin_golf_analytics/
|
|
152
|
+
garmin_golf_fetch.py fetch rounds from the Garmin API
|
|
153
|
+
club_report.py distance tables per club, split by lie
|
|
154
|
+
round_report.py score, penalties, blow-up holes, putting
|
|
155
|
+
rounds_common.py which rounds count, shared by both reports
|
|
156
|
+
scoring.py per-hole par and relative score
|
|
157
|
+
hole_sync.py tracked shots vs scorecard reconciliation
|
|
158
|
+
cli.py the `gga` dispatcher
|
|
159
|
+
tests/ 125 tests, 97% coverage
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pip install -e '.[dev]'
|
|
166
|
+
./run_tests.sh # pytest + coverage gate at 90%
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
CI runs the suite on Python 3.10 to 3.13 and checks that every console script
|
|
170
|
+
resolves (`.github/workflows/tests.yml`).
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
175
|
+
|
|
176
|
+
## Changelog
|
|
177
|
+
|
|
178
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# garmin-golf-analytics
|
|
2
|
+
|
|
3
|
+
[](https://github.com/etroulland/garmin-golf-analytics/actions/workflows/tests.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Pull your Garmin Connect golf rounds and turn them into numbers you can act on: measured shot
|
|
7
|
+
distances per club, split by lie, plus a round debrief that isn't fooled by Garmin's own
|
|
8
|
+
defaults.
|
|
9
|
+
|
|
10
|
+
Garmin already stores every shot its sensors caught. This repo reads that data through the
|
|
11
|
+
`garminconnect` API instead of asking you to download a `DI_CONNECT` zip by hand, then applies
|
|
12
|
+
the parsing rules that make the numbers trustworthy.
|
|
13
|
+
|
|
14
|
+
## Why this exists
|
|
15
|
+
|
|
16
|
+
The Garmin golf data looks clean and isn't. Things that silently corrupt a naive analysis:
|
|
17
|
+
|
|
18
|
+
- **No putt detection without the sensors.** The watch only counts putts with the Garmin club
|
|
19
|
+
sensors on the putter. Without them nothing is measured: any `putts` value is whatever was typed
|
|
20
|
+
in by hand, and Garmin's own default fill is `putts: 2` on every hole. Averaging that gives a
|
|
21
|
+
perfect two-putt game you don't have, and `sensorOnPutter` is your only way to know which is
|
|
22
|
+
which.
|
|
23
|
+
- **No per-hole par on the hole itself.** `holes[].par` is always `null`, but the pars of the
|
|
24
|
+
course are in `courseSnapshots[].holePars`, a digit string ordered 1..18, in the same response.
|
|
25
|
+
Garmin's own `score` field is a NET score and uses `handicappedStrokes`, not the course handicap.
|
|
26
|
+
- **Uneven sampling.** One club can read 185 m off the tee and 108 m from the fairway, because
|
|
27
|
+
most of its fairway shots are punch-outs. Pooling the two produces a number that describes no
|
|
28
|
+
shot you ever hit.
|
|
29
|
+
- **Short-game pollution.** A gap wedge chips from 20 m all day long. Include those in its average
|
|
30
|
+
and it looks 40 m shorter than it plays with a full swing.
|
|
31
|
+
- **Club identity gaps.** `clubId: 0` marks swings the watch could not attribute to a club. They
|
|
32
|
+
are not a club and must never appear as one.
|
|
33
|
+
- **Partial rounds.** Abandoned rounds and range sessions land in the same feed as real ones.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install garmin-golf-analytics
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or from a clone, with the test suite:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/etroulland/garmin-golf-analytics
|
|
45
|
+
cd garmin-golf-analytics
|
|
46
|
+
python -m venv .venv && source .venv/bin/activate
|
|
47
|
+
pip install -e '.[dev]'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Log in once. `garminconnect` writes a token store, so no credentials ever live in this repo:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -c "from garminconnect import Garmin; Garmin().login()"
|
|
54
|
+
# answer the prompts once; the session is cached in ~/.garminconnect
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Fetch rounds
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export GARMINTOKENS=~/.garminconnect
|
|
61
|
+
gga-fetch # latest round only
|
|
62
|
+
gga-fetch --days 30 # every round played in the window
|
|
63
|
+
gga-fetch --scid 383676990
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The same commands are available as subcommands of a single `gga` dispatcher:
|
|
67
|
+
`gga fetch`, `gga clubs`, `gga rounds`.
|
|
68
|
+
|
|
69
|
+
Each round becomes `rounds/round_<YYYY-MM-DD>_<scid>.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"scorecard": { "startTime": "...", "strokes": 46, "handicappedStrokes": 9, "holes": [...] },
|
|
74
|
+
"course": { "name": "Golf Club St. Meard", "roundPar": 32, "pars": { "1": 3, "2": 3, "...": 0 } },
|
|
75
|
+
"shots": { "1": [{"clubId": 788036363, "meters": 181.9, "startLoc": {"lie": "TeeBox"}}] },
|
|
76
|
+
"playedHoles": 9
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`course` comes from `courseSnapshots`, a sibling of `scorecardDetails` in the same API response.
|
|
81
|
+
It carries the per-hole par that `holes[].par` never does. `pars` is keyed by hole **number**: a
|
|
82
|
+
`BACK_9` round lists holes 10..18, so join on the number, never on the position in the array.
|
|
83
|
+
|
|
84
|
+
Club names need `Golf-CLUB.json` and `Golf-CLUB_TYPES.json`, which only ship inside a Garmin
|
|
85
|
+
data export. Point `--zipref` (or `$GARMIN_ZIPREF`) at a `DI-GOLF` directory to get them joined in.
|
|
86
|
+
Club names come from the API (`get_golf_club_stats`), which returns every club with its
|
|
87
|
+
`clubTypeId` but never the label. `--zipref` is still accepted and overrides it, because only a
|
|
88
|
+
data export carries the loft per club. Shots whose club cannot be named are excluded from the
|
|
89
|
+
distance report rather than shown as `clubId:<n>`, which no one can act on.
|
|
90
|
+
|
|
91
|
+
## Club distances
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
gga-clubs rounds/
|
|
95
|
+
gga-clubs rounds/ --lie TeeBox # first shot of a hole (see caveat below)
|
|
96
|
+
gga-clubs rounds/ --min-meters 40 # raise the floor to drop chips
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Median, p75, p90 and max per club **per lie**, because that is how the numbers are actually usable
|
|
100
|
+
on the course. Clubs under 5 tracked shots are flagged `(low n)`.
|
|
101
|
+
|
|
102
|
+
Every distance is a **total** distance: Garmin stores one `meters` value per shot, from the start
|
|
103
|
+
point to the end point, roll included. Carry is not in the data and is not reported.
|
|
104
|
+
`startLoc.lie == "TeeBox"` means "first shot played on that hole", not "hit from the tee": a par 4
|
|
105
|
+
played 5-iron then wedge tags the 5-iron TeeBox and the wedge Fairway.
|
|
106
|
+
|
|
107
|
+
Garmin records only a fraction of the swings you take, so n is small for a long time. Treat
|
|
108
|
+
anything under 5 shots as noise and never let one low-n club outrank a measured one.
|
|
109
|
+
|
|
110
|
+
## Round debrief
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
gga-rounds rounds/
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Strokes per hole distribution, blow-up rate, penalties per round, and three-putt rate. Rounds with
|
|
117
|
+
fewer than 9 played holes, or under 35 strokes on 9 / 60 on 18, are dropped as practice or
|
|
118
|
+
abandoned before anything is averaged.
|
|
119
|
+
|
|
120
|
+
## Layout
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
src/garmin_golf_analytics/
|
|
124
|
+
garmin_golf_fetch.py fetch rounds from the Garmin API
|
|
125
|
+
club_report.py distance tables per club, split by lie
|
|
126
|
+
round_report.py score, penalties, blow-up holes, putting
|
|
127
|
+
rounds_common.py which rounds count, shared by both reports
|
|
128
|
+
scoring.py per-hole par and relative score
|
|
129
|
+
hole_sync.py tracked shots vs scorecard reconciliation
|
|
130
|
+
cli.py the `gga` dispatcher
|
|
131
|
+
tests/ 125 tests, 97% coverage
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install -e '.[dev]'
|
|
138
|
+
./run_tests.sh # pytest + coverage gate at 90%
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
CI runs the suite on Python 3.10 to 3.13 and checks that every console script
|
|
142
|
+
resolves (`.github/workflows/tests.yml`).
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
147
|
+
|
|
148
|
+
## Changelog
|
|
149
|
+
|
|
150
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "garmin-golf-analytics"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Turn Garmin Connect golf rounds into per-club distances and a score debrief that isn't fooled by Garmin's defaults."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Etienne Troulland" }]
|
|
14
|
+
keywords = ["garmin", "golf", "garmin-connect", "sports-analytics"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Games/Entertainment",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["garminconnect>=0.2.19,<0.3"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8", "pytest-cov>=5"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/etroulland/garmin-golf-analytics"
|
|
34
|
+
Changelog = "https://github.com/etroulland/garmin-golf-analytics/blob/main/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
gga = "garmin_golf_analytics.cli:main"
|
|
38
|
+
gga-fetch = "garmin_golf_analytics.garmin_golf_fetch:main"
|
|
39
|
+
gga-clubs = "garmin_golf_analytics.club_report:main"
|
|
40
|
+
gga-rounds = "garmin_golf_analytics.round_report:main"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.dynamic]
|
|
43
|
+
version = { attr = "garmin_golf_analytics.__version__" }
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
addopts = "-q"
|
|
51
|
+
|
|
52
|
+
[tool.coverage.run]
|
|
53
|
+
source = ["garmin_golf_analytics"]
|
|
54
|
+
|
|
55
|
+
[tool.coverage.report]
|
|
56
|
+
fail_under = 90
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Console entry points for the garmin-golf-analytics commands.
|
|
2
|
+
|
|
3
|
+
Each command is a thin wrapper around the ``main(argv)`` of one module, so the
|
|
4
|
+
CLIs stay testable and nothing imports garminconnect unless you actually fetch.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fetch() -> int:
|
|
12
|
+
from .garmin_golf_fetch import main
|
|
13
|
+
|
|
14
|
+
return main()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def clubs() -> int:
|
|
18
|
+
from .club_report import main
|
|
19
|
+
|
|
20
|
+
return main()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def rounds() -> int:
|
|
24
|
+
from .round_report import main
|
|
25
|
+
|
|
26
|
+
return main()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
"""Dispatch on the first argument: ``cli fetch|clubs|rounds ...``."""
|
|
31
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
32
|
+
commands = {"fetch": fetch, "clubs": clubs, "rounds": rounds}
|
|
33
|
+
if not args or args[0] in ("-h", "--help"):
|
|
34
|
+
print("usage: cli {fetch,clubs,rounds} [options]\n"
|
|
35
|
+
" fetch pull rounds from Garmin Connect\n"
|
|
36
|
+
" clubs per-club distances, split by lie\n"
|
|
37
|
+
" rounds round debrief (score, penalties, putting)")
|
|
38
|
+
return 0 if args else 2
|
|
39
|
+
command = commands.get(args[0])
|
|
40
|
+
if command is None:
|
|
41
|
+
print(f"unknown command: {args[0]} (expected fetch, clubs or rounds)", file=sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
sys.argv = [f"cli {args[0]}", *args[1:]]
|
|
44
|
+
return command()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Club distance report built from the JSON bundles written by garmin_golf_fetch.py.
|
|
3
|
+
|
|
4
|
+
Prints, per club and per lie, the median TOTAL distance, the share of shots that
|
|
5
|
+
end on the fairway or the green, and the count of mishits. Percentiles alone hide
|
|
6
|
+
the thing that actually costs strokes: a club whose p25-p75 looks tight can still
|
|
7
|
+
put two thirds of its balls in the rough, and a median computed with tops and
|
|
8
|
+
chips mixed in describes a shot nobody plays.
|
|
9
|
+
|
|
10
|
+
Garmin records one "meters" value per shot, from the start point to the end
|
|
11
|
+
point: ground distance, roll included. Carry is NOT in the data and is never
|
|
12
|
+
reported here.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python club_report.py rounds/ # all bundles in a directory
|
|
16
|
+
python club_report.py rounds/ --lie TeeBox # one lie only
|
|
17
|
+
python club_report.py rounds/ --min-meters 40 --min-shots 5
|
|
18
|
+
python club_report.py rounds/ --keep-excluded # keep Garmin's excluded rounds
|
|
19
|
+
|
|
20
|
+
Only shots actually tracked by the watch are present (Garmin records a fraction
|
|
21
|
+
of the swings played), so a club with n < 5 gets flagged low-confidence.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from typing import Iterable
|
|
30
|
+
|
|
31
|
+
from .rounds_common import load_bundles, played_holes, select
|
|
32
|
+
|
|
33
|
+
LIE_ORDER = ("TeeBox", "Fairway", "Rough", "Bunker", "Green", "Recovery")
|
|
34
|
+
GOOD_LIES = ("Fairway", "Green")
|
|
35
|
+
# The putter is a real club off the green on dry, fast turf: a putt hit from the
|
|
36
|
+
# green is a stroke of the short game, a putt hit from anywhere else is an
|
|
37
|
+
# approach and deserves its own row. Garmin gives both the same shotType.
|
|
38
|
+
PUTTER = "Putter"
|
|
39
|
+
# A shot this short is a top, a duff or a mis-hit: it never describes the club.
|
|
40
|
+
MISHIT_METERS = 60
|
|
41
|
+
# No distance floor by default: a floor applied to every club silently deletes
|
|
42
|
+
# the wedges from their own table, and mishits are already counted separately.
|
|
43
|
+
DEFAULT_MIN_METERS = 0.0
|
|
44
|
+
DEFAULT_MIN_SHOTS = 5
|
|
45
|
+
|
|
46
|
+
# Garmin only stores a single "meters" value per shot: the GPS distance from the
|
|
47
|
+
# start point to the end point, roll included. There is no carry in the data, so
|
|
48
|
+
# every number this report prints, and the floor below it, is a TOTAL distance.
|
|
49
|
+
DISTANCE_LABEL = "total (GPS, roll included)"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def percentile(values: list[float], fraction: float) -> float:
|
|
53
|
+
"""Linear-interpolated percentile on a sorted list."""
|
|
54
|
+
if not values:
|
|
55
|
+
raise ValueError("percentile of empty list")
|
|
56
|
+
ordered = sorted(values)
|
|
57
|
+
position = (len(ordered) - 1) * fraction
|
|
58
|
+
low = int(position)
|
|
59
|
+
high = min(low + 1, len(ordered) - 1)
|
|
60
|
+
weight = position - low
|
|
61
|
+
return ordered[low] * (1 - weight) + ordered[high] * weight
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_shots(bundles: Iterable[dict]) -> Iterable[dict]:
|
|
65
|
+
for bundle in bundles:
|
|
66
|
+
for hole_shots in bundle.get("shots", {}).values():
|
|
67
|
+
yield from hole_shots
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def collect(shots: Iterable[dict], min_meters: float, lie: str | None) -> dict[tuple, list[tuple[float, str]]]:
|
|
71
|
+
"""Group shots by (club, lie) as (distance, ending lie) pairs.
|
|
72
|
+
|
|
73
|
+
A putt hit ON the green is dropped: it is short-game, not a club distance.
|
|
74
|
+
A putt hit anywhere else is kept as "Putter (approach)", because Garmin tags
|
|
75
|
+
both the same way and the off-green one is a real shot the player chose.
|
|
76
|
+
"""
|
|
77
|
+
grouped: dict[tuple, list[tuple[float, str]]] = defaultdict(list)
|
|
78
|
+
for shot in shots:
|
|
79
|
+
meters = shot.get("meters")
|
|
80
|
+
# clubId=0 means the watch could not identify the club: those shots
|
|
81
|
+
# carry an UNKNOWN shotType and must never be shown as a "club".
|
|
82
|
+
if not shot.get("clubId") or shot.get("excludeFromStats"):
|
|
83
|
+
continue
|
|
84
|
+
shot_lie = (shot.get("startLoc") or {}).get("lie") or "Unknown"
|
|
85
|
+
end_lie = (shot.get("endLoc") or {}).get("lie") or "Unknown"
|
|
86
|
+
if shot.get("shotType") == "PUTT" and shot_lie == "Green":
|
|
87
|
+
continue
|
|
88
|
+
# No name means an unmapped clubTypeId (or a club the watch faked):
|
|
89
|
+
# an id in a club table is noise, and it cannot be compared to anything.
|
|
90
|
+
club = shot.get("clubName")
|
|
91
|
+
if not club:
|
|
92
|
+
continue
|
|
93
|
+
# Keep putts played off the green: they are approaches with a flat club.
|
|
94
|
+
if club == PUTTER and shot_lie == "Green":
|
|
95
|
+
continue
|
|
96
|
+
if club == PUTTER:
|
|
97
|
+
# A putt that never reaches the green is not an approach either.
|
|
98
|
+
if end_lie != "Green":
|
|
99
|
+
continue
|
|
100
|
+
club = "Putter (approach)"
|
|
101
|
+
if meters is None or meters < min_meters:
|
|
102
|
+
continue
|
|
103
|
+
if lie and shot_lie != lie:
|
|
104
|
+
continue
|
|
105
|
+
grouped[(club, shot_lie)].append((float(meters), end_lie))
|
|
106
|
+
return grouped
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def report(grouped: dict[tuple, list[tuple[float, str]]], min_shots: int) -> None:
|
|
110
|
+
by_lie: dict[str, list[tuple[str, list[tuple[float, str]]]]] = defaultdict(list)
|
|
111
|
+
for (club, lie), shots in grouped.items():
|
|
112
|
+
by_lie[lie].append((club, shots))
|
|
113
|
+
|
|
114
|
+
lies = sorted(by_lie, key=lambda name: (LIE_ORDER.index(name) if name in LIE_ORDER else 99, name))
|
|
115
|
+
for lie in lies:
|
|
116
|
+
print(f"\n## {lie}")
|
|
117
|
+
print(f"{'club':22} {'n':>3} {'med':>6} {'good%':>6} {'max':>6} {'mishits':>8} (total m)")
|
|
118
|
+
rows = sorted(by_lie[lie], key=lambda row: -percentile([d for d, _ in row[1]], 0.5))
|
|
119
|
+
for club, shots in rows:
|
|
120
|
+
# The club's own floor: a wedge never plays 60 m, a driver never plays 12.
|
|
121
|
+
floor = min(MISHIT_METERS, percentile([d for d, _ in shots], 0.5) * 0.5)
|
|
122
|
+
played = [d for d, _ in shots if d >= floor]
|
|
123
|
+
mishits = len(shots) - len(played)
|
|
124
|
+
good = sum(1 for _, lie_out in shots if lie_out in GOOD_LIES)
|
|
125
|
+
med = percentile(played, 0.5) if played else float("nan")
|
|
126
|
+
good_pct = f"{100 * good / len(shots):5.0f}" if shots else " -"
|
|
127
|
+
flag = " (low n)" if len(played) < min_shots else ""
|
|
128
|
+
print(
|
|
129
|
+
f"{club[:22]:22} {len(played):3d} {med:6.0f} {good_pct}% "
|
|
130
|
+
f"{max(played) if played else float('nan'):6.0f} {mishits:8d}{flag}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: list[str] | None = None) -> int:
|
|
135
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
136
|
+
parser.add_argument("directory", nargs="?", default="rounds", help="directory of round_*.json (default: rounds)")
|
|
137
|
+
parser.add_argument("--lie", help="restrict to one lie (TeeBox, Fairway, Rough, ...)")
|
|
138
|
+
parser.add_argument("--min-meters", type=float, default=DEFAULT_MIN_METERS,
|
|
139
|
+
help="drop shots below this total distance (default: no floor)")
|
|
140
|
+
parser.add_argument("--min-shots", type=int, default=DEFAULT_MIN_SHOTS,
|
|
141
|
+
help=f"flag clubs under this sample size (default: {DEFAULT_MIN_SHOTS})")
|
|
142
|
+
parser.add_argument("--min-holes", type=int, default=9,
|
|
143
|
+
help="drop rounds with fewer played holes (default: 9)")
|
|
144
|
+
parser.add_argument("--keep-excluded", action="store_true",
|
|
145
|
+
help="also use rounds Garmin flagged excludeFromStats")
|
|
146
|
+
args = parser.parse_args(argv)
|
|
147
|
+
|
|
148
|
+
if not os.path.isdir(args.directory):
|
|
149
|
+
print(f"no such directory: {args.directory}", file=sys.stderr)
|
|
150
|
+
return 2
|
|
151
|
+
|
|
152
|
+
bundles = load_bundles(args.directory)
|
|
153
|
+
if not bundles:
|
|
154
|
+
print(f"no round_*.json in {args.directory}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
# Same round selection as round_report.py: one sample, one set of rules.
|
|
158
|
+
selection = select(bundles, args.min_holes, keep_excluded=args.keep_excluded)
|
|
159
|
+
kept = [b for b in selection.kept if played_holes(b)]
|
|
160
|
+
shots = list(iter_shots(kept))
|
|
161
|
+
grouped = collect(shots, args.min_meters, args.lie)
|
|
162
|
+
print(f"{selection.describe()}, {len(shots)} tracked shots, distances are {DISTANCE_LABEL}")
|
|
163
|
+
if not grouped:
|
|
164
|
+
print("no shot above the distance floor", file=sys.stderr)
|
|
165
|
+
return 1
|
|
166
|
+
report(grouped, args.min_shots)
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
raise SystemExit(main())
|