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.
Files changed (27) hide show
  1. garmin_golf_analytics-0.0.4/LICENSE +21 -0
  2. garmin_golf_analytics-0.0.4/PKG-INFO +178 -0
  3. garmin_golf_analytics-0.0.4/README.md +150 -0
  4. garmin_golf_analytics-0.0.4/pyproject.toml +56 -0
  5. garmin_golf_analytics-0.0.4/setup.cfg +4 -0
  6. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/__init__.py +5 -0
  7. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/cli.py +44 -0
  8. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/club_report.py +171 -0
  9. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/garmin_golf_fetch.py +280 -0
  10. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/hole_sync.py +50 -0
  11. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/round_report.py +166 -0
  12. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/rounds_common.py +85 -0
  13. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics/scoring.py +70 -0
  14. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/PKG-INFO +178 -0
  15. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/SOURCES.txt +25 -0
  16. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/dependency_links.txt +1 -0
  17. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/entry_points.txt +5 -0
  18. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/requires.txt +5 -0
  19. garmin_golf_analytics-0.0.4/src/garmin_golf_analytics.egg-info/top_level.txt +1 -0
  20. garmin_golf_analytics-0.0.4/tests/test_cli.py +48 -0
  21. garmin_golf_analytics-0.0.4/tests/test_club_report.py +216 -0
  22. garmin_golf_analytics-0.0.4/tests/test_garmin_golf_fetch.py +297 -0
  23. garmin_golf_analytics-0.0.4/tests/test_hole_sync.py +69 -0
  24. garmin_golf_analytics-0.0.4/tests/test_putts.py +62 -0
  25. garmin_golf_analytics-0.0.4/tests/test_round_report.py +120 -0
  26. garmin_golf_analytics-0.0.4/tests/test_rounds_common.py +107 -0
  27. 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
+ [![tests](https://github.com/etroulland/garmin-golf-analytics/actions/workflows/tests.yml/badge.svg)](https://github.com/etroulland/garmin-golf-analytics/actions/workflows/tests.yml)
32
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ [![tests](https://github.com/etroulland/garmin-golf-analytics/actions/workflows/tests.yml/badge.svg)](https://github.com/etroulland/garmin-golf-analytics/actions/workflows/tests.yml)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Garmin Connect golf rounds into distances and score numbers you can act on."""
2
+
3
+ __version__ = "0.0.4"
4
+
5
+ __all__ = ["__version__"]
@@ -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())