Menno van Leeuwen 30569c4019
Some checks failed
Ansible Lint Check / check-ansible (push) Failing after 27s
Nix Format Check / check-format (push) Failing after 1m33s
Python Lint Check / check-python (push) Failing after 19s
feat: enhance flitsmeister utility with improved CSV parsing and statistics calculation
2025-05-21 12:08:17 +02:00

329 lines
8.0 KiB
Go

package main
import (
"encoding/csv"
"flag"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
"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() {
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 <price> [-efficiency <km/l> | -lper100km <l/100km>] <filename.csv>")
flag.PrintDefaults()
return
}
// 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)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Comma = ','
records, err := reader.ReadAll()
if err != nil {
panic(err)
}
dutchMonths := map[string]string{
"januari": "January", "februari": "February", "maart": "March",
"april": "April", "mei": "May", "juni": "June", "juli": "July",
"augustus": "August", "september": "September", "oktober": "October",
"november": "November", "december": "December",
}
tripsByMonth := make(map[string][]Trip)
startAddrCount := make(map[string]int)
endAddrCount := make(map[string]int)
fuelEnabled := *fuelPrice > 0 && finalEfficiency > 0
// Parse CSV
for _, record := range records[1:] {
if len(record) < 13 {
continue
}
// Parse start time
startTime, err := parseDutchTime(record[1], dutchMonths)
if err != nil {
continue
}
// Parse end time
endTime, err := parseDutchTime(record[2], dutchMonths)
if err != nil {
continue
}
// 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)
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 {
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
}
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"
}
fmt.Printf(format+"\n", toInterfaceSlice(headers)...) // print header
fmt.Println(strings.Repeat("-", 180))
for _, month := range months {
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
}
}
}
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
}