tracksolid_timescale_grafan.../01_BusinessAnalytics.md

773 lines
27 KiB
Markdown
Raw Normal View History

# 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
# Resolve container name dynamically (survives Coolify redeployments)
TS_DB=$(docker ps --filter "name=timescale_db" --format "{{.Names}}" | head -1)
# 1. Run distance correction migration (fixes historical data)
docker exec -i "$TS_DB" psql -U postgres -d tracksolid_db \
< /migrations/04_bug_fix_migration.sql
# 2. Run schema enhancement migration (new tables + columns)
docker exec -i "$TS_DB" psql -U postgres -d tracksolid_db \
< /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`*