- 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>
1437 lines
53 KiB
Markdown
1437 lines
53 KiB
Markdown
# 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`*
|