# 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 | 2–6 fixes per minute per active vehicle | | Route gaps of 1–2 km between points | Continuous accurate path traces | | Speed deltas invisible at 60s intervals | Harsh driving events detectable at 10–30s 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 | | 55–70% | Good — healthy operational range | No action required | | 40–55% | 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 | |---|---|---| | 80–100 km/h | Warning | Log, notify supervisor if persistent | | 100–120 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 | 80–150 km | < 40 km | > 300 km | | Long-haul truck | 300–500 km | < 150 km | > 700 km | | Field/supervisor vehicle | 50–120 km | < 20 km | > 250 km | | Motorcycle courier | 60–120 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 1–2 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 40–60%, 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% | 40–60% | < 40% | | Idle time as % of shift | < 15% | 15–30% | > 30% | | Speeding events per 100 km | < 0.5 | 0.5–2.0 | > 2.0 | | Harsh driving index per 100 km | < 0.5 | 0.5–2.0 | > 2.0 | | Late starts per month (per driver) | 0–1 | 2–4 | ≥ 5 | | Days vehicle not used (per month) | 0–2 | 3–5 | > 5 | | GPS fix age (live_positions) | < 2 min | 2–10 min | > 10 min | | Alarm rate per vehicle per week | 0–2 | 3–7 | > 7 | | Readiness score (§9) | ≥ 85 | 60–84 | < 60 | | Cost per ticket (van, NBO baseline) | < 400 KES | 400–900 KES | > 900 KES | | On-site within 90 min (§4.5) | ≥ 85% | 70–85% | < 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`*