tracksolid_timescale_grafan.../01_BusinessAnalytics.md
David Kiania cebcf74ba2 feat: business analytics expansion + driver CSV import
- 01_BusinessAnalytics.md: add §0 usage tags, §2.4 cost-per-ticket,
  §3.6–3.8 alarm/drift/odometer, §4.4–4.5 dispatch log + SLA metrics,
  §9 fleet readiness scorecard, §10 service-interval forecaster,
  Appendix B threshold calibration guide (773 → 1437 lines)

- 06_business_analytics_migration.sql: schema support for all new
  analytics sections — assigned_city column, dispatch_log table,
  ops schema, service_log, odometer_readings, tickets skeleton,
  vw_service_forecast view

- import_drivers_csv.py: one-shot script to populate driver_name,
  vehicle_number, vehicle_models, cost_centre, assigned_city, sim,
  iccid, imsi from 20260414_FS__Logistics - final_fixed.csv (144 rows);
  dry-run by default, --apply to commit, --only-null for safe additive mode

- 20260414_FS__Logistics - final_fixed.csv: source data committed for
  reproducibility and container exec workflow

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

1437 lines
53 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
0. [How to Use This Document](#0-how-to-use-this-document)
1. [Data Foundation Summary](#1-data-foundation-summary)
2. [Fleet Utilisation](#2-fleet-utilisation)
3. [Driver Behaviour](#3-driver-behaviour)
4. [Real-Time Dispatch & Field-Service SLAs](#4-real-time-dispatch--field-service-slas)
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)
9. [Fleet Readiness Scorecard](#9-fleet-readiness-scorecard)
10. [Service-Interval Forecaster](#10-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](#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
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
`[DASHBOARD]` `[ALERT]` alert if a vehicle has not moved for 2 consecutive working days.
```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;
```
---
### 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.
```sql
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.
```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;
```
---
### 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.
```sql
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:
```sql
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 |
```sql
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.
```sql
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.
```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;
```
---
### 4.4 Dispatch Log Schema
A persistent record of every dispatch decision, needed for every SLA and cost metric that follows. Create once:
```sql
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:
```sql
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:
```sql
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):
```sql
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:
```sql
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:**
```sql
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
```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);
```
---
## 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 |
```sql
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):
```sql
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:
```sql
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:
```sql
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:
```sql
-- 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:
```sql
-- 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 generated: 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`*