Golf cart fleet management software should track five things: carts, rides, battery readings, zones and alerts. A cart has many rides and many battery readings. A ride is a series of GPS fixes. Zones are checked against each fix, and anything worth a human's attention becomes an alert tied back to a cart.
The rest of this post builds that model in plain Python 3.11, standard library only. Paste the three code blocks into one file in order and it runs as-is.
What entities does golf cart fleet management software need?
| Entity | Key fields | Why it matters |
|---|---|---|
| Cart | id, vehicle_class, pack_voltage, locked | Everything else hangs off the cart id |
| Fix | lat, lon, timestamp, speed_mph | The raw GPS reading behind every rule |
| Ride | cart_id, rider_id, start, end | Tells you who had the cart when something happened |
| BatteryReading | cart_id, soc_pct, timestamp | Feeds the health score and charging decisions |
| Zone | name, center, radius_m, kind | Greens, ponds and the property edge, as circles |
| Alert | kind, cart_id, message, at | The one list a manager actually reads |
Why is vehicle_class a field and not a label?
Because the class changes which rules apply. Under the US federal standard, a low-speed vehicle (LSV) is a 4-wheeled motor vehicle whose top speed is greater than 20 mph but not greater than 25 mph (Federal Register, 17 June 1998). A cart that tops out at 20 mph or below falls outside that definition.
A mixed fleet therefore has two expected speed ceilings. If the class is stored on the cart, a speed check is a dictionary lookup.
How do you model carts, fixes, rides and zones in Python?
Dataclasses are enough. Fixes and zones are frozen because a reading should never change after it is recorded.
from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum from math import asin, cos, radians, sin, sqrt class VehicleClass(Enum): GOLF_CART = "golf_cart" LSV = "lsv" SPEED_LIMIT_MPH = {VehicleClass.GOLF_CART: 20.0, VehicleClass.LSV: 25.0} EARTH_RADIUS_M = 6_371_000 THEFT_DISTANCE_M = 30.0 @dataclass class Cart: id: str vehicle_class: VehicleClass pack_voltage: int = 48 locked: bool = False @dataclass(frozen=True) class Fix: lat: float lon: float timestamp: datetime speed_mph: float @dataclass class Ride: cart_id: str rider_id: str start: datetime end: datetime | None = None @dataclass(frozen=True) class BatteryReading: cart_id: str soc_pct: int timestamp: datetime @dataclass(frozen=True) class Zone: name: str lat: float lon: float radius_m: float kind: str # "no_go" (greens, ponds) or "boundary" (property edge) @dataclass class Alert: kind: str cart_id: str message: str at: datetime How should zone, theft, speed and health rules work?
Each rule is a small function that reads data and appends to the alert list. None of them sends a command to a vehicle.
That matters most for zones. check_zones records the breach and returns a warning message for the rider. It never slows or stops the cart. Cutting power to a moving vehicle because of one GPS reading is a safety decision, and it does not belong in a geometry helper.
The speed check follows the same idea. A golf cart reporting 23.5 mph is more likely a wrong class, a modified cart or a noisy reading than something to enforce, so it is raised as a data-quality or setup flag.
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Great-circle distance between two points, in meters.""" dlat, dlon = radians(lat2 - lat1), radians(lon2 - lon1) a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 return 2 * EARTH_RADIUS_M * asin(sqrt(a)) def check_zones(ride: Ride, fix: Fix, zones: list[Zone], alerts: list[Alert]) -> list[str]: """Record zone breaches and return warning messages for the rider.""" # Warn and record only. This function must never slow or stop a cart. warnings: list[str] = [] for zone in zones: dist = haversine_m(fix.lat, fix.lon, zone.lat, zone.lon) inside = dist <= zone.radius_m breached = inside if zone.kind == "no_go" else not inside if not breached: continue detail = f"rider {ride.rider_id}, {zone.name} ({zone.kind}), {dist:.1f} m from center" alerts.append(Alert("zone_breach", ride.cart_id, detail, fix.timestamp)) verb = "leave" if zone.kind == "no_go" else "return inside" warnings.append(f"Please {verb} {zone.name}") return warnings def check_locked_move(cart: Cart, prev: Fix, curr: Fix, alerts: list[Alert]) -> None: """Alert when a locked cart moves more than 30 m between two fixes.""" moved = haversine_m(prev.lat, prev.lon, curr.lat, curr.lon) if cart.locked and moved > THEFT_DISTANCE_M: detail = f"moved {moved:.1f} m while locked" alerts.append(Alert("locked_cart_moved", cart.id, detail, curr.timestamp)) def check_speed(cart: Cart, fix: Fix, alerts: list[Alert]) -> None: """Flag a speed above the class limit. A data or setup flag, not enforcement.""" limit = SPEED_LIMIT_MPH[cart.vehicle_class] if fix.speed_mph > limit: detail = f"{fix.speed_mph} mph reported, class limit is {limit:.0f} mph ({cart.vehicle_class.value})" alerts.append(Alert("speed_over_class_limit", cart.id, detail, fix.timestamp)) def health_score(soc_pct: int, days_since_service: int, fault_codes: int) -> int: """Score 0-100: up to 50 for charge, 30 for recent service, 20 for no faults.""" charge = min(max(soc_pct, 0), 100) // 2 service = max(0, 30 - days_since_service // 6) faults = max(0, 20 - 10 * fault_codes) return charge + service + faults def work_order_if_needed(cart: Cart, score: int, threshold: int = 50) -> dict | None: """Turn a low score into a work order for a person to review.""" if score >= threshold: return None return {"cart_id": cart.id, "reason": f"health score {score} is below {threshold}", "status": "open"} The 30 m theft threshold sits above normal GPS drift for a parked cart. The health weights are a starting point, so tune them against your own service history.
What does it print on sample data?
The sample points share one longitude, so every distance is a straight north-south line and easy to check by hand: 0.0001 degrees of latitude is about 11.1 m.
t0 = datetime(2026, 5, 1, 10, 0, 0) zones = [ Zone("Green 7", 33.0010, -112.0, 30, "no_go"), Zone("Course boundary", 33.0000, -112.0, 500, "boundary"), ] alerts: list[Alert] = [] c1 = Cart("C-01", VehicleClass.GOLF_CART) ride = Ride("C-01", "R-1001", start=t0) fixes = [ Fix(33.0000, -112.0, t0, 8.0), Fix(33.0009, -112.0, t0 + timedelta(seconds=30), 9.0), Fix(33.0020, -112.0, t0 + timedelta(seconds=60), 23.5), ] for fix in fixes: for warning in check_zones(ride, fix, zones, alerts): print(f"Rider warning: {warning}") check_speed(c1, fix, alerts) ride.end = fixes[-1].timestamp minutes = (ride.end - ride.start).total_seconds() / 60 print(f"Ride on {ride.cart_id} by {ride.rider_id}: {minutes:.1f} min, {len(fixes)} fixes") c2 = Cart("C-02", VehicleClass.LSV, locked=True) parked = Fix(33.0000, -112.0, t0, 0.0) later = Fix(33.0005, -112.0, t0 + timedelta(seconds=60), 4.0) check_locked_move(c2, parked, later, alerts) for a in alerts: print(f"[{a.at:%H:%M:%S}] {a.kind} {a.cart_id}: {a.message}") readings = [BatteryReading("C-01", 82, t0), BatteryReading("C-02", 38, t0)] history = {"C-01": (30, 0), "C-02": (150, 2)} # days since service, fault codes for cart, reading in zip([c1, c2], readings): days, fault_count = history[cart.id] score = health_score(reading.soc_pct, days, fault_count) print(f"{cart.id} health: {score}", work_order_if_needed(cart, score)) Expected output:
Rider warning: Please leave Green 7 Ride on C-01 by R-1001: 1.0 min, 3 fixes [10:00:30] zone_breach C-01: rider R-1001, Green 7 (no_go), 11.1 m from center [10:01:00] speed_over_class_limit C-01: 23.5 mph reported, class limit is 20 mph (golf_cart) [10:01:00] locked_cart_moved C-02: moved 55.6 m while locked C-01 health: 86 None C-02 health: 24 {'cart_id': 'C-02', 'reason': 'health score 24 is below 50', 'status': 'open'} C-01 scores 41 + 25 + 20 = 86. C-02 scores 19 + 5 + 0 = 24, which is under 50, so it gets a work order.
What should you check before you build or buy?
Before you build or buy: 6 checks
- Is vehicle class stored per cart, so golf carts and LSVs get different rules?
- Is every fix kept with its timestamp and tied to a ride and a rider?
- Do zones only warn riders and record breaches, with no automatic slowing or stopping?
- Does the theft rule ignore normal GPS drift on a parked cart?
- Can you see how the health score is calculated, and does a low score become a work order a person reviews?
- Are odd readings, such as a speed above the class limit, shown as flags to investigate?
Where can you see this in a running product?
Axons Mobility is golf cart and LSV fleet management software, based in Dubai, UAE, for cart makers, dealers and fleet operators. If you want to see how these pieces look in a running product, the golf cart fleet use cases page walks through eight of them.
Run the script against a day of your own fixes first, then read that page with your results next to it.
