tracksolid_timescale_grafan.../01_BusinessAnalytics.md
David Kiania 274473c544
Some checks failed
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run
Static Analysis / static (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled
docs: update analytics report with live DB state (18 Apr 2026)
- §1: add current deployment state table — 63 devices, 0 driver names,
  5 trips, pipeline stopped 6 Apr (401 token expiry); note tracksolid_2
  vs tracksolid schema split
- §6: status column per question (Ready/Needs data/Blocked) reflecting
  actual DB state; add cost-per-ticket, city drift, odometer rows
- §8: add Step 0 full deployment sequence (git pull → migrations 01-06
  → container rebuild → sync_driver_audit → import_drivers_csv);
  Step 3 updated to reference import script; Step 5 collapsed to pointer
- Footer: db-state stamp and update date

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 08:39:58 +03:00

57 KiB
Raw Permalink Blame History

Fireside Communications — Fleet Business Analytics

Tracksolid Pro · Field Operations & Logistics Intelligence Assessment

April 2026


Table of Contents

  1. How to Use This Document
  2. Data Foundation Summary
  3. Fleet Utilisation
  4. Driver Behaviour
  5. Real-Time Dispatch & Field-Service SLAs
  6. Distance per Driver per Day
  7. Business Questions Now Answerable
  8. Grafana Dashboard Blueprint
  9. What Unlocks the Remaining 30%
  10. Fleet Readiness Scorecard
  11. Service-Interval Forecaster

0. How to Use This Document

Every query in this document is tagged by intended consumption cadence. Build Grafana panels, alert rules, and scheduled reports against the tag — not the SQL text — so that moving a metric between dashboard and alert is a one-line change.

Tag Meaning Typical cadence Owner
[DASHBOARD] Live or near-live panel Refresh 30 s 5 min Ops / Dispatch
[ALERT] Trigger a page or ticket Evaluate 1 15 min On-call / Fleet Manager
[MONTHLY] Management / exec reporting Run on 1st of month Finance / Ops Lead
[AD-HOC] Investigation, audit, one-off On demand Analyst / Ops

Reading a query block: each section lead-in states the tag(s). If a query has no tag it is reference material (schema, benchmark tables, appendix).

Thresholds are starting points, not gospel. Every red/amber/green band in this document must be re-calibrated against your own 30-day distribution once data matures. See Appendix B — Threshold Calibration Guide.

City-cohort cuts. Fireside operates in Nairobi, Mombasa, and Kampala. Traffic, fuel prices, and shift norms differ materially between them. Any fleet-level metric should be sliceable by devices.assigned_city once that column is populated (see §3.7).


1. Data Foundation Summary

1.1 Current Deployment State (as of 18 Apr 2026)

⚠ New stack not yet live. The refactored ingestion pipeline (ingest_movement_rev.py v2.2) targets the tracksolid schema, which is currently empty. All live data sits in the legacy tracksolid_2 schema populated by the prior codebase. The queries in this document are written for the target schema (tracksolid) and will produce results once the new stack is deployed and the device sync has run.

Metric Observed value Source
Devices registered 63 (AT4-series, 353549* IMEIs) tracksolid_2.devices
Driver names populated 0 / 63 tracksolid_2.devices
Vehicle numbers populated 0 / 63 tracksolid_2.devices
SIM numbers populated 14 / 63 tracksolid_2.devices
Live positions (stale) 19 tracksolid_2.live_positions
Position history rows 208 tracksolid_2.position_history
Trips recorded 5 (12.8 km total) tracksolid_2.trips
Parking / alarms / OBD 0 each tracksolid_2.*
Last pipeline run 6 Apr 2026 13:20 EAT tracksolid_2.ingestion_log
Pipeline failure rate 41% (277/668 runs, all 401 auth errors) tracksolid_2.ingestion_log

Why the pipeline stopped (6 Apr): 276 consecutive 401 Unauthorized errors against eu-open.tracksolidpro.com. The API token expired and was not refreshed — the prior codebase lacked the auto-refresh logic that ts_shared_rev.py now includes. Deploying the new stack resolves this permanently.

CSV fleet (144 devices, X3/JC400P series): The 20260414_FS__Logistics - final_fixed.csv file contains a separate, newer batch of devices (865135*, 862798* IMEIs) with full driver names and plates. These 144 devices are not yet registered in the DB at all — they will be synced by sync_driver_audit.py after the new stack is deployed, then enriched by import_drivers_csv.py.


1.2 Target Data Architecture

Once deployed, the ingestion stack populates the following data sources:

Table Content Frequency
tracksolid.live_positions Current position of every vehicle Every 60 seconds
tracksolid.position_history (source: poll) Fleet position snapshot Every 60 seconds
tracksolid.position_history (source: track_list) Every GPS waypoint per device Every 30 minutes (35-min window)
tracksolid.trips Trip summaries: distance, speed, duration, idle Every 15 minutes
tracksolid.parking_events Stop/idle events with address and duration Every 15 minutes
tracksolid.alarms Alarm events with type, severity, location Every 5 minutes
tracksolid.devices Vehicle and driver registry Daily at 02:00
dwh_gold.fact_daily_fleet_metrics Daily KPI aggregates per vehicle Nightly ETL

Position history density improvement with poll_track_list (POLL-01):

Before After
~1 fix per minute per vehicle 26 fixes per minute per active vehicle
Route gaps of 12 km between points Continuous accurate path traces
Speed deltas invisible at 60s intervals Harsh driving events detectable at 1030s intervals

All timestamps are stored in UTC and displayed in Africa/Nairobi (EAT = UTC+3) throughout this document.


2. Fleet Utilisation

2.1 Utilisation Rate

The percentage of working hours a vehicle is actively driving versus sitting idle or unused. Calculated per vehicle per day:

SELECT
  t.imei,
  d.driver_name,
  d.vehicle_number,
  DATE(t.start_time AT TIME ZONE 'Africa/Nairobi')         AS working_day,
  ROUND(SUM(t.driving_time_s) / 3600.0, 2)                AS drive_hours,
  ROUND(SUM(t.idle_time_s)    / 3600.0, 2)                AS idle_hours,
  ROUND(
    SUM(t.driving_time_s) / (10.0 * 3600) * 100, 1
  )                                                        AS utilisation_pct
FROM tracksolid.trips t
JOIN tracksolid.devices d ON d.imei = t.imei
WHERE t.start_time >= CURRENT_DATE AT TIME ZONE 'Africa/Nairobi'
  AND t.end_time IS NOT NULL
GROUP BY t.imei, d.driver_name, d.vehicle_number, working_day
ORDER BY utilisation_pct DESC;

Benchmark targets:

Rate Interpretation Action
> 70% Excellent — asset working hard Monitor driver fatigue
5570% Good — healthy operational range No action required
4055% Below average — investigate stops Review route planning
< 40% Poor — asset underutilised Reassign or investigate
0% Vehicle did not move today Verify not broken down or abandoned

Note: The denominator (10 hours) should be adjusted to match your actual contractual shift length.


2.2 Daily Revenue-Generating Hours vs Fuel-Wasting Idle

Engine-on-but-stationary time is direct cost with no output. At Kenya diesel prices (~KES 180/litre) and typical 8 L/100 km consumption, a stationary diesel engine burns approximately 0.8 L/hour at idle.

SELECT
  imei,
  SUM(total_drive_hours)                              AS drive_hours,
  SUM(total_idle_hours)                               AS idle_hours,
  ROUND(
    SUM(total_idle_hours) * 0.8 * 180, 0
  )                                                   AS idle_fuel_cost_kes,
  ROUND(
    SUM(total_idle_hours) /
    NULLIF(SUM(total_drive_hours + total_idle_hours), 0) * 100, 1
  )                                                   AS idle_pct
FROM dwh_gold.fact_daily_fleet_metrics
WHERE day >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY imei
ORDER BY idle_fuel_cost_kes DESC;

Fleet-wide idle cost this month:

SELECT
  ROUND(SUM(total_idle_hours), 1)            AS fleet_idle_hours,
  ROUND(SUM(total_idle_hours) * 0.8 * 180)   AS estimated_wasted_kes
FROM dwh_gold.fact_daily_fleet_metrics
WHERE day >= DATE_TRUNC('month', CURRENT_DATE);

2.3 Vehicles That Did Not Move Today

[DASHBOARD] [ALERT] — alert if a vehicle has not moved for ≥ 2 consecutive working days.

SELECT
  d.imei,
  d.vehicle_name,
  d.vehicle_number,
  d.driver_name,
  lp.gps_time AT TIME ZONE 'Africa/Nairobi' AS last_seen,
  lp.speed
FROM tracksolid.devices d
LEFT JOIN tracksolid.live_positions lp ON lp.imei = d.imei
LEFT JOIN tracksolid.trips t
  ON  t.imei = d.imei
  AND DATE(t.start_time AT TIME ZONE 'Africa/Nairobi') = CURRENT_DATE
WHERE d.enabled_flag = 1
  AND t.imei IS NULL
ORDER BY d.imei;

2.4 Cost-per-Ticket and Cost-per-Km

[MONTHLY] — the single most actionable finance metric: what does one completed field-service job actually cost in fuel? Pairs the trip table with the ticketing system (replace ops.tickets with the actual source — Zoho Desk, Freshdesk, or the Fireside job-management export).

Requires devices.fuel_100km (see §8 Step 2). Diesel price is parameterised so this query works across Nairobi / Mombasa / Kampala without editing.

WITH fuel_rates AS (
  SELECT
    'NBO'::TEXT AS city, 180.0::NUMERIC AS price_per_litre  -- Nairobi diesel KES
  UNION ALL SELECT 'MBA', 175.0
  UNION ALL SELECT 'KLA', 5200.0                            -- Kampala UGX → convert in BI layer
),
daily_cost AS (
  SELECT
    t.imei,
    DATE(t.start_time AT TIME ZONE 'Africa/Nairobi') AS working_day,
    SUM(t.distance_km)                               AS km,
    SUM(t.distance_km) * (d.fuel_100km / 100.0)      AS litres,
    SUM(t.distance_km) * (d.fuel_100km / 100.0) * f.price_per_litre AS fuel_cost
  FROM tracksolid.trips t
  JOIN tracksolid.devices d ON d.imei = t.imei
  LEFT JOIN fuel_rates f    ON f.city = d.assigned_city
  WHERE t.start_time >= DATE_TRUNC('month', CURRENT_DATE)
    AND t.end_time IS NOT NULL
  GROUP BY t.imei, working_day, d.fuel_100km, f.price_per_litre
),
tickets AS (
  SELECT
    assigned_imei           AS imei,
    DATE(closed_at AT TIME ZONE 'Africa/Nairobi') AS working_day,
    COUNT(*) FILTER (WHERE status = 'resolved')   AS tickets_closed
  FROM ops.tickets
  WHERE closed_at >= DATE_TRUNC('month', CURRENT_DATE)
  GROUP BY assigned_imei, working_day
)
SELECT
  dc.imei,
  d.driver_name,
  d.vehicle_number,
  SUM(dc.km)                                                AS km_month,
  ROUND(SUM(dc.fuel_cost), 0)                               AS fuel_cost_kes_month,
  COALESCE(SUM(tk.tickets_closed), 0)                       AS tickets_closed,
  ROUND(SUM(dc.fuel_cost) / NULLIF(SUM(tk.tickets_closed), 0), 0) AS cost_per_ticket_kes,
  ROUND(SUM(dc.fuel_cost) / NULLIF(SUM(dc.km), 0), 2)       AS cost_per_km_kes
FROM daily_cost dc
JOIN tracksolid.devices d ON d.imei = dc.imei
LEFT JOIN tickets tk
  ON  tk.imei = dc.imei
  AND tk.working_day = dc.working_day
GROUP BY dc.imei, d.driver_name, d.vehicle_number
ORDER BY cost_per_ticket_kes DESC NULLS LAST;

Interpretation bands — driver-level cost-per-ticket (van fleet, Nairobi baseline):

KES / ticket Signal Typical cause
< 400 Efficient Dense route, minimal backtracking
400 900 Normal Mixed urban route
900 1500 Review Scattered geography or low ticket throughput
> 1500 Investigate Idle time, off-route driving, or single-ticket days

Dependency: requires ticket data joined on IMEI or driver ID. If only driver-level data is available, swap assigned_imei for a driver→imei lookup.


3. Driver Behaviour

3.1 Speeding

Counts position fixes where speed exceeded threshold, normalised per 100 km to avoid penalising drivers who simply drive more.

WITH driver_speed AS (
  SELECT
    ph.imei,
    COUNT(*) FILTER (WHERE ph.speed > 80)   AS fixes_over_80,
    COUNT(*) FILTER (WHERE ph.speed > 100)  AS fixes_over_100,
    COUNT(*) FILTER (WHERE ph.speed > 120)  AS fixes_over_120,
    COUNT(*)                                AS total_fixes
  FROM tracksolid.position_history ph
  WHERE ph.gps_time > NOW() - INTERVAL '7 days'
    AND ph.gps_time < NOW()
    AND ph.speed IS NOT NULL
  GROUP BY ph.imei
),
driver_km AS (
  SELECT imei, SUM(distance_km) AS total_km
  FROM tracksolid.trips
  WHERE start_time > NOW() - INTERVAL '7 days'
  GROUP BY imei
)
SELECT
  ds.imei,
  d.driver_name,
  d.vehicle_number,
  ROUND(dk.total_km, 1)                                     AS km_driven,
  ds.fixes_over_80                                          AS events_80_kmh,
  ds.fixes_over_100                                         AS events_100_kmh,
  ds.fixes_over_120                                         AS events_120_kmh,
  ROUND(ds.fixes_over_80  / NULLIF(dk.total_km, 0) * 100, 2) AS rate_per_100km
FROM driver_speed ds
JOIN driver_km dk ON dk.imei = ds.imei
JOIN tracksolid.devices d ON d.imei = ds.imei
ORDER BY rate_per_100km DESC;

Severity banding:

Speed Classification Response
80100 km/h Warning Log, notify supervisor if persistent
100120 km/h Serious Formal driver warning
> 120 km/h Critical Immediate management escalation

3.2 Harsh Driving — Hard Braking and Sudden Acceleration

Requires track_list data (POLL-01). Identifies speed changes greater than 30 km/h within a 60-second window — the signature of hard braking or sudden acceleration. Both events cause tyre wear, brake wear, fuel spikes, and increase accident probability.

WITH ordered AS (
  SELECT
    imei,
    gps_time,
    speed,
    LAG(speed)    OVER (PARTITION BY imei ORDER BY gps_time) AS prev_speed,
    LAG(gps_time) OVER (PARTITION BY imei ORDER BY gps_time) AS prev_time
  FROM tracksolid.position_history
  WHERE source   = 'track_list'
    AND gps_time > NOW() - INTERVAL '7 days'
    AND gps_time < NOW()
)
SELECT
  imei,
  gps_time AT TIME ZONE 'Africa/Nairobi'        AS event_time,
  prev_speed                                    AS speed_before,
  speed                                         AS speed_after,
  ABS(speed - prev_speed)                       AS delta_kmh,
  CASE
    WHEN speed > prev_speed THEN 'hard_acceleration'
    ELSE 'hard_braking'
  END                                           AS event_type
FROM ordered
WHERE ABS(speed - prev_speed) > 30
  AND EXTRACT(EPOCH FROM (gps_time - prev_time)) BETWEEN 5 AND 60
ORDER BY event_time DESC;

Driver aggression index — normalised harsh events per 100 km:

WITH harsh AS (
  SELECT
    imei,
    COUNT(*) AS harsh_events
  FROM (
    SELECT
      imei,
      speed,
      LAG(speed) OVER (PARTITION BY imei ORDER BY gps_time) AS prev_speed,
      LAG(gps_time) OVER (PARTITION BY imei ORDER BY gps_time) AS prev_time,
      gps_time
    FROM tracksolid.position_history
    WHERE source = 'track_list'
      AND gps_time > NOW() - INTERVAL '30 days'
  ) sub
  WHERE ABS(speed - prev_speed) > 30
    AND EXTRACT(EPOCH FROM (gps_time - prev_time)) BETWEEN 5 AND 60
  GROUP BY imei
),
km AS (
  SELECT imei, SUM(distance_km) AS total_km
  FROM tracksolid.trips
  WHERE start_time > NOW() - INTERVAL '30 days'
  GROUP BY imei
)
SELECT
  h.imei,
  d.driver_name,
  d.vehicle_number,
  h.harsh_events,
  ROUND(k.total_km, 0)                                     AS km_driven,
  ROUND(h.harsh_events / NULLIF(k.total_km, 0) * 100, 2)  AS aggression_index
FROM harsh h
JOIN km k ON k.imei = h.imei
JOIN tracksolid.devices d ON d.imei = h.imei
ORDER BY aggression_index DESC;

An aggression index below 0.5 is good. Above 2.0 warrants a driver coaching conversation. Above 5.0 is a safety concern.


3.3 Tardiness — Late Starts and Early Knock-Off

Late starts (first ignition-on after scheduled shift start):

SELECT
  f.vehicle_key                                         AS imei,
  d.driver_name,
  d.vehicle_number,
  f.day,
  f.day_start_time,
  CASE
    WHEN f.day_start_time > '07:45:00' THEN
      EXTRACT(EPOCH FROM (f.day_start_time - '07:30:00'::TIME)) / 60
    ELSE 0
  END::INT                                              AS minutes_late
FROM dwh_gold.fact_daily_fleet_metrics f
JOIN tracksolid.devices d ON d.imei = f.vehicle_key
WHERE f.day >= CURRENT_DATE - INTERVAL '30 days'
  AND f.day_start_time > '07:45:00'
ORDER BY minutes_late DESC;

Early knock-off (last trip ended before scheduled shift end):

SELECT
  f.vehicle_key                                         AS imei,
  d.driver_name,
  f.day,
  f.day_end_time,
  CASE
    WHEN f.day_end_time < '17:00:00' THEN
      EXTRACT(EPOCH FROM ('17:00:00'::TIME - f.day_end_time)) / 60
    ELSE 0
  END::INT                                              AS minutes_early
FROM dwh_gold.fact_daily_fleet_metrics f
JOIN tracksolid.devices d ON d.imei = f.vehicle_key
WHERE f.day >= CURRENT_DATE - INTERVAL '30 days'
  AND f.day_end_time < '17:00:00'
  AND f.total_trips > 0
ORDER BY minutes_early DESC;

Adjust '07:30:00' and '17:00:00' to match your actual contracted shift times.

Chronic late starters — monthly pattern:

SELECT
  f.vehicle_key                   AS imei,
  d.driver_name,
  COUNT(*)                        AS late_days,
  ROUND(AVG(
    EXTRACT(EPOCH FROM (f.day_start_time - '07:30:00'::TIME)) / 60
  ), 0)                           AS avg_minutes_late
FROM dwh_gold.fact_daily_fleet_metrics f
JOIN tracksolid.devices d ON d.imei = f.vehicle_key
WHERE f.day >= DATE_TRUNC('month', CURRENT_DATE)
  AND f.day_start_time > '07:45:00'
GROUP BY f.vehicle_key, d.driver_name
HAVING COUNT(*) >= 3
ORDER BY late_days DESC, avg_minutes_late DESC;

3.4 After-Hours Movement

Any trip starting or ending outside contracted hours. Flags unauthorised vehicle use, night deliveries not on schedule, or potential vehicle theft.

SELECT
  t.imei,
  d.driver_name,
  d.vehicle_number,
  t.start_time AT TIME ZONE 'Africa/Nairobi'  AS departure_nairobi,
  t.end_time   AT TIME ZONE 'Africa/Nairobi'  AS arrival_nairobi,
  ROUND(t.distance_km::numeric, 1)            AS distance_km,
  CASE
    WHEN EXTRACT(HOUR FROM t.start_time AT TIME ZONE 'Africa/Nairobi') < 6  THEN 'pre-dawn departure'
    WHEN EXTRACT(HOUR FROM t.start_time AT TIME ZONE 'Africa/Nairobi') >= 20 THEN 'night departure'
    ELSE 'after-hours return'
  END                                         AS flag
FROM tracksolid.trips t
JOIN tracksolid.devices d ON d.imei = t.imei
WHERE (
  EXTRACT(HOUR FROM t.start_time AT TIME ZONE 'Africa/Nairobi') < 6
  OR EXTRACT(HOUR FROM t.start_time AT TIME ZONE 'Africa/Nairobi') >= 20
  OR EXTRACT(HOUR FROM t.end_time   AT TIME ZONE 'Africa/Nairobi') >= 21
)
  AND t.start_time > NOW() - INTERVAL '30 days'
ORDER BY t.start_time DESC;

3.5 Km Covered per Driver per Day

SELECT
  t.imei,
  d.driver_name,
  d.vehicle_number,
  DATE(t.start_time AT TIME ZONE 'Africa/Nairobi')   AS working_day,
  ROUND(SUM(t.distance_km)::numeric, 1)              AS km_driven,
  COUNT(*)                                           AS trips,
  ROUND(SUM(t.driving_time_s) / 3600.0, 2)          AS drive_hours,
  ROUND(SUM(t.idle_time_s)    / 3600.0, 2)          AS idle_hours,
  MAX(t.max_speed_kmh)                               AS peak_speed_kmh,
  MIN(t.start_time AT TIME ZONE 'Africa/Nairobi')::TIME AS first_departure,
  MAX(t.end_time   AT TIME ZONE 'Africa/Nairobi')::TIME AS last_return
FROM tracksolid.trips t
JOIN tracksolid.devices d ON d.imei = t.imei
WHERE t.start_time >= CURRENT_DATE AT TIME ZONE 'Africa/Nairobi'
  AND t.end_time IS NOT NULL
GROUP BY t.imei, d.driver_name, d.vehicle_number, working_day
ORDER BY km_driven DESC;

Expected daily km benchmarks by vehicle type:

Vehicle Type Expected Daily km Flag: Below Flag: Above
Urban delivery van 80150 km < 40 km > 300 km
Long-haul truck 300500 km < 150 km > 700 km
Field/supervisor vehicle 50120 km < 20 km > 250 km
Motorcycle courier 60120 km < 30 km > 200 km

A driver consistently covering 250 km/day in an urban van either has a legitimately large route or is running personal errands between jobs. Both scenarios need different responses.

Weekly km trend per driver:

SELECT
  t.imei,
  d.driver_name,
  DATE_TRUNC('week', t.start_time AT TIME ZONE 'Africa/Nairobi')  AS week_start,
  ROUND(SUM(t.distance_km)::numeric, 1)                           AS total_km,
  COUNT(DISTINCT DATE(t.start_time AT TIME ZONE 'Africa/Nairobi')) AS days_active,
  ROUND(SUM(t.distance_km)::numeric /
    NULLIF(COUNT(DISTINCT DATE(t.start_time AT TIME ZONE 'Africa/Nairobi')), 0), 1
  )                                                               AS avg_km_per_day
FROM tracksolid.trips t
JOIN tracksolid.devices d ON d.imei = t.imei
WHERE t.start_time > NOW() - INTERVAL '90 days'
  AND t.end_time IS NOT NULL
GROUP BY t.imei, d.driver_name, week_start
ORDER BY t.imei, week_start;

3.6 Alarm-While-Parked — Tamper and Theft Signal

[ALERT] — an alarm event on a vehicle that has been stationary for > 10 minutes is qualitatively different from an alarm mid-drive. Stationary alarms are the strongest signal for tamper, battery disconnect, unauthorised ignition, or geofence breach by a parked vehicle being loaded. Fires highest-priority page.

SELECT
  a.imei,
  d.driver_name,
  d.vehicle_number,
  a.alarm_name,
  a.alarm_time AT TIME ZONE 'Africa/Nairobi' AS event_time,
  ROUND(
    EXTRACT(EPOCH FROM (a.alarm_time - p.end_time)) / 60.0, 1
  )                                          AS minutes_parked_before_alarm,
  p.address                                  AS park_location,
  a.lat, a.lng
FROM tracksolid.alarms a
JOIN tracksolid.devices d ON d.imei = a.imei
JOIN LATERAL (
  SELECT end_time, address
  FROM tracksolid.parking_events p
  WHERE p.imei = a.imei
    AND p.start_time <= a.alarm_time
    AND (p.end_time IS NULL OR p.end_time >= a.alarm_time)
  ORDER BY p.start_time DESC
  LIMIT 1
) p ON TRUE
WHERE a.alarm_time > NOW() - INTERVAL '24 hours'
  AND a.alarm_type IN ('vibration', 'power_cut', 'geofence_enter', 'geofence_exit', 'unauthorized_ignition')
ORDER BY a.alarm_time DESC;

Page rule: any row where alarm_type IN ('power_cut', 'unauthorized_ignition') AND vehicle has been parked > 10 min pages the on-call operations lead immediately. Other stationary alarms ticket to the fleet manager for next-day review.


3.7 Geographic Drift — Vehicles Operating Outside Assigned City

[MONTHLY] [ALERT] — detects vehicles running outside their assigned operating territory. Protects against unauthorised inter-city trips, fuel tourism, and route fraud.

Prerequisite — add an assigned_city column to the devices table:

ALTER TABLE tracksolid.devices ADD COLUMN IF NOT EXISTS assigned_city TEXT;
-- Example back-fill:
UPDATE tracksolid.devices SET assigned_city = 'NBO' WHERE imei IN (...);
UPDATE tracksolid.devices SET assigned_city = 'MBA' WHERE imei IN (...);
UPDATE tracksolid.devices SET assigned_city = 'KLA' WHERE imei IN (...);

City bounding boxes (approximate; widen as needed for suburban coverage):

City Code min lat max lat min lng max lng
Nairobi metro NBO -1.45 -1.15 36.65 37.05
Mombasa metro MBA -4.15 -3.90 39.55 39.80
Kampala metro KLA 0.20 0.45 32.50 32.75
WITH city_box AS (
  SELECT * FROM (VALUES
    ('NBO', -1.45, -1.15, 36.65, 37.05),
    ('MBA', -4.15, -3.90, 39.55, 39.80),
    ('KLA',  0.20,  0.45, 32.50, 32.75)
  ) AS c(code, min_lat, max_lat, min_lng, max_lng)
),
out_of_zone AS (
  SELECT
    ph.imei,
    d.assigned_city,
    DATE(ph.gps_time AT TIME ZONE 'Africa/Nairobi') AS day,
    COUNT(*) AS fixes_outside_zone
  FROM tracksolid.position_history ph
  JOIN tracksolid.devices d ON d.imei = ph.imei
  JOIN city_box c           ON c.code = d.assigned_city
  WHERE ph.gps_time > NOW() - INTERVAL '30 days'
    AND (
      ph.lat < c.min_lat OR ph.lat > c.max_lat
      OR ph.lng < c.min_lng OR ph.lng > c.max_lng
    )
  GROUP BY ph.imei, d.assigned_city, day
)
SELECT
  o.imei,
  d.driver_name,
  d.vehicle_number,
  o.assigned_city,
  o.day,
  o.fixes_outside_zone
FROM out_of_zone o
JOIN tracksolid.devices d ON d.imei = o.imei
WHERE o.fixes_outside_zone > 20    -- ~10 minutes of continuous out-of-zone driving
ORDER BY o.day DESC, o.fixes_outside_zone DESC;

Alert threshold: > 50 fixes outside zone in a single day = escalate. Expected legitimate cases: cross-city service trips, driver taking vehicle home across a city boundary (policy decision).


3.8 Odometer Divergence — Tracker vs Physical Reading

[MONTHLY] — compares cumulative distance recorded by the tracker against the vehicle's physical odometer (captured at service or fuel card events). Divergence > 10% suggests sensor drift, GPS gaps, or unauthorised driving with the tracker disabled.

WITH tracker_km AS (
  SELECT
    imei,
    SUM(distance_km) AS trips_km_30d
  FROM tracksolid.trips
  WHERE start_time > NOW() - INTERVAL '30 days'
    AND end_time IS NOT NULL
  GROUP BY imei
),
physical_readings AS (
  -- Replace with actual odometer log source (service records, fuel card, manual entry)
  SELECT
    imei,
    reading_km,
    reading_date,
    LAG(reading_km)   OVER (PARTITION BY imei ORDER BY reading_date) AS prev_reading_km,
    LAG(reading_date) OVER (PARTITION BY imei ORDER BY reading_date) AS prev_reading_date
  FROM ops.odometer_readings
  WHERE reading_date > NOW() - INTERVAL '60 days'
),
physical_delta AS (
  SELECT
    imei,
    reading_km - prev_reading_km                             AS physical_km,
    EXTRACT(DAY FROM (reading_date - prev_reading_date))     AS period_days
  FROM physical_readings
  WHERE prev_reading_km IS NOT NULL
    AND period_days BETWEEN 20 AND 40
)
SELECT
  p.imei,
  d.driver_name,
  d.vehicle_number,
  ROUND(p.physical_km, 0)                                      AS odometer_km_period,
  ROUND(tk.trips_km_30d, 0)                                    AS tracker_km_30d,
  ROUND(
    (p.physical_km - tk.trips_km_30d) / NULLIF(p.physical_km, 0) * 100,
    1
  )                                                             AS divergence_pct
FROM physical_delta p
JOIN tracker_km tk ON tk.imei = p.imei
JOIN tracksolid.devices d ON d.imei = p.imei
WHERE ABS(
  (p.physical_km - tk.trips_km_30d) / NULLIF(p.physical_km, 0)
) > 0.10
ORDER BY ABS(p.physical_km - tk.trips_km_30d) DESC;

Interpretation:

Divergence Likely cause Action
Tracker < physical (> 10%) GPS outage, tracker powered off, engine driven with no fix Audit device uptime; inspect for tamper
Tracker > physical (> 10%) Duplicate trip records, distance-correction bug Run migration check; review trips.distance_km distribution
Divergence growing month-over-month Sensor drift, antenna degradation Replace device or antenna

4. Real-Time Dispatch & Field-Service SLAs

4.1 Find the 5 Closest Available Vehicles

Given a new job at a known location, this query returns the nearest active vehicles with a fresh GPS fix. Runs in milliseconds against the live_positions table with the PostGIS spatial index.

-- Replace :job_lat and :job_lng with the job coordinates
SELECT
  lp.imei,
  d.vehicle_name,
  d.vehicle_number,
  d.driver_name,
  d.driver_phone,
  d.vehicle_category,
  lp.acc_status,
  lp.speed,
  ROUND(
    ST_Distance(
      lp.geom::geography,
      ST_SetSRID(ST_MakePoint(:job_lng, :job_lat), 4326)::geography
    ) / 1000.0, 2
  )                                              AS distance_km,
  ROUND(
    ST_Distance(
      lp.geom::geography,
      ST_SetSRID(ST_MakePoint(:job_lng, :job_lat), 4326)::geography
    ) / 1000.0 / 30.0 * 60, 0
  )                                              AS eta_minutes_urban,
  lp.gps_time AT TIME ZONE 'Africa/Nairobi'     AS last_seen
FROM tracksolid.live_positions lp
JOIN tracksolid.devices d ON d.imei = lp.imei
WHERE lp.acc_status = '1'
  AND lp.speed < 5
  AND lp.gps_time > NOW() - INTERVAL '5 minutes'
ORDER BY distance_km ASC
LIMIT 5;

ETA speed assumptions — adjust the divisor to match the route type:

Route type Speed (km/h) Formula
Nairobi CBD 20 km/h / 20.0 * 60
Nairobi urban 30 km/h / 30.0 * 60
Peri-urban 50 km/h / 50.0 * 60
Highway 80 km/h / 80.0 * 60

4.2 Dispatch Logic for n8n or API Integration

The recommended workflow when a new job/ticket arrives:

  1. Trigger: New job created (webhook from job management system or n8n)
  2. Force-refresh positions: Call get_device_locations() for the top 10 candidate IMEIs to get sub-second fresh positions before committing
  3. Run dispatch query above with job coordinates
  4. Filter by vehicle type if the job requires specific capacity (AND d.vehicle_category = 'van')
  5. Exclude vehicles with open alarms: AND NOT EXISTS (SELECT 1 FROM tracksolid.alarms a WHERE a.imei = lp.imei AND a.alarm_time > NOW() - INTERVAL '1 hour')
  6. Present top 3 candidates to dispatcher (or auto-assign #1 if fully automated)
  7. Log dispatch decision to a separate dispatch_log table for SLA tracking

4.3 All Active Vehicles Map — Live Fleet View

Returns all vehicles with a position fix in the last 10 minutes, suitable for a Grafana Geomap panel with auto-refresh at 30 seconds.

SELECT
  lp.imei,
  COALESCE(d.vehicle_name, d.vehicle_number, lp.imei) AS label,
  d.driver_name,
  lp.lat,
  lp.lng,
  lp.speed,
  lp.acc_status,
  CASE
    WHEN lp.speed > 5            THEN 'moving'
    WHEN lp.acc_status = '1'     THEN 'idle'
    ELSE                              'parked'
  END                                                  AS vehicle_state,
  lp.gps_time AT TIME ZONE 'Africa/Nairobi'           AS last_seen
FROM tracksolid.live_positions lp
JOIN tracksolid.devices d ON d.imei = lp.imei
WHERE lp.gps_time > NOW() - INTERVAL '10 minutes'
ORDER BY lp.imei;

4.4 Dispatch Log Schema

A persistent record of every dispatch decision, needed for every SLA and cost metric that follows. Create once:

CREATE TABLE IF NOT EXISTS tracksolid.dispatch_log (
  dispatch_id        BIGSERIAL PRIMARY KEY,
  ticket_id          TEXT        NOT NULL,
  imei               TEXT        NOT NULL REFERENCES tracksolid.devices(imei),
  driver_name        TEXT,
  job_lat            DOUBLE PRECISION NOT NULL,
  job_lng            DOUBLE PRECISION NOT NULL,
  job_geom           GEOMETRY(POINT, 4326),
  assigned_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  first_movement_at  TIMESTAMPTZ,            -- populated when vehicle leaves depot
  on_site_at         TIMESTAMPTZ,            -- vehicle enters 150 m radius of job
  resolved_at        TIMESTAMPTZ,            -- ticket closed in ops system
  cancelled_at       TIMESTAMPTZ,
  distance_km        NUMERIC(8, 2),
  created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_dispatch_log_ticket ON tracksolid.dispatch_log(ticket_id);
CREATE INDEX IF NOT EXISTS idx_dispatch_log_imei_assigned
  ON tracksolid.dispatch_log(imei, assigned_at DESC);
CREATE INDEX IF NOT EXISTS idx_dispatch_log_assigned_at
  ON tracksolid.dispatch_log(assigned_at DESC);

Population plan: n8n or the ops integration layer writes one row per dispatch at assignment. A nightly job back-fills first_movement_at / on_site_at by joining trips and live_positions against job_geom.


4.5 Field-Service SLA Metrics

[DASHBOARD] [ALERT] [MONTHLY] — the operational heartbeat of a field-services business. Four timings per ticket, each a discrete SLA with its own band.

ticket_created ─► assigned ─► first_movement ─► on_site ─► resolved
                  (dispatch    (depot depart     (vehicle   (job done)
                   latency)     latency)          arrived)

(a) Dispatch latency — from ticket creation to vehicle assignment:

SELECT
  t.ticket_id,
  EXTRACT(EPOCH FROM (dl.assigned_at - t.created_at)) / 60 AS dispatch_latency_min
FROM ops.tickets t
JOIN tracksolid.dispatch_log dl ON dl.ticket_id = t.ticket_id
WHERE t.created_at > NOW() - INTERVAL '7 days';

(b) Dispatch-to-depart — from assignment to vehicle actually leaving the depot:

SELECT
  dl.ticket_id,
  dl.imei,
  d.driver_name,
  EXTRACT(EPOCH FROM (dl.first_movement_at - dl.assigned_at)) / 60 AS depart_delay_min
FROM tracksolid.dispatch_log dl
JOIN tracksolid.devices d ON d.imei = dl.imei
WHERE dl.assigned_at > NOW() - INTERVAL '7 days'
  AND dl.first_movement_at IS NOT NULL
ORDER BY depart_delay_min DESC;

(c) Time-to-site — from assignment to arrival at the job location (vehicle within 150 m):

SELECT
  dl.ticket_id,
  dl.imei,
  ROUND(dl.distance_km, 1)                                          AS distance_km,
  EXTRACT(EPOCH FROM (dl.on_site_at - dl.assigned_at)) / 60         AS time_to_site_min,
  ROUND(
    dl.distance_km /
    NULLIF(EXTRACT(EPOCH FROM (dl.on_site_at - dl.assigned_at)) / 3600, 0),
    1
  )                                                                 AS avg_transit_kmh
FROM tracksolid.dispatch_log dl
WHERE dl.assigned_at > NOW() - INTERVAL '7 days'
  AND dl.on_site_at IS NOT NULL;

(d) On-site to resolution — wrench time at the job:

SELECT
  dl.ticket_id,
  dl.imei,
  EXTRACT(EPOCH FROM (dl.resolved_at - dl.on_site_at)) / 60 AS wrench_time_min
FROM tracksolid.dispatch_log dl
WHERE dl.on_site_at IS NOT NULL
  AND dl.resolved_at IS NOT NULL
  AND dl.assigned_at > NOW() - INTERVAL '30 days';

Monthly SLA attainment per driver:

SELECT
  dl.imei,
  d.driver_name,
  COUNT(*)                                                      AS tickets,
  ROUND(AVG(
    EXTRACT(EPOCH FROM (dl.first_movement_at - dl.assigned_at))
  ) / 60, 1)                                                     AS avg_depart_min,
  ROUND(AVG(
    EXTRACT(EPOCH FROM (dl.on_site_at - dl.assigned_at))
  ) / 60, 1)                                                     AS avg_time_to_site_min,
  ROUND(AVG(
    EXTRACT(EPOCH FROM (dl.resolved_at - dl.on_site_at))
  ) / 60, 1)                                                     AS avg_wrench_min,
  ROUND(
    100.0 * COUNT(*) FILTER (
      WHERE EXTRACT(EPOCH FROM (dl.on_site_at - dl.assigned_at)) / 60 <= 90
    ) / NULLIF(COUNT(*), 0),
    1
  )                                                              AS pct_on_site_within_90min
FROM tracksolid.dispatch_log dl
JOIN tracksolid.devices d ON d.imei = dl.imei
WHERE dl.assigned_at >= DATE_TRUNC('month', CURRENT_DATE)
  AND dl.on_site_at IS NOT NULL
GROUP BY dl.imei, d.driver_name
ORDER BY pct_on_site_within_90min DESC;

Target bands (baseline — recalibrate after 90 days of data):

SLA Green Amber Red
Dispatch latency (ops → driver) < 10 min 10 25 min > 25 min
Depart delay (assigned → moving) < 15 min 15 35 min > 35 min
Time-to-site (assigned → on-site) < 60 min 60 120 min > 120 min
Wrench time (on-site → resolved) < 90 min 90 180 min > 180 min
% on-site within 90 min (monthly) ≥ 85% 70 85% < 70%

5. Distance per Driver per Day

5.1 Today's Summary

SELECT
  t.imei,
  COALESCE(d.driver_name, 'Unassigned')               AS driver,
  COALESCE(d.vehicle_number, t.imei)                  AS vehicle,
  ROUND(SUM(t.distance_km)::numeric, 1)               AS km_today,
  COUNT(*)                                            AS trips_today,
  ROUND(SUM(t.driving_time_s) / 3600.0, 2)           AS drive_hours,
  ROUND(SUM(t.idle_time_s)    / 3600.0, 2)           AS idle_hours,
  MIN(t.start_time AT TIME ZONE 'Africa/Nairobi')::TIME AS first_departure,
  MAX(t.end_time   AT TIME ZONE 'Africa/Nairobi')::TIME AS last_return
FROM tracksolid.trips t
JOIN tracksolid.devices d ON d.imei = t.imei
WHERE t.start_time >= CURRENT_DATE AT TIME ZONE 'Africa/Nairobi'
  AND t.end_time IS NOT NULL
GROUP BY t.imei, d.driver_name, d.vehicle_number
ORDER BY km_today DESC;

5.2 30-Day Driver Performance Scorecard

Combines distance, behaviour, and punctuality into a single view per driver.

WITH km_summary AS (
  SELECT
    imei,
    COUNT(DISTINCT DATE(start_time AT TIME ZONE 'Africa/Nairobi')) AS days_active,
    ROUND(SUM(distance_km)::numeric, 1)                            AS total_km,
    ROUND(AVG(distance_km)::numeric, 1)                            AS avg_km_per_trip,
    MAX(max_speed_kmh)                                             AS peak_speed
  FROM tracksolid.trips
  WHERE start_time > NOW() - INTERVAL '30 days'
    AND end_time IS NOT NULL
  GROUP BY imei
),
alarm_summary AS (
  SELECT imei, COUNT(*) AS alarm_count
  FROM tracksolid.alarms
  WHERE alarm_time > NOW() - INTERVAL '30 days'
  GROUP BY imei
),
late_summary AS (
  SELECT vehicle_key AS imei, COUNT(*) AS late_days
  FROM dwh_gold.fact_daily_fleet_metrics
  WHERE day > CURRENT_DATE - 30
    AND day_start_time > '07:45:00'
  GROUP BY vehicle_key
)
SELECT
  k.imei,
  d.driver_name,
  d.vehicle_number,
  k.days_active,
  k.total_km,
  ROUND(k.total_km / NULLIF(k.days_active, 0), 1)  AS avg_km_per_day,
  k.peak_speed                                      AS peak_speed_kmh,
  COALESCE(a.alarm_count, 0)                        AS alarms_30d,
  COALESCE(l.late_days,   0)                        AS late_starts_30d
FROM km_summary k
JOIN tracksolid.devices d ON d.imei = k.imei
LEFT JOIN alarm_summary a ON a.imei = k.imei
LEFT JOIN late_summary  l ON l.imei = k.imei
ORDER BY k.total_km DESC;

6. Business Questions Now Answerable

Status key: Ready = answerable once new stack deployed | ⚙ Needs data = additional setup required | 🔴 Blocked = pending action before any data

Business Question Primary Data Source Status
Which vehicles are moving right now? live_positions Ready (deploy stack)
Who started work latest today? fact_daily_fleet_metrics.day_start_time Ready (deploy stack)
Who drove the most km this week? trips + devices Ready (deploy + CSV import)
Which vehicle spent the most time idling? trips.idle_time_s Ready (deploy stack)
How much fuel was wasted on idle today? trips.idle_time_s × rate ⚙ Needs fuel_100km set per vehicle
Which driver triggered the most alarms this month? alarms + devices Ready (deploy stack)
What is total fleet distance this month? trips Ready (deploy stack)
Which vehicles did not move at all today? trips LEFT JOIN devices Ready (deploy stack)
Who is nearest to a new job right now? live_positions + PostGIS Ready (deploy + CSV import for names)
Did any vehicle leave depot after hours? trips time filter Ready (deploy stack)
What is the speeding rate per driver per week? position_history speed filter Ready (needs 1 week data)
Which driver has the harshest driving style? position_history delta query Ready (needs 2 weeks track_list)
What does one field ticket cost in fuel? trips + ops.tickets + fuel_100km ⚙ Needs fuel_100km + ticket feed wired
Which vehicles are running outside assigned city? position_history + assigned_city ⚙ Needs assigned_city set (CSV import)
How many km to next service interval? devices.current_mileage + ops.service_log ⚙ Needs first service-log entry per vehicle
Are vehicles on approved routes? position_history + geofences ⚙ Pending geofence population (Step 4)
Is cold chain in temperature range? temperature_readings 🔴 Pending webhook registration (Step 1)
How much fuel is consumed per route? fuel_readings + trips 🔴 Pending fuel sensor webhook (Step 1)
Did any vehicle enter a restricted zone? alarms + geofences 🔴 Pending geofence setup (Step 4)
What percentage of the fleet was utilised today? trips + devices count Ready (deploy stack)
Alarm while parked — tamper / theft signal alarms + parking_events Ready (deploy stack)
Odometer divergence — tracker vs physical trips + ops.odometer_readings ⚙ Needs first odometer reading entry

7. Grafana Dashboard Blueprint

Panel 1 — Real-Time Fleet Map (auto-refresh: 30s)

  • Type: Geomap
  • Source: live_positions joined to devices
  • Colour coding:
    • Green = moving (speed > 5 km/h)
    • Amber = ignition on, stationary (acc_status = '1', speed ≤ 5)
    • Red = offline (last fix > 10 minutes ago)
  • Tooltip: driver name, vehicle number, speed, last seen

Panel 2 — Fleet Status Summary Row (auto-refresh: 1m)

Stat Query
Vehicles active now COUNT WHERE acc_status = '1' AND gps_time > NOW() - 5m
Vehicles moving COUNT WHERE speed > 5 AND gps_time > NOW() - 5m
Vehicles offline COUNT WHERE gps_time < NOW() - 10m
Open alarms COUNT FROM alarms WHERE alarm_time > NOW() - 1h
Fleet km today SUM(distance_km) WHERE start_time >= today

Panel 3 — Daily KPI Table (refresh: 1h)

Columns: Vehicle · Driver · Km Today · Trips · Drive Hours · Idle Hours · First Departure · Last Return · Alarms

Panel 4 — Driver Behaviour Leaderboard (refresh: 1h)

Ranked by aggression index (harsh events per 100 km), speeding events, and late starts. Colour-coded red/amber/green per threshold.

Panel 5 — Distance Trend (7-day bar chart)

  • X-axis: Date
  • Y-axis: Total km
  • Series: one bar per vehicle or fleet total with daily breakdown

Panel 6 — Idle Cost Tracker (refresh: 1h)

  • Running total of idle hours and estimated KES wasted this month
  • Trend line showing improvement or deterioration week-over-week

Panel 7 — Alarm Frequency (30-day time series)

  • Line chart: alarm count per day
  • Breakdown by alarm type (overspeed, geofence, harsh braking)

Panel 8 — Utilisation Heatmap (weekly)

  • Y-axis: Vehicle/driver
  • X-axis: Day of week
  • Colour: utilisation % (green > 60%, amber 4060%, red < 40%)

8. What Unlocks the Remaining 30%

The data foundation is in place. The following steps activate the remaining analytics capabilities, in priority order.

Step 0 — Deploy New Ingestion Stack (Current Blocker — do first)

All analytics in this document are blocked until the new stack is live. The legacy pipeline stopped on 6 Apr 2026 due to 401 token expiry errors. The refactored code fixes this permanently.

# On the Coolify server / inside the repo directory:

# 1. Pull latest code (includes all revisions through cebcf74)
git pull

# 2. Apply schema migrations (01 through 06 in order)
TS_DB=$(docker ps --filter "name=timescale_db" --format "{{.Names}}" | head -1)
for f in 01_tracksolid_base.sql 02_tracksolid_full_schema_rev.sql \
          03_webhook_schema_migration.sql 04_bug_fix_migration.sql \
          05_enhancement_migration.sql 06_business_analytics_migration.sql; do
  echo "Applying $f..."
  docker exec -i "$TS_DB" psql -U postgres -d tracksolid_db < "$f"
done

# 3. Rebuild and start new ingestion containers
docker compose up -d --build ingest_movement ingest_events webhook_receiver

# 4. Run initial device sync (populates tracksolid.devices from API)
docker exec -it ingest_movement python sync_driver_audit.py

# 5. Import driver/vehicle details from CSV
docker exec -it ingest_movement python import_drivers_csv.py          # dry-run
docker exec -it ingest_movement python import_drivers_csv.py --apply  # commit

# 6. Schedule nightly ETL
# Add to cron or n8n: SELECT dwh_gold.refresh_daily_metrics(CURRENT_DATE - 1);

Expected state after Step 0:

  • tracksolid.devices: 144+ rows with driver names, plates, departments, assigned_city
  • tracksolid.live_positions: positions refreshing every 60 seconds
  • tracksolid.trips / position_history: accumulating from first pipeline run
  • All analytics in this document begin producing results within 15 minutes of container start

Step 1 — Register Webhooks in Tracksolid Pro Account (Blocker)

Without registration, the following tables remain empty regardless of code:

Webhook Table Unlocks
/pushobd obd_readings Engine health, fuel level per fix, RPM
/pushoil fuel_readings Fuel theft detection, tank level trend
/pushtem temperature_readings Cold chain compliance alerts
/pushlbs lbs_readings Positions when GPS signal lost
/pushevent device_events Device powered off/on events (tamper detection)
/pushtripreport trips (push source) Real-time trip completion events

Action: Log into Tracksolid Pro → Account Settings → Webhook Configuration → add server URL for each endpoint.


Step 2 — Set fuel_100km per Vehicle Type

Currently null for all 63 devices. Once set, all fuel cost calculations activate automatically.

-- Example: set consumption rates by vehicle category
UPDATE tracksolid.devices SET fuel_100km = 8.5  WHERE vehicle_category = 'truck';
UPDATE tracksolid.devices SET fuel_100km = 7.0  WHERE vehicle_category = 'van';
UPDATE tracksolid.devices SET fuel_100km = 4.5  WHERE vehicle_category = 'motorcycle';
UPDATE tracksolid.devices SET fuel_100km = 9.0  WHERE vehicle_category = 'car';

Step 3 — Populate Vehicle Names and Driver Names

Automated: import_drivers_csv.py (committed to the repo) reads 20260414_FS__Logistics - final_fixed.csv (144 devices) and sets driver_name, vehicle_number, vehicle_models, cost_centre, assigned_city, sim, iccid, imsi in a single pass. Run after Step 0 device sync.

docker exec -it ingest_movement python import_drivers_csv.py --apply

CSV coverage after import: 140 vehicles with plates, 144 with driver names, 138 with SIM, assigned_city inferred (NBO=136, KLA=4). The 4 "Identification" spare units are skipped automatically.

Manual top-up for any device not in the CSV:

UPDATE tracksolid.devices
SET vehicle_name     = 'KBZ 123A',
    vehicle_number   = 'KBZ 123A',
    driver_name      = 'John Kamau',
    driver_phone     = '+254700000001',
    vehicle_category = 'van',
    assigned_city    = 'NBO'
WHERE imei = '352093080000001';

Step 4 — Define Geofences

Populate tracksolid.geofences with:

  • Depot boundaries — alert when vehicles leave outside working hours
  • Approved route corridors — alert when vehicles deviate from assigned routes
  • Restricted zones — alert when vehicles enter prohibited areas (e.g. competitor premises, residential zones during noise hours)
-- Example: circular depot geofence
INSERT INTO tracksolid.geofences (fence_id, fence_name, fence_type, geom, radius_m)
VALUES (
  'depot_nairobi_main',
  'Main Nairobi Depot',
  'circle',
  ST_SetSRID(ST_MakePoint(36.8219, -1.2921), 4326),
  200
);

Step 5 — Run Migrations and Deploy Updated Containers

See Step 0 above for the full deployment sequence. All six migrations (0106) must be applied in order before starting the new containers. Step 0 includes the complete command block.


9. Fleet Readiness Scorecard

[DASHBOARD] [MONTHLY] — a single composite number per vehicle, useful as a morning briefing and a monthly fleet health report. Runs against only the tables you already have — no new DDL required — so this is the fastest concrete win in this document.

Five sub-scores (0 100), averaged with weights:

Sub-score Weight Signal
Freshness 25% GPS fix age vs. a 5-minute target
Coverage 20% Active days in the last 7
Silence 15% Tracker went dark > 30 min during working hours
Alarm pressure 20% Alarms per 100 km over 30 days
Driver behaviour 20% Aggression + speeding index
WITH freshness AS (
  SELECT
    imei,
    EXTRACT(EPOCH FROM (NOW() - gps_time)) / 60 AS minutes_since_fix
  FROM tracksolid.live_positions
),
coverage AS (
  SELECT
    imei,
    COUNT(DISTINCT DATE(start_time AT TIME ZONE 'Africa/Nairobi')) AS days_active_7d
  FROM tracksolid.trips
  WHERE start_time > NOW() - INTERVAL '7 days'
  GROUP BY imei
),
silence AS (
  -- Gaps > 30 min during 07:00  19:00 EAT in the last 7 days
  SELECT
    imei,
    COUNT(*) AS silence_events_7d
  FROM (
    SELECT
      imei,
      gps_time,
      LAG(gps_time) OVER (PARTITION BY imei ORDER BY gps_time) AS prev_time
    FROM tracksolid.position_history
    WHERE gps_time > NOW() - INTERVAL '7 days'
      AND EXTRACT(HOUR FROM gps_time AT TIME ZONE 'Africa/Nairobi') BETWEEN 7 AND 19
  ) gaps
  WHERE EXTRACT(EPOCH FROM (gps_time - prev_time)) > 1800
  GROUP BY imei
),
alarm_pressure AS (
  SELECT
    a.imei,
    COUNT(*) AS alarms_30d,
    SUM(t.distance_km) AS km_30d
  FROM tracksolid.alarms a
  LEFT JOIN tracksolid.trips t
    ON t.imei = a.imei
    AND t.start_time > NOW() - INTERVAL '30 days'
  WHERE a.alarm_time > NOW() - INTERVAL '30 days'
  GROUP BY a.imei
),
behaviour AS (
  SELECT
    ph.imei,
    COUNT(*) FILTER (WHERE ph.speed > 100)                                   AS over_100,
    COUNT(*) FILTER (
      WHERE ABS(ph.speed - LAG(ph.speed) OVER (
        PARTITION BY ph.imei ORDER BY ph.gps_time
      )) > 30
    )                                                                         AS harsh_events
  FROM tracksolid.position_history ph
  WHERE ph.gps_time > NOW() - INTERVAL '30 days'
    AND ph.source = 'track_list'
  GROUP BY ph.imei
)
SELECT
  d.imei,
  d.driver_name,
  d.vehicle_number,
  ROUND(
    GREATEST(0, 100 - COALESCE(f.minutes_since_fix, 999) / 5.0 * 20)
  )                                                     AS freshness_score,
  ROUND(
    LEAST(100, COALESCE(c.days_active_7d, 0) / 5.0 * 100)
  )                                                     AS coverage_score,
  ROUND(
    GREATEST(0, 100 - COALESCE(s.silence_events_7d, 0) * 10)
  )                                                     AS silence_score,
  ROUND(
    GREATEST(0, 100 - COALESCE(
      ap.alarms_30d::NUMERIC / NULLIF(ap.km_30d, 0) * 100 * 20, 0
    ))
  )                                                     AS alarm_score,
  ROUND(
    GREATEST(0, 100 - COALESCE(b.over_100, 0) * 2 - COALESCE(b.harsh_events, 0) * 3)
  )                                                     AS behaviour_score,
  ROUND(
    GREATEST(0, 100 - COALESCE(f.minutes_since_fix, 999) / 5.0 * 20) * 0.25
    + LEAST(100, COALESCE(c.days_active_7d, 0) / 5.0 * 100) * 0.20
    + GREATEST(0, 100 - COALESCE(s.silence_events_7d, 0) * 10) * 0.15
    + GREATEST(0, 100 - COALESCE(
        ap.alarms_30d::NUMERIC / NULLIF(ap.km_30d, 0) * 100 * 20, 0
      )) * 0.20
    + GREATEST(0, 100 - COALESCE(b.over_100, 0) * 2 - COALESCE(b.harsh_events, 0) * 3) * 0.20
  )                                                     AS readiness_score
FROM tracksolid.devices d
LEFT JOIN freshness       f  ON f.imei  = d.imei
LEFT JOIN coverage        c  ON c.imei  = d.imei
LEFT JOIN silence         s  ON s.imei  = d.imei
LEFT JOIN alarm_pressure  ap ON ap.imei = d.imei
LEFT JOIN behaviour       b  ON b.imei  = d.imei
WHERE d.enabled_flag = 1
ORDER BY readiness_score ASC NULLS FIRST;

Interpretation:

Score Band Action
85 100 Green — ready Dispatch freely
60 84 Amber — monitor Review the lowest sub-score; fix trackers or coach driver
< 60 Red — unreliable Do not dispatch for priority jobs; service or replace
NULL Silent Vehicle never reported — investigate install / commission

The scorecard is also the cleanest Panel 2 replacement for the Grafana Fleet Status Summary.


10. Service-Interval Forecaster

[MONTHLY] [ALERT] — predicts when each vehicle will hit its next service interval (default 10,000 km), based on its trailing 30-day km rate. Lets ops pre-book workshop slots and avoid fleet-wide conflicts.

Requires a service-log table (create once):

CREATE TABLE IF NOT EXISTS ops.service_log (
  service_id        BIGSERIAL PRIMARY KEY,
  imei              TEXT        NOT NULL REFERENCES tracksolid.devices(imei),
  service_date      DATE        NOT NULL,
  odometer_km       INTEGER     NOT NULL,
  service_type      TEXT,                                         -- 'scheduled', 'repair', 'tyre', etc.
  cost_kes          INTEGER,
  notes             TEXT,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_service_log_imei_date
  ON ops.service_log(imei, service_date DESC);

Forecaster query — km until next service, projected service date:

WITH last_service AS (
  SELECT DISTINCT ON (imei)
    imei,
    service_date,
    odometer_km
  FROM ops.service_log
  WHERE service_type = 'scheduled'
  ORDER BY imei, service_date DESC
),
current_odometer AS (
  SELECT imei, current_mileage_km
  FROM tracksolid.devices
),
trailing_rate AS (
  SELECT
    imei,
    SUM(distance_km) / 30.0 AS km_per_day_30d
  FROM tracksolid.trips
  WHERE start_time > NOW() - INTERVAL '30 days'
    AND end_time IS NOT NULL
  GROUP BY imei
)
SELECT
  d.imei,
  d.driver_name,
  d.vehicle_number,
  ls.service_date                                          AS last_service_date,
  ls.odometer_km                                           AS last_service_odo,
  co.current_mileage_km                                    AS current_odo,
  (co.current_mileage_km - COALESCE(ls.odometer_km, 0))    AS km_since_service,
  GREATEST(
    0,
    10000 - (co.current_mileage_km - COALESCE(ls.odometer_km, 0))
  )                                                        AS km_to_next_service,
  ROUND(tr.km_per_day_30d, 1)                              AS km_per_day_30d,
  CASE
    WHEN tr.km_per_day_30d > 0 THEN
      CURRENT_DATE + (
        GREATEST(0, 10000 - (co.current_mileage_km - COALESCE(ls.odometer_km, 0)))
        / tr.km_per_day_30d
      )::INT
    ELSE NULL
  END                                                      AS projected_service_date
FROM tracksolid.devices d
LEFT JOIN last_service     ls ON ls.imei = d.imei
LEFT JOIN current_odometer co ON co.imei = d.imei
LEFT JOIN trailing_rate    tr ON tr.imei = d.imei
WHERE d.enabled_flag = 1
ORDER BY projected_service_date NULLS LAST;

Weekly booking view — how many vehicles need service in each of the next 8 weeks:

WITH forecast AS (
  -- (same CTE body as above; wrap as subquery or view `ops.vw_service_forecast`)
  SELECT imei, projected_service_date
  FROM ops.vw_service_forecast
  WHERE projected_service_date IS NOT NULL
)
SELECT
  DATE_TRUNC('week', projected_service_date)::DATE AS week_start,
  COUNT(*)                                          AS vehicles_due
FROM forecast
WHERE projected_service_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '8 weeks'
GROUP BY week_start
ORDER BY week_start;

Alert: any vehicle with km_to_next_service < (7 × km_per_day_30d) fires an amber ticket to the fleet manager. Any vehicle already overdue (km_to_next_service = 0) fires red.


Appendix A — Key Metric Thresholds Reference

Metric Green Amber Red
Fleet utilisation rate > 60% 4060% < 40%
Idle time as % of shift < 15% 1530% > 30%
Speeding events per 100 km < 0.5 0.52.0 > 2.0
Harsh driving index per 100 km < 0.5 0.52.0 > 2.0
Late starts per month (per driver) 01 24 ≥ 5
Days vehicle not used (per month) 02 35 > 5
GPS fix age (live_positions) < 2 min 210 min > 10 min
Alarm rate per vehicle per week 02 37 > 7
Readiness score (§9) ≥ 85 6084 < 60
Cost per ticket (van, NBO baseline) < 400 KES 400900 KES > 900 KES
On-site within 90 min (§4.5) ≥ 85% 7085% < 70%

Appendix B — Threshold Calibration Guide

Every threshold in Appendix A is a starting point. They are drawn from general field-services norms and three Fireside incident reviews — not from Fireside's own distribution. After ~30 days of clean data, recalibrate each one against your own observed p50 / p90 / p99.

The principle: green should catch ≥ 50% of vehicle-days, amber ≥ 30%, red ≤ 20%. If red is firing on more than 25% of the fleet every day, the alert is noise and will be ignored.

Calibration recipe — run monthly for each threshold-backed metric:

-- Example: utilisation % — recompute green/amber/red cut-points from the live distribution
WITH daily AS (
  SELECT
    t.imei,
    DATE(t.start_time AT TIME ZONE 'Africa/Nairobi') AS day,
    SUM(t.driving_time_s) / (10.0 * 3600) * 100 AS utilisation_pct
  FROM tracksolid.trips t
  WHERE t.start_time > NOW() - INTERVAL '30 days'
    AND t.end_time IS NOT NULL
  GROUP BY t.imei, day
)
SELECT
  PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY utilisation_pct) AS p25_red_cut,
  PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY utilisation_pct) AS p50_amber_cut,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY utilisation_pct) AS p75_green_cut,
  PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY utilisation_pct) AS p90_stretch
FROM daily;

Replace the Appendix A band edges with the returned percentiles. Repeat for idle %, speeding rate, harsh driving index, alarms per week. Document the recalibration date and the previous values in a changelog so band drift is visible.

City-cohort cuts. Nairobi traffic, Mombasa port runs, and Kampala cross-border routes produce genuinely different distributions. Group the recalibration by devices.assigned_city so you end up with three threshold sets, not one fleet-average compromise:

-- Apply the same percentile function grouped by city
SELECT
  d.assigned_city,
  PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY utilisation_pct) AS p50,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY utilisation_pct) AS p75
FROM daily
JOIN tracksolid.devices d ON d.imei = daily.imei
GROUP BY d.assigned_city;

Document updated: 2026-04-18 · Stack: TimescaleDB 2.15 + PostGIS + Tracksolid Pro Open Platform API Ingestion pipeline: ingest_movement_rev.py v2.2 · ingest_events_rev.py · webhook_receiver_rev.py DB state verified: 18 Apr 2026 — live data in tracksolid_2 (63 devices, pipeline stopped 6 Apr). New stack targets tracksolid schema — pending deployment.