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 |
**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.1 Current Deployment State *(as of 18 Apr 2026)*
> **⚠ New stack not yet live.** The refactored ingestion pipeline (`ingest_movement_rev.py` v2.2) targets the `tracksolid` schema, which is currently empty. All live data sits in the legacy `tracksolid_2` schema populated by the prior codebase. The queries in this document are written for the target schema (`tracksolid`) and will produce results once the new stack is deployed and the device sync has run.
**Why the pipeline stopped (6 Apr):** 276 consecutive `401 Unauthorized` errors against `eu-open.tracksolidpro.com`. The API token expired and was not refreshed — the prior codebase lacked the auto-refresh logic that `ts_shared_rev.py` now includes. Deploying the new stack resolves this permanently.
**CSV fleet (144 devices, X3/JC400P series):** The `20260414_FS__Logistics - final_fixed.csv` file contains a separate, newer batch of devices (`865135*`, `862798*` IMEIs) with full driver names and plates. **These 144 devices are not yet registered in the DB at all** — they will be synced by `sync_driver_audit.py` after the new stack is deployed, then enriched by `import_drivers_csv.py`.
---
### 1.2 Target Data Architecture
Once deployed, the ingestion stack populates the following data sources:
| 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.
`[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
| > 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.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') <6THEN'pre-dawndeparture'
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 | <40km|> 300 km |
| Long-haul truck | 300–500 km | <150km|> 700 km |
| Field/supervisor vehicle | 50–120 km | <20km|> 250 km |
| Motorcycle courier | 60–120 km | <30km|> 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
### 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 |
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_latORph.lat> c.max_lat
OR ph.lng <c.min_lngORph.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
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
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
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.
Status key: **✅ Ready** = answerable once new stack deployed | **⚙ Needs data** = additional setup required | **🔴 Blocked** = pending action before any data
| Business Question | Primary Data Source | Status |
The data foundation is in place. The following steps activate the remaining analytics capabilities, in priority order.
### Step 0 — Deploy New Ingestion Stack *(Current Blocker — do first)*
All analytics in this document are blocked until the new stack is live. The legacy pipeline stopped on **6 Apr 2026** due to 401 token expiry errors. The refactored code fixes this permanently.
```bash
# On the Coolify server / inside the repo directory:
# 1. Pull latest code (includes all revisions through cebcf74)
git pull
# 2. Apply schema migrations (01 through 06 in order)
TS_DB=$(docker ps --filter "name=timescale_db" --format "{{.Names}}" | head -1)
for f in 01_tracksolid_base.sql 02_tracksolid_full_schema_rev.sql \
**Automated:** `import_drivers_csv.py` (committed to the repo) reads `20260414_FS__Logistics - final_fixed.csv` (144 devices) and sets `driver_name`, `vehicle_number`, `vehicle_models`, `cost_centre`, `assigned_city`, `sim`, `iccid`, `imsi` in a single pass. Run after Step 0 device sync.
CSV coverage after import: 140 vehicles with plates, 144 with driver names, 138 with SIM, `assigned_city` inferred (NBO=136, KLA=4). The 4 "Identification" spare units are skipped automatically.
See **Step 0** above for the full deployment sequence. All six migrations (01–06) must be applied in order before starting the new containers. Step 0 includes the complete command block.
`[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 |
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,
**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.
| Cost per ticket (van, NBO baseline) | <400KES|400–900KES|> 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
*DB state verified: 18 Apr 2026 — live data in `tracksolid_2` (63 devices, pipeline stopped 6 Apr). New stack targets `tracksolid` schema — pending deployment.*