Last active
September 5, 2024 04:28
-
-
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…
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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]