From 30569c4019af044e060debadc01da159d776efed Mon Sep 17 00:00:00 2001 From: Menno van Leeuwen Date: Wed, 21 May 2025 12:08:17 +0200 Subject: [PATCH] feat: enhance flitsmeister utility with improved CSV parsing and statistics calculation --- .../tasks/global/utils/flitsmeister.go | 337 ++++++++++++++---- 1 file changed, 269 insertions(+), 68 deletions(-) diff --git a/config/ansible/tasks/global/utils/flitsmeister.go b/config/ansible/tasks/global/utils/flitsmeister.go index 2ca5371..47432ca 100644 --- a/config/ansible/tasks/global/utils/flitsmeister.go +++ b/config/ansible/tasks/global/utils/flitsmeister.go @@ -2,7 +2,9 @@ package main import ( "encoding/csv" + "flag" "fmt" + "math" "os" "sort" "strconv" @@ -10,13 +12,51 @@ import ( "time" ) +type Trip struct { + StartTime time.Time + EndTime time.Time + StartAddr string + EndAddr string + KMStart float64 + KMEnd float64 + Distance float64 + License string + BusinessCost float64 + Type string +} + +type MonthStats struct { + TotalKM float64 + Trips int + Longest float64 + Shortest float64 + TotalDuration time.Duration + LongestGap time.Duration + OdoAnomalies int + AvgSpeed float64 + AvgTripDuration time.Duration + FuelCost float64 +} + func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: flitsmeister ") + fuelPrice := flag.Float64("fuelprice", 0, "Fuel price per liter (EUR)") + fuelEfficiency := flag.Float64("efficiency", 0, "Fuel efficiency (km per liter)") + lPer100km := flag.Float64("lper100km", 0, "Fuel consumption (liters per 100km)") + flag.Parse() + + if len(flag.Args()) < 1 { + fmt.Println("Usage: go run main.go -fuelprice [-efficiency | -lper100km ] ") + flag.PrintDefaults() return } - file, err := os.Open(os.Args[1]) + // Convert l/100km to km/l if provided + finalEfficiency := *fuelEfficiency + if *lPer100km > 0 { + finalEfficiency = 100.0 / *lPer100km + } + + file, err := os.Open(flag.Arg(0)) if err != nil { panic(err) } @@ -29,7 +69,6 @@ func main() { panic(err) } - // Dutch to English month translation dutchMonths := map[string]string{ "januari": "January", "februari": "February", "maart": "March", "april": "April", "mei": "May", "juni": "June", "juli": "July", @@ -37,91 +76,253 @@ func main() { "november": "November", "december": "December", } - monthlyTotals := make(map[string]float64) - monthlyCounts := make(map[string]int) - monthlyLongest := make(map[string]float64) - monthlyShortest := make(map[string]float64) + tripsByMonth := make(map[string][]Trip) + startAddrCount := make(map[string]int) + endAddrCount := make(map[string]int) + fuelEnabled := *fuelPrice > 0 && finalEfficiency > 0 - // Skip header row + // Parse CSV for _, record := range records[1:] { - if len(record) < 8 { + if len(record) < 13 { continue } - // Parse date with Dutch month names - dateParts := strings.Split(record[1], " ") - if len(dateParts) < 4 { - continue - } - - // Translate Dutch month to English - dutchMonth := strings.ToLower(dateParts[1]) - engMonth, ok := dutchMonths[dutchMonth] - if !ok { - fmt.Printf("Unknown month: %s\n", dutchMonth) - continue - } - - // Rebuild date string with English month - dateStr := fmt.Sprintf("%s %s %s %s", - dateParts[0], engMonth, dateParts[2], dateParts[3]) - - // Parse date (single-digit days allowed) - tripDate, err := time.Parse("2 January 2006 15:04", dateStr) + // Parse start time + startTime, err := parseDutchTime(record[1], dutchMonths) if err != nil { - fmt.Printf("Error parsing date: %v\n", err) continue } - // Create month key (format: YYYY-MM) - monthKey := fmt.Sprintf("%d-%02d", tripDate.Year(), tripDate.Month()) - - // Parse distance - distance, err := strconv.ParseFloat(record[7], 64) + // Parse end time + endTime, err := parseDutchTime(record[2], dutchMonths) if err != nil { - fmt.Printf("Error parsing distance: %v\n", err) continue } - monthlyTotals[monthKey] += distance - monthlyCounts[monthKey]++ + // Parse distance data + kmStart, _ := strconv.ParseFloat(strings.ReplaceAll(record[5], ",", ""), 64) + kmEnd, _ := strconv.ParseFloat(strings.ReplaceAll(record[6], ",", ""), 64) + distance, _ := strconv.ParseFloat(strings.ReplaceAll(record[7], ",", ""), 64) - // Track longest and shortest trip - if _, exists := monthlyLongest[monthKey]; !exists { - monthlyLongest[monthKey] = distance - monthlyShortest[monthKey] = distance + trip := Trip{ + StartTime: startTime, + EndTime: endTime, + StartAddr: record[3], + EndAddr: record[4], + KMStart: kmStart, + KMEnd: kmEnd, + Distance: distance, + License: record[8], + BusinessCost: parseFloat(record[11]), + Type: strings.TrimSpace(record[12]), + } + + monthKey := fmt.Sprintf("%d-%02d", startTime.Year(), startTime.Month()) + tripsByMonth[monthKey] = append(tripsByMonth[monthKey], trip) + startAddrCount[trip.StartAddr]++ + endAddrCount[trip.EndAddr]++ + } + + // Calculate stats + months := sortedKeys(tripsByMonth) + statsByMonth := calculateStats(tripsByMonth, fuelEnabled, *fuelPrice, finalEfficiency) + + // Print results + printMainTable(statsByMonth, months, fuelEnabled, tripsByMonth) + printTopAddresses(startAddrCount, endAddrCount) +} + +func parseDutchTime(datetime string, monthMap map[string]string) (time.Time, error) { + parts := strings.Split(datetime, " ") + if len(parts) < 4 { + return time.Time{}, fmt.Errorf("invalid time format") + } + + engMonth, ok := monthMap[strings.ToLower(parts[1])] + if !ok { + return time.Time{}, fmt.Errorf("unknown month") + } + + timeStr := fmt.Sprintf("%s %s %s %s", parts[0], engMonth, parts[2], parts[3]) + return time.Parse("2 January 2006 15:04", timeStr) +} + +func calculateStats(tripsByMonth map[string][]Trip, fuelEnabled bool, fuelPrice, fuelEfficiency float64) map[string]MonthStats { + stats := make(map[string]MonthStats) + + for month, trips := range tripsByMonth { + var s MonthStats + var prevEnd time.Time + var longestGap time.Duration + + sumSpeed := 0.0 + speedCount := 0 + + sort.Slice(trips, func(i, j int) bool { + return trips[i].StartTime.Before(trips[j].StartTime) + }) + + for i, t := range trips { + s.TotalKM += t.Distance + s.Trips++ + + duration := t.EndTime.Sub(t.StartTime) + s.TotalDuration += duration + if duration.Hours() > 0 { + sumSpeed += t.Distance / duration.Hours() + speedCount++ + } + + if t.Distance > s.Longest { + s.Longest = t.Distance + } + if t.Distance < s.Shortest || s.Shortest == 0 { + s.Shortest = t.Distance + } + + if i > 0 { + gap := t.StartTime.Sub(prevEnd) + if gap > longestGap { + longestGap = gap + } + if math.Abs(trips[i-1].KMEnd-t.KMStart) > 0.01 { + s.OdoAnomalies++ + } + } + prevEnd = t.EndTime + } + + s.LongestGap = longestGap + if speedCount > 0 { + s.AvgSpeed = sumSpeed / float64(speedCount) } else { - if distance > monthlyLongest[monthKey] { - monthlyLongest[monthKey] = distance - } - if distance < monthlyShortest[monthKey] { - monthlyShortest[monthKey] = distance - } + s.AvgSpeed = 0 } + if s.Trips > 0 { + s.AvgTripDuration = time.Duration(int64(s.TotalDuration) / int64(s.Trips)) + } + + if fuelEnabled { + s.FuelCost = (s.TotalKM / fuelEfficiency) * fuelPrice + } + + stats[month] = s } - // Sort months chronologically - var months []string - for k := range monthlyTotals { - months = append(months, k) + return stats +} + +func printMainTable(stats map[string]MonthStats, months []string, fuelEnabled bool, tripsByMonth map[string][]Trip) { + fmt.Println("\n=== Monthly Driving Overview ===") + + headers := []string{"Month", "Total", "Trips", "AvgKM", "Longest", "Shortest", + "DriveTime", "AvgTripDur", "OdoErr", "AvgSpeed"} + format := "%-10s | %-16s | %-7s | %-14s | %-24s | %-26s | %-18s | %-18s | %-10s | %-18s" + if fuelEnabled { + headers = append(headers, "Fuel Cost (EUR)") + format += " | %-18s" } - sort.Strings(months) + fmt.Printf(format+"\n", toInterfaceSlice(headers)...) // print header + fmt.Println(strings.Repeat("-", 180)) - // Print table header - fmt.Printf("%-10s | %-12s | %-7s | %-12s | %-12s | %-12s\n", "Month", "Total KM", "Trips", "Avg KM/Trip", "Longest", "Shortest") - fmt.Println(strings.Repeat("-", 77)) - - // Print stats per month for _, month := range months { - total := monthlyTotals[month] - trips := monthlyCounts[month] - avg := 0.0 - if trips > 0 { - avg = total / float64(trips) + s := stats[month] + trips := tripsByMonth[month] + + // Find longest and shortest trip durations + var longestDur, shortestDur time.Duration + var longestDist, shortestDist float64 + if len(trips) > 0 { + for i, t := range trips { + dur := t.EndTime.Sub(t.StartTime) + if t.Distance > longestDist || i == 0 { + longestDist = t.Distance + longestDur = dur + } + if t.Distance < shortestDist || i == 0 { + shortestDist = t.Distance + shortestDur = dur + } + } } - longest := monthlyLongest[month] - shortest := monthlyShortest[month] - fmt.Printf("%-10s | %-12.2f | %-7d | %-12.2f | %-12.2f | %-12.2f\n", - month, total, trips, avg, longest, shortest) + + row := []interface{}{ + month, + fmt.Sprintf("%.2f Km", s.TotalKM), + fmt.Sprintf("%d", s.Trips), + fmt.Sprintf("%.2f Km", safeDiv(s.TotalKM, float64(s.Trips))), + fmt.Sprintf("%.2f Km (%s)", longestDist, fmtDuration(longestDur)), + fmt.Sprintf("%.2f Km (%s)", shortestDist, fmtDuration(shortestDur)), + fmtDuration(s.TotalDuration), + fmtDuration(s.AvgTripDuration), + fmt.Sprintf("%d", s.OdoAnomalies), + fmt.Sprintf("%.2f Km/h", s.AvgSpeed), + } + if fuelEnabled { + row = append(row, fmt.Sprintf("%.2f EUR", s.FuelCost)) + } + fmt.Printf(format+"\n", row...) } } + +func toInterfaceSlice(strs []string) []interface{} { + res := make([]interface{}, len(strs)) + for i, v := range strs { + res[i] = v + } + return res +} + +func printTopAddresses(start, end map[string]int) { + fmt.Println("\n=== Frequent Locations ===") + fmt.Println("Top 3 Start Addresses:") + printTopN(start, 3) + fmt.Println("\nTop 3 End Addresses:") + printTopN(end, 3) +} + +// Helper functions (safeDiv, fmtDuration, printTopN) remain unchanged from previous version +// [Include the helper functions from previous script here] + +func safeDiv(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b +} + +func fmtDuration(d time.Duration) string { + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + return fmt.Sprintf("%02dh%02dm", h, m) +} + +func printTopN(counter map[string]int, n int) { + type kv struct { + Key string + Value int + } + var sorted []kv + for k, v := range counter { + sorted = append(sorted, kv{k, v}) + } + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Value > sorted[j].Value }) + + for i := 0; i < n && i < len(sorted); i++ { + fmt.Printf("%d. %s (%d)\n", i+1, sorted[i].Key, sorted[i].Value) + } +} + +func sortedKeys(m map[string][]Trip) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func parseFloat(s string) float64 { + f, _ := strconv.ParseFloat(strings.ReplaceAll(s, ",", ""), 64) + return f +}