Cold-chain temperature excursions: count events, not readings
In this article (8 sections)
Related programme: the Data Science course (6 months; ₹1,30,000 (inclusive of GST) · EMI from ₹5,000/month).
A cold-chain excursion is a continuous period when a product's temperature is outside its labelled range, so the unit of analysis is the event, not the reading. Group consecutive out-of-range readings into runs, record each run's start, duration, side and peak, and treat any gap in the logger record as unknown rather than in range. In the synthetic data below, ten out-of-range readings collapse into four events, two of which meet an illustrative 30-minute escalation rule, and one shipment has a 70-minute hole that no summary should paper over.
Who is asking, and what they must decide
The reader of this analysis is usually a quality assurance head at a pharmaceutical distributor, a vaccine store or a food logistics firm. For every flagged shipment they decide whether stock can be released, must be quarantined pending the manufacturer's stability assessment, or must be destroyed. Across many shipments they decide which lanes, vehicles, packaging or hand-over points need fixing. A dashboard that says "37 alarms this week" helps neither decision. They need to know how many distinct excursions happened, how long each lasted, how far each went, and where the record is missing.
The acceptable range, any time allowances and the release decision come from the product label, the manufacturer's stability data and your quality procedures. This article treats all of them as inputs.
What arrives from the loggers
| Field | Example | Note |
|---|---|---|
| shipment_id | SH-A | Or a logger serial mapped to a shipment by date |
| ts | 2026-09-15 09:00 | Check the logger's clock and time zone against dispatch records |
| temp_c | 8.6 | Probe position matters: air, product simulant or inside the box |
| interval | 10 min | Configured sampling interval; drift or missed samples are common |
| trip events | dispatch, handover, receipt | From the transport management system, not the logger |
Loggers often upload only when they reach a gateway, so data arrives late and in pieces. Deduplicate on shipment and timestamp before analysis.
Definitions to fix before coding
- Excursion event: a maximal run of consecutive readings on the same side of the range (above high, or below low), not interrupted by a logging gap.
- Duration = readings in the run × logging interval. This treats each reading as representing its interval; if your procedure interpolates the crossing time, use that instead and say so.
- Peak = maximum temperature for a high excursion, minimum for a low one.
- Unknown interval: any gap between consecutive readings longer than a threshold you set.
- Events per 100 shipment-days = events ÷ (Σ shipment durations in days) × 100, for comparing lanes of different lengths.
The run-length grouping in pandas
Synthetic readings for two hypothetical shipments of a product labelled for 2–8 °C. The range, the minimum escalation duration and the gap threshold are parameters, not recommendations.
import pandas as pd
# Synthetic data logger readings, one every 10 minutes, for two hypothetical vaccine shipments
LOW_C, HIGH_C = 2.0, 8.0 # the labelled storage range for this product
MIN_EVENT_MIN = 30 # excursions shorter than this are logged but not escalated
INTERVAL_MIN = 10 # expected logging interval
GAP_MIN = 20 # a gap longer than this is "unknown", not "in range"
def shipment(sid, temps, start="2026-09-15 08:00", drop=()):
t = pd.date_range(start, periods=len(temps), freq=f"{INTERVAL_MIN}min")
df = pd.DataFrame({"shipment_id": sid, "ts": t, "temp_c": temps})
return df.drop(index=list(drop))
a = [5.0] * 6 + [8.6, 9.4, 10.1, 9.0, 7.5] + [5.5] * 6 + [8.3] + [5.0] * 4 + [1.4, 1.2, 1.8] + [4.0] * 3
b = [4.5] * 8 + [6.0, 6.5, 7.0, 7.5] + [4.8] * 6 + [8.9, 9.1] + [5.0] * 8
log = pd.concat([shipment("SH-A", a), shipment("SH-B", b, drop=range(12, 18))], ignore_index=True)
log = log.sort_values(["shipment_id", "ts"])
log["side"] = "in"
log.loc[log["temp_c"] > HIGH_C, "side"] = "high"
log.loc[log["temp_c"] < LOW_C, "side"] = "low"
log["out"] = log["side"] != "in"
log["gap_before_min"] = log.groupby("shipment_id")["ts"].diff().dt.total_seconds() / 60
# A new run starts when the side changes or a logging gap breaks the sequence
prev_side = log.groupby("shipment_id")["side"].shift()
brk = (log["side"] != prev_side) | (log["gap_before_min"] > GAP_MIN)
log["run"] = brk.groupby(log["shipment_id"]).cumsum()
ev = (log[log["out"]]
.groupby(["shipment_id", "run"])
.agg(start=("ts", "min"), side=("side", "first"), readings=("temp_c", "size"),
peak=("temp_c", lambda s: s.max() if s.max() > HIGH_C else s.min()))
.reset_index())
ev["minutes"] = ev["readings"] * INTERVAL_MIN
ev["escalate"] = ev["minutes"] >= MIN_EVENT_MIN
print(ev[["shipment_id", "start", "side", "minutes", "peak", "escalate"]].to_string(index=False))
gaps = log[log["gap_before_min"] > GAP_MIN]
print("Unknown intervals:")
print(gaps[["shipment_id", "ts", "gap_before_min"]].to_string(index=False))
print("Out-of-range readings:", int(log["out"].sum()))
print("Excursion events:", len(ev), "| escalated:", int(ev["escalate"].sum()))Output:
shipment_id start side minutes peak escalate
SH-A 2026-09-15 09:00:00 high 40 10.1 True
SH-A 2026-09-15 10:50:00 high 10 8.3 False
SH-A 2026-09-15 11:40:00 low 30 1.2 True
SH-B 2026-09-15 11:00:00 high 20 9.1 False
Unknown intervals:
shipment_id ts gap_before_min
SH-B 2026-09-15 11:00:00 70.0
Out-of-range readings: 10
Excursion events: 4 | escalated: 2The cumsum over a boolean "something changed" series is the whole trick: every change of side, or every gap, starts a new run number, and grouping by that number gives one row per event.
What a reading count would have hidden
SH-A had three different problems. A 40-minute high excursion peaking at 10.1 °C at 09:00 looks like a door left open or a delayed hand-over. A single 8.3 °C reading at 10:50 may be probe noise or a brief warm spot. A 30-minute low excursion to 1.2 °C at 11:40 is a freezing risk, which for many biologics is more serious than warmth. A total of "8 readings out of range" for SH-A mixes all three and loses the side entirely.
SH-B's record is incomplete. The logger reported nothing between 09:50 and 11:00, then its first reading back was 8.9 °C. The observed excursion is 20 minutes, below the escalation rule. But the product may have been warm for most of the hour before that. Reporting SH-B as "not escalated" would be a false reassurance; the honest status is "excursion of at least 20 minutes, preceded by a 70-minute logging gap", and that goes to the quality reviewer.
Traps specific to cold-chain data
- 1Treating gaps as in range. Any aggregation that simply counts out-of-range readings implicitly assumes the missing ones were fine.
- 2Merging events. Some procedures combine excursions separated by a short return to range. If yours does, add a merge window parameter and apply it after the run grouping, not by loosening the range.
- 3Cumulative allowances. Product-specific stability data may allow a cumulative time out of range across the product's life, not per shipment. That requires carrying excursion minutes forward across every leg, including storage.
- 4Probe placement and mapping. An air probe near the door reacts faster than product temperature. Know what the logger measured before interpreting a short spike.
- 5Clock drift. Logger clocks that are minutes or hours off make it impossible to match an excursion to a hand-over. Reconcile against dispatch and receipt times.
- 6Derived stability metrics. Mean kinetic temperature and similar measures rely on constants and methods set in the applicable guidance and product documentation. Do not improvise them in a dashboard.
Putting it in front of the QA head
Give them a per-shipment event table, exactly like the one above, with a status column that has three values: no excursion, excursion (with duration and peak), and incomplete record. Sort incomplete records to the top. For the lane review, show events per 100 shipment-days by lane and by leg, split into high and low, alongside the share of shipments with unknown intervals. A lane with few excursions and many gaps is not a good lane; it is an unmeasured one.
Leave release decisions to them. The analysis states what happened and what is unknown; the stability assessment and disposition belong to quality.
Learning the technique
Run-length grouping, time-series gaps and deciding what "missing" means are staples of the sensor and event data work in our Data Science programme. If you want the SQL-first route into operations analytics instead, the Data Analyst programme covers the aggregation side. A related pattern of splitting time into clipped intervals appears in EV charging station utilisation.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Science programme — 6 months. From data foundations to machine learning, deep learning and deployment.
Explore Data Science