tracksolid_timescale_grafan.../01_BusinessAnalytics.md
David Kiania 09b3860706 Add fleet business analytics document
Covers fleet utilisation, driver behaviour (speeding, harsh driving,
tardiness, after-hours movement), real-time dispatch queries, km per
driver per day, full business question inventory, Grafana dashboard
blueprint, and the 5-step roadmap to unlock remaining capabilities.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:57:36 +03:00

769 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Fireside Communications — Fleet Business Analytics
## Tracksolid Pro · Field Operations & Logistics Intelligence Assessment
### April 2026
---
## Table of Contents
1. [Data Foundation Summary](#1-data-foundation-summary)
2. [Fleet Utilisation](#2-fleet-utilisation)
3. [Driver Behaviour](#3-driver-behaviour)
4. [Real-Time Dispatch — Nearest Vehicle to Job](#4-real-time-dispatch--nearest-vehicle-to-job)
5. [Distance per Driver per Day](#5-distance-per-driver-per-day)
6. [Business Questions Now Answerable](#6-business-questions-now-answerable)
7. [Grafana Dashboard Blueprint](#7-grafana-dashboard-blueprint)
8. [What Unlocks the Remaining 30%](#8-what-unlocks-the-remaining-30)
---
## 1. Data Foundation Summary
The ingestion stack currently populates the following data sources, each feeding the analytics layer:
| 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** increased significantly with the addition of `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:
```sql
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.
```sql
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:**
```sql
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
```sql
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;
```
---
## 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.
```sql
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.
```sql
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:
```sql
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):
```sql
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):
```sql
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:**
```sql
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.
```sql
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
```sql
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:**
```sql
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;
```
---
## 4. Real-Time Dispatch — Nearest Vehicle to Job
### 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.
```sql
-- 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.
```sql
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;
```
---
## 5. Distance per Driver per Day
### 5.1 Today's Summary
```sql
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.
```sql
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
| Business Question | Primary Data Source | Confidence |
|---|---|---|
| Which vehicles are moving right now? | `live_positions` | High |
| Who started work latest today? | `fact_daily_fleet_metrics.day_start_time` | High |
| Who drove the most km this week? | `trips` + `devices` | High |
| Which vehicle spent the most time idling? | `trips.idle_time_s` | High |
| How much fuel was wasted on idle today? | `trips.idle_time_s` × est. rate | Medium (needs `fuel_100km` set) |
| Which driver triggered the most alarms this month? | `alarms` + `devices` | High |
| What is total fleet distance this month? | `trips` | High |
| Which vehicles did not move at all today? | `trips` LEFT JOIN `devices` | High |
| Who is nearest to a new job right now? | `live_positions` + PostGIS | High |
| Did any vehicle leave depot after hours? | `trips` time filter | High |
| What is the speeding rate per driver per week? | `position_history` speed filter | High |
| Which driver has the harshest driving style? | `position_history` delta query | High (needs 12 weeks of `track_list` data to accumulate) |
| Are vehicles on approved routes? | `position_history` + `geofences` | Low (pending geofence population) |
| Is cold chain in temperature range? | `temperature_readings` | Low (pending webhook registration) |
| How much fuel is consumed per route? | `fuel_readings` + `trips` | Low (pending fuel sensor webhook) |
| What is the real odometer per vehicle? | `live_positions.current_mileage` | Medium (depends on tracker calibration) |
| How many km to next service interval? | `live_positions.current_mileage` - last service | Open (requires service log) |
| Did any vehicle enter a restricted zone? | `alarms` (geofence type) + `geofences` | Low (pending geofence setup) |
| Which drivers are consistently late on Mondays? | `fact_daily_fleet_metrics` day-of-week filter | High |
| What percentage of the fleet was utilised today? | `trips` + `devices` count | High |
---
## 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 five steps activate the remaining analytics capabilities:
### 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.
```sql
-- 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
Currently all 63 devices show blank fields. Reports display IMEI numbers instead of human-readable identities.
```sql
-- Update individually or import from CSV via COPY
UPDATE tracksolid.devices
SET vehicle_name = 'KBZ 123A',
vehicle_number = 'KBZ 123A',
driver_name = 'John Kamau',
driver_phone = '+254700000001',
vehicle_category = 'van'
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)
```sql
-- 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
```bash
# 1. Run distance correction migration (fixes historical data)
docker exec timescale_db-bo3nov2ija7g8wn9b1g2paxs-210508774107 \
psql -U postgres -d tracksolid_db -f /migrations/04_bug_fix_migration.sql
# 2. Run schema enhancement migration (new tables + columns)
docker exec timescale_db-bo3nov2ija7g8wn9b1g2paxs-210508774107 \
psql -U postgres -d tracksolid_db -f /migrations/05_enhancement_migration.sql
# 3. Rebuild and restart ingestion containers with updated code
docker compose up -d --build ingest_movement ingest_events webhook_receiver
# 4. Schedule nightly ETL
# Add to cron or n8n:
# SELECT dwh_gold.refresh_daily_metrics(CURRENT_DATE - 1);
```
---
## Appendix — 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 |
---
*Document generated: 2026-04-10 · 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`*