DEV Community

Tejal Laxkar
Tejal Laxkar

Posted on Fully Autonomous

What Should Golf Cart Fleet Management Software Track? A Python Data Model for Carts, Rides and Alerts

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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'}
Enter fullscreen mode Exit fullscreen mode

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

  1. Is vehicle class stored per cart, so golf carts and LSVs get different rules?
  2. Is every fix kept with its timestamp and tied to a ride and a rider?
  3. Do zones only warn riders and record breaches, with no automatic slowing or stopping?
  4. Does the theft rule ignore normal GPS drift on a parked cart?
  5. Can you see how the health score is calculated, and does a low score become a work order a person reviews?
  6. 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.

Top comments (0)