Skip to content

Instantly share code, notes, and snippets.

@pblittle
Last active September 5, 2024 04:28
Show Gist options
  • Save pblittle/1dcaa232b7beb1f2b6baa1ccf1ed3eba to your computer and use it in GitHub Desktop.
Save pblittle/1dcaa232b7beb1f2b6baa1ccf1ed3eba to your computer and use it in GitHub Desktop.
This golf shot data processing application is designed to analyze and standardize shot data from various golf launch monitors, with current support for the Rapsodo MLM2 Pro. The app takes raw CSV data exported from a launch monitor as input, processes it to extract key metrics such as club type, total distance, and side carry, and then normalize…
package main
import (
"encoding/csv"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// ShotData represents a single row of processed golf shot data
type ShotData struct {
// Club represents the type of golf club used for the shot
// Examples: "Driver", "7I", "Pw" (Pitching Wedge), "3Hy" (3 Hybrid)
Club string
// Type categorizes the shot based on the club used
// Possible values: "Tee" (for drives and wood shots) or "Approach" (for irons and wedges)
Type string
// Target is the calculated average distance for this club type
// It's computed after processing all shots for each club type
Target float64
// Total represents the total distance of the shot in yards or meters
// This includes both carry distance and roll
Total float64
// Side represents the lateral deviation of the shot from the target line
// Positive values indicate a shot to the right, negative to the left
Side float64
}
// Regular expression to match header rows
var headerPattern = regexp.MustCompile(`(?i)(club type|total distance|side carry)`)
// ClubTypeMap maps regex patterns to shot types
var ClubTypeMap = map[*regexp.Regexp]string{
// Matches any club type starting with 'D' (case-insensitive)
// Examples: Driver, D, DR
regexp.MustCompile(`(?i)^d`): "Tee",
// Matches woods from 2 to 9 (case-insensitive)
// Examples: 3W, 5w, 7Wood
regexp.MustCompile(`(?i)^[2-9]w(ood)?$`): "Tee",
// Matches irons from 1 to 9 (case-insensitive)
// Examples: 4i, 7I, 9iron
regexp.MustCompile(`(?i)^\d+i(ron)?$`): "Approach",
// Matches specialty wedges (case-insensitive)
// P: Pitching wedge, S: Sand wedge, G: Gap wedge, L: Lob wedge
regexp.MustCompile(`(?i)^[psgl]w(edge)?$`): "Approach",
// Matches hybrid clubs (case-insensitive)
// Examples: 3 Hybrid, 4H, 5 h, 4Hy
regexp.MustCompile(`(?i)^\d+\s*(h(ybrid)?|hy)$`): "Approach",
}
func main() {
if len(os.Args) < 2 {
log.Fatal("Usage: go run main.go <input_csv_file>")
}
inputFile := os.Args[1]
outputFile := replaceFileExtension(inputFile, "_processed.csv")
shotData, err := processCSV(inputFile)
if err != nil {
log.Fatalf("Error processing CSV file: %v", err)
}
calculateTargets(&shotData)
if err := writeCSV(outputFile, shotData); err != nil {
log.Fatalf("Failed to write CSV: %v", err)
}
log.Printf("Data successfully processed and saved to %s", outputFile)
}
// processCSV reads the input CSV file and returns a slice of ShotData
func processCSV(inputFile string) ([]ShotData, error) {
file, err := os.Open(inputFile)
if err != nil {
return nil, fmt.Errorf("opening file: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Comma = ',' // Using comma as separator
reader.LazyQuotes = true
reader.FieldsPerRecord = -1 // Allow variable number of fields
var shotData []ShotData
var headers []string
lineCount := 0
inDataBlock := false
for {
row, err := reader.Read()
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("reading row: %w", err)
}
lineCount++
// Debug output
fmt.Printf("Row %d: %v\n", lineCount, row)
if isHeader(row) {
headers = normalizeHeaders(row)
inDataBlock = true
fmt.Printf("Headers found: %v\n", headers)
continue
}
if !inDataBlock {
continue
}
if isEmptyRow(row) || strings.HasPrefix(strings.ToLower(row[0]), "average") {
inDataBlock = false
continue
}
data, err := parseRow(row, headers)
if err != nil {
log.Printf("Skipping row %d due to error: %v", lineCount, err)
continue
}
if data != nil {
shotData = append(shotData, *data)
}
}
if len(shotData) == 0 {
return nil, fmt.Errorf("no valid data found in the file")
}
return shotData, nil
}
// normalizeHeaders standardizes header names
func normalizeHeaders(row []string) []string {
normalized := make([]string, len(row))
for i, header := range row {
normalized[i] = strings.ToLower(strings.TrimSpace(header))
}
return normalized
}
// isHeader checks if a row is a header row
func isHeader(row []string) bool {
return headerPattern.MatchString(strings.Join(row, " "))
}
// isEmptyRow checks if a row is empty
func isEmptyRow(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
// parseRow converts a row of strings into a ShotData struct
func parseRow(row []string, headers []string) (*ShotData, error) {
if len(row) < 3 || len(headers) < 3 {
return nil, fmt.Errorf("insufficient columns")
}
var club, totalStr, sideStr string
for i, header := range headers {
if i >= len(row) {
break
}
switch {
case strings.Contains(header, "club type"):
club = row[i]
case strings.Contains(header, "total distance"):
totalStr = row[i]
case strings.Contains(header, "side carry"):
sideStr = row[i]
}
}
if club == "" || totalStr == "" || sideStr == "" {
return nil, fmt.Errorf("missing required fields")
}
total, err := strconv.ParseFloat(totalStr, 64)
if err != nil {
return nil, fmt.Errorf("parsing total distance '%s': %w", totalStr, err)
}
side, err := strconv.ParseFloat(sideStr, 64)
if err != nil {
return nil, fmt.Errorf("parsing side carry '%s': %w", sideStr, err)
}
normalizedClub := normalizeClubType(club)
shotType := determineShotType(normalizedClub)
return &ShotData{
Club: normalizedClub,
Type: shotType,
Total: total,
Side: side,
}, nil
}
// normalizeClubType standardizes the club type notation
func normalizeClubType(clubType string) string {
clubType = strings.TrimSpace(strings.ToLower(clubType))
// Normalize driver
if clubType == "driver" || clubType == "d" || clubType == "dr" {
return "Driver"
}
// Normalize woods
if strings.Contains(clubType, "w") || strings.Contains(clubType, "wood") {
number := extractNumber(clubType)
if number != "" {
return number + "W"
}
}
// Normalize hybrids
if strings.Contains(clubType, "h") || strings.Contains(clubType, "hybrid") || strings.Contains(clubType, "hy") {
number := extractNumber(clubType)
if number != "" {
return number + "Hy"
}
}
// Normalize irons
if strings.Contains(clubType, "i") || strings.Contains(clubType, "iron") {
number := extractNumber(clubType)
if number != "" {
return number + "I"
}
}
// Normalize wedges
wedgeMap := map[string]string{
"pw": "Pw", "pitching": "Pw",
"sw": "Sw", "sand": "Sw",
"gw": "Gw", "gap": "Gw",
"lw": "Lw", "lob": "Lw",
}
for key, value := range wedgeMap {
if strings.Contains(clubType, key) {
return value
}
}
// If no normalization rule applies, capitalize the first letter
return strings.Title(clubType)
}
// extractNumber extracts the first number from a string
func extractNumber(s string) string {
re := regexp.MustCompile(`\d+`)
match := re.FindString(s)
return match
}
// determineShotType determines the shot type based on the club type
func determineShotType(clubType string) string {
for pattern, shotType := range ClubTypeMap {
if pattern.MatchString(clubType) {
return shotType
}
}
return "Approach" // Default shot type
}
// calculateTargets calculates the target distance for each club type
func calculateTargets(shotData *[]ShotData) {
totals := make(map[string]float64)
counts := make(map[string]int)
for _, data := range *shotData {
totals[data.Club] += data.Total
counts[data.Club]++
}
for i := range *shotData {
if counts[(*shotData)[i].Club] > 0 {
(*shotData)[i].Target = totals[(*shotData)[i].Club] / float64(counts[(*shotData)[i].Club])
}
}
}
// writeCSV writes the processed shot data to a CSV file
func writeCSV(filename string, data []ShotData) error {
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// Write header
if err := writer.Write([]string{"Club", "Type", "Target", "Total", "Side"}); err != nil {
return fmt.Errorf("writing header: %w", err)
}
// Write data rows
for _, d := range data {
record := []string{
d.Club,
d.Type,
fmt.Sprintf("%.2f", d.Target),
fmt.Sprintf("%.2f", d.Total),
fmt.Sprintf("%.2f", d.Side),
}
if err := writer.Write(record); err != nil {
return fmt.Errorf("writing record: %w", err)
}
}
return nil
}
// replaceFileExtension replaces the file extension of a filename
func replaceFileExtension(filename, newSuffix string) string {
base := strings.TrimSuffix(filename, filepath.Ext(filename))
return base + newSuffix
}
@pblittle
Copy link
Author

pblittle commented Sep 5, 2024

go run ./main.go ~/Downloads/mlm2pro_shotexport_090324.csv
Row 1: [Rapsodo MLM2PRO: Palmer Little - 09/03/2024 9:29 PM ]
Row 2: [Club Type Club Brand Club Model Carry Distance Total Distance Ball Speed Launch Angle Launch Direction Apex Side Carry Club Speed Smash Factor Descent Angle]
Headers found: [club type club brand club model carry distance total distance ball speed launch angle launch direction apex side carry club speed smash factor descent angle]
Row 3: [pw U.S. Kids Golf Tour Series 5 65.1 78.3 61.1 24.6 0.0 28.8 -0.3 59.6 1.03 34.2]
Row 4: [pw U.S. Kids Golf Tour Series 5 78.2 95.7 70.3 19.4 5.1 28.2 5.4 63.5 1.11 30.1]
Row 5: [pw U.S. Kids Golf Tour Series 5 80.7 94.1 69.3 24.1 2.3 37.6 1.7 60.1 1.15 36.5]
Row 6: [pw U.S. Kids Golf Tour Series 5 76.4 92.5 68.6 20.8 7.9 29.5 9.1 61.3 1.12 31.6]
Row 7: [pw U.S. Kids Golf Tour Series 5 72.6 85.0 64.3 28.2 2.4 39.6 1.7 67.8 0.00 40.1]
Row 8: [pw U.S. Kids Golf Tour Series 5 82.7 94.7 69.5 29.7 3.0 50.7 2.8 64.6 1.08 43.8]
Row 9: [pw U.S. Kids Golf Tour Series 5 84.3 97.6 71.0 25.5 5.7 43.1 6.8 64.6 1.10 39.2]
Row 10: [pw U.S. Kids Golf Tour Series 5 78.2 90.8 67.3 27.9 0.0 43.3 -1.1 65.1 1.03 40.8]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment