Multi-Sensor Correlation to Reduce False Positives
A single-probe threshold breach is the weakest possible basis for a regulatory action, yet it is still the trigger most rule engines fire on. A door held open for ninety seconds, a defrost cycle, a forklift jolting a logger, or one drifting thermocouple will each push a primary RTD past its limit and cascade into an out-of-specification (OOS) flag, a quarantine hold, and a CAPA workflow — none of which the product actually needed. Every one of those false positives consumes QA review time, slows supply chain velocity, and adds thermal risk through repeated handling of cargo that was never compromised. Multi-sensor correlation closes this gap by requiring corroboration: before a deviation escalates, the engine cross-references the primary reading against auxiliary environmental and operational signals and only confirms the excursion when independent evidence agrees. The single regulatory anchor for this work is 21 CFR 11.10(e): a suppressed alert is still a record, so the original telemetry, the sensor states that justified suppression, and the rule path that produced the decision must all survive as a secure, time-stamped, tamper-evident entry.
This correlation layer is the noise filter inside the broader Temperature Excursion Detection & Automated Rule Engines framework. It sits downstream of ingestion and upstream of scoring, so that spurious single-probe spikes are removed before the duration-based excursion scoring engine ever accumulates exposure against them.
Problem Statement: Why Single-Probe Breaches Misfire
Three failure patterns drive the false-positive rate in practice, and each maps to a corroborating signal that the primary probe alone cannot see:
- Transient thermal ingress. A door-open or load event raises local air temperature for seconds to a couple of minutes, well inside the documented stability margin of most 2–8 °C biologics, but long enough to cross an instantaneous limit. A door-contact sensor and a stable compressor runtime tell the engine this is ingress, not a cooling failure.
- Mechanical artefacts. A forklift impact or transport vibration can momentarily unseat a probe or induce a spurious reading. An accelerometer spike co-timed with the deviation marks the event as a handling artefact.
- Single-sensor drift or fault. One miscalibrated or failing probe reports a breach while every neighbouring node in the same zone stays in band. Spatial disagreement is the signature of an instrument fault, not a product event.
No regulation explicitly mandates correlation, but suppressing an alert without it is indefensible: under ICH Q9 quality risk management the suppression logic is a risk control that must be assessed, version-controlled, and validated, and under ICH Q10 any change to its weights or thresholds triggers change management. The non-negotiable design constraint is that suppression must never become silent dismissal — every downgrade is a compliance artefact, and the false-negative risk of masking a real excursion has to be bounded explicitly. Tolerance bands themselves are resolved per SKU upstream by Dynamic Threshold Mapping for Multi-Product Pallets, so the correlation layer reasons about whether a breach is real, not where the limit sits.
Concept & Specification: Consensus Model and Suppression Record
Correlation combines two distinct decision mechanisms. Deterministic hard gates invalidate an excursion immediately when an unambiguous physical cause is present — for example, door open and compressor active and a vibration spike consistent with a load event. Hard gates are auditable boolean logic with no probabilistic ambiguity, which is what makes them defensible to an inspector. Probabilistic consensus scoring handles the residual: each co-located sensor casts a weighted vote on whether the deviation is real, weighted by historical reliability, calibration status, and spatial proximity to the primary probe. The engine confirms an excursion only when the weighted agreement crosses a configured confidence threshold and a minimum number of corroborating sensors are present.
The decision is recorded as an append-only, hash-chained entry whether the outcome is CONFIRMED or SUPPRESSED. The Regulatory anchor column states why each field must exist for the record to survive inspection.
| Field | Type | Constraint | Regulatory anchor |
|---|---|---|---|
event_id |
string (UUID) | Immutable, unique per evaluation | 11.10(e) attributable, traceable record |
primary_sensor_id |
string | Non-null; probe that reported the breach | 11.10(e) attributable to source |
peak_deviation_c |
decimal(5,4) | Signed magnitude outside band | ICH Q9 risk magnitude evidence |
corroborating_sensors |
int | ≥ 0; count agreeing in window | ICH Q9 basis for suppression decision |
consensus_score |
decimal(5,4) | 0–1, quantised half-up | 11.10(b) accurate, reproducible value |
hard_gate |
enum | NONE / DOOR / VIBRATION / DEFROST |
11.10(a) deterministic decision evidence |
decision |
enum | CONFIRMED / SUPPRESSED |
ICH Q9 proportional action routing |
config_version |
string (sha256) | Fingerprint of correlation parameters | 11.10(k) change control over logic |
evaluated_at |
string (ISO 8601, UTC) | Timezone-aware, UTC | 11.10(e) contemporaneous time-stamp |
prev_hash |
string (sha256) | Links to prior record | 11.10(e) tamper-evident audit chain |
record_hash |
string (sha256) | SHA-256 of canonical record | ALCOA+ enduring, tamper-evident |
Two properties keep this from becoming a black box. The consensus score is computed in fixed-point Decimal so a replayed evaluation is bit-identical on any host — supporting the “accurate and complete records” requirement of 11.10(b) and the reproducibility expectation of 11.10(a). And the full correlation configuration (sensor weights, confidence threshold, min_corroborating_sensors, window length, hard-gate definitions) is fingerprinted into every record, so a reviewer can prove which parameter set produced a given suppression — the change-control evidence required by 11.10(k).
Architecture Diagram: Correlation Pipeline
The correlation layer ingests aligned, UTC-normalised telemetry, evaluates hard gates first (a single deterministic veto is cheaper and more defensible than a probabilistic vote), then runs consensus scoring over a sliding window before emitting a confirmed or suppressed disposition — each branch writing a hash-chained record. Timestamp normalisation upstream depends on Time-Series Alignment for Multi-Zone Cold Storage, because correlation over an unaligned series compares readings that never co-occurred and silently corrupts the consensus.
Production Python Implementation
The module below is a complete, runnable correlation engine. It buffers a sliding window of aligned readings, applies deterministic hard gates, computes a fixed-point consensus score from weighted sensor votes, enforces a min_corroborating_sensors floor, fingerprints its configuration into every record, and links records into an append-only hash chain. Each block cites the clause it satisfies. The engine never silently maps missing auxiliary data to “no breach”: when corroborating coverage falls below the configured floor it raises the decision to CONFIRMED under a tightened policy rather than suppressing on absent evidence.
"""Multi-sensor correlation engine for pharmaceutical cold chain excursion gating.
Consumes aligned telemetry, suppresses single-probe false positives via
deterministic hard gates plus probabilistic consensus, and emits a
tamper-evident, hash-chained decision record for every evaluation.
"""
from __future__ import annotations
import hashlib
import json
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Deque, Mapping, Sequence
class Decision(str, Enum):
CONFIRMED = "CONFIRMED"
SUPPRESSED = "SUPPRESSED"
class HardGate(str, Enum):
NONE = "NONE"
DOOR = "DOOR"
VIBRATION = "VIBRATION"
DEFROST = "DEFROST"
@dataclass(frozen=True)
class CorrelationConfig:
"""Versioned correlation parameters. The fingerprint of this object is
bound to every record to satisfy change control over decision logic
(21 CFR 11.10(k))."""
window_seconds: int = 90
confidence_threshold: Decimal = Decimal("0.70")
min_corroborating_sensors: int = 2
# Per-sensor vote weight: reliability x calibration x spatial proximity,
# risk-assessed and documented under ICH Q9 quality risk management.
sensor_weights: Mapping[str, Decimal] = field(
default_factory=lambda: {
"rtd_a": Decimal("1.00"),
"rtd_b": Decimal("0.95"),
"rtd_c": Decimal("0.90"),
}
)
vibration_g_threshold: Decimal = Decimal("0.80")
def fingerprint(self) -> str:
# Canonical, sorted serialisation so the fingerprint is reproducible
# on any host (11.10(a) validated, reproducible system behaviour).
payload = {
"window_seconds": self.window_seconds,
"confidence_threshold": str(self.confidence_threshold),
"min_corroborating_sensors": self.min_corroborating_sensors,
"sensor_weights": {k: str(v) for k, v in sorted(self.sensor_weights.items())},
"vibration_g_threshold": str(self.vibration_g_threshold),
}
canon = json.dumps(payload, separators=(",", ":"), sort_keys=True)
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class Reading:
"""A single aligned telemetry sample (timestamps already UTC-normalised
upstream by the time-series alignment layer)."""
sensor_id: str
deviation_c: Decimal # signed distance outside the in-band envelope
ts: datetime # timezone-aware UTC
door_open: bool = False
compressor_active: bool = False
vibration_g: Decimal = Decimal("0")
humidity_drop: bool = False
def _q(value: Decimal, places: str) -> Decimal:
# Half-up quantisation gives a deterministic, inspector-reproducible value
# for the persisted score (11.10(b) accurate and complete records).
return value.quantize(Decimal(places), rounding=ROUND_HALF_UP)
class CorrelationEngine:
def __init__(self, config: CorrelationConfig, genesis_hash: str = "0" * 64):
self.config = config
self._buffer: Deque[Reading] = deque()
self._prev_hash = genesis_hash
self._config_version = config.fingerprint()
def _prune(self, now: datetime) -> None:
horizon = now - timedelta(seconds=self.config.window_seconds)
while self._buffer and self._buffer[0].ts < horizon:
self._buffer.popleft()
def _hard_gate(self, primary: Reading) -> HardGate:
# Deterministic vetoes: an unambiguous physical cause invalidates the
# excursion without a probabilistic vote (11.10(a) defensible logic).
if primary.door_open and primary.compressor_active:
return HardGate.DOOR
if primary.vibration_g >= self.config.vibration_g_threshold:
return HardGate.VIBRATION
if primary.humidity_drop and primary.compressor_active:
return HardGate.DEFROST
return HardGate.NONE
def _consensus(self, primary: Reading) -> tuple[Decimal, int]:
"""Weighted agreement that the deviation is real. A corroborating
sensor is one whose own deviation has the same sign and at least half
the primary magnitude within the window."""
agree = Decimal("0")
total = Decimal("0")
corroborating = 0
threshold_mag = abs(primary.deviation_c) / 2
for r in self._buffer:
if r.sensor_id == primary.sensor_id:
continue
weight = self.config.sensor_weights.get(r.sensor_id, Decimal("0.50"))
total += weight
same_sign = (r.deviation_c >= 0) == (primary.deviation_c >= 0)
if same_sign and abs(r.deviation_c) >= threshold_mag:
agree += weight
corroborating += 1
if total == 0:
return Decimal("0"), 0
return _q(agree / total, "0.0001"), corroborating
def evaluate(self, primary: Reading) -> dict:
self._buffer.append(primary)
self._prune(primary.ts)
gate = self._hard_gate(primary)
score, corroborating = self._consensus(primary)
if gate is not HardGate.NONE:
decision = Decision.SUPPRESSED
elif corroborating < self.config.min_corroborating_sensors:
# Insufficient corroboration is NOT evidence of a false positive.
# Fail safe to CONFIRMED so absent data can never mask a real
# excursion (ICH Q9 bounded false-negative risk; WHO GDP continuous
# control during sensor dropout).
decision = Decision.CONFIRMED
elif score >= self.config.confidence_threshold:
decision = Decision.CONFIRMED
else:
decision = Decision.SUPPRESSED
return self._seal(primary, gate, score, corroborating, decision)
def _seal(self, primary: Reading, gate: HardGate, score: Decimal,
corroborating: int, decision: Decision) -> dict:
# Append-only hash chain: every decision, confirmed or suppressed, is a
# tamper-evident record linked to its predecessor (11.10(e) audit trail).
record = {
"event_id": hashlib.sha256(
f"{primary.sensor_id}{primary.ts.isoformat()}".encode()
).hexdigest(),
"primary_sensor_id": primary.sensor_id,
"peak_deviation_c": str(_q(primary.deviation_c, "0.0001")),
"corroborating_sensors": corroborating,
"consensus_score": str(score),
"hard_gate": gate.value,
"decision": decision.value,
"config_version": self._config_version,
"evaluated_at": primary.ts.astimezone(timezone.utc).isoformat(),
"prev_hash": self._prev_hash,
}
canon = json.dumps(record, separators=(",", ":"), sort_keys=True)
record["record_hash"] = hashlib.sha256(canon.encode("utf-8")).hexdigest()
self._prev_hash = record["record_hash"]
return record
def verify_chain(records: Sequence[Mapping], genesis_hash: str = "0" * 64) -> bool:
# Re-derive each hash to prove no record was edited after the fact, the
# tamper-evidence check an inspector applies under 11.10(e).
prev = genesis_hash
for rec in records:
if rec["prev_hash"] != prev:
return False
body = {k: v for k, v in rec.items() if k != "record_hash"}
canon = json.dumps(body, separators=(",", ":"), sort_keys=True)
if hashlib.sha256(canon.encode("utf-8")).hexdigest() != rec["record_hash"]:
return False
prev = rec["record_hash"]
return True
if __name__ == "__main__":
cfg = CorrelationConfig()
engine = CorrelationEngine(cfg)
now = datetime.now(timezone.utc)
# Door-open ingress: hard-gate suppression.
out = engine.evaluate(Reading(
sensor_id="rtd_a", deviation_c=Decimal("2.5"), ts=now,
door_open=True, compressor_active=True,
))
print(out["decision"], out["hard_gate"]) # SUPPRESSED DOOR
For the consumer side — async ingestion, time-series resampling, and the spatial-temporal DataFrame that feeds aligned readings into this engine — see the implementation walkthrough in Reducing false alarms with multi-sensor correlation in Python.
Configuration & Deployment Parameters
Correlation behaviour is governed by a small set of risk-assessed parameters. Treat each as a controlled item under ICH Q10 change management — a change to any of them is a change to validated decision logic and re-fingerprints every downstream record.
| Parameter | Env var | Typical value | Rationale |
|---|---|---|---|
| Window length | CORR_WINDOW_SECONDS |
30–120 s | Long enough to capture co-timed signals, short enough to keep latency sub-second. |
| Confidence threshold | CORR_CONFIDENCE |
0.70 | Weighted-agreement floor to confirm; calibrate against historical excursions. |
| Min corroborating sensors | CORR_MIN_SENSORS |
2 | Below this the engine fails safe to CONFIRMED. |
| Vibration veto | CORR_VIBRATION_G |
0.80 g | Hard-gate threshold matching documented forklift/transport impact profiles. |
| Aux availability floor | CORR_AUX_FLOOR |
0.60 | Fraction of expected auxiliary sensors required before consensus is trusted. |
Deploy the correlation engine as a stateless evaluator behind the alignment layer, with configuration loaded from a signed, version-controlled source rather than ad-hoc edits. Isolate correlation from alert routing so a routing backlog cannot delay a confirmed excursion. Calibrate confidence_threshold and min_corroborating_sensors against real historical excursion data from your specific product types and facility layouts — not generic pharmaceutical defaults, which do not capture the rare-but-recurring failure modes of a physical sensor fleet. Tolerance bands the engine reasons against are owned upstream by Dynamic Threshold Mapping for Multi-Product Pallets, and the gateway that authenticates and forwards this telemetry is covered by Designing Secure IoT Gateways for Pharma Logistics.
Verification & Testing
Validation must prove the engine filters environmental noise without masking genuine thermal deviations — the OQ/PQ acceptance criterion. Two test classes matter:
- Controlled injection tests. Replay recorded door-open, defrost, and forklift-impact sequences alongside a real cooling-failure trace and assert the engine suppresses the former and confirms the latter. This exercises the rare physical failure modes that Monte Carlo synthetic data does not reproduce.
- Determinism and audit-chain tests. Assert that re-evaluating the same input on a different host produces a bit-identical
consensus_scoreandrecord_hash, and thatverify_chainrejects any record whose body is mutated.
import copy
from decimal import Decimal
from datetime import datetime, timezone
def test_door_event_is_suppressed():
cfg = CorrelationConfig()
engine = CorrelationEngine(cfg)
rec = engine.evaluate(Reading(
sensor_id="rtd_a", deviation_c=Decimal("2.5"),
ts=datetime.now(timezone.utc),
door_open=True, compressor_active=True,
))
# Transient ingress must never reach CAPA (ICH Q9 proportional action).
assert rec["decision"] == "SUPPRESSED"
assert rec["hard_gate"] == "DOOR"
def test_chain_detects_tampering():
cfg = CorrelationConfig()
engine = CorrelationEngine(cfg)
recs = [engine.evaluate(Reading("rtd_a", Decimal("3.0"),
datetime.now(timezone.utc)))]
assert verify_chain(recs) is True
forged = copy.deepcopy(recs)
forged[0]["decision"] = "SUPPRESSED" # post-hoc edit
# 11.10(e) tamper-evidence: any edit must break verification.
assert verify_chain(forged) is False
Wire these into the CSV protocol: the injection suite is the OQ evidence that suppression logic is bounded, and the determinism suite is the PQ evidence that the system is reproducible. Schema conformance of the inbound telemetry is validated separately by Schema Validation Pipelines for Temperature Telemetry, so the correlation tests can assume well-formed input and focus on decision correctness.
Known Failure Modes & Mitigations
| Failure mode | Symptom | Mitigation |
|---|---|---|
| Auxiliary sensor dropout | Corroborating count collapses; consensus untrustworthy | Below CORR_AUX_FLOOR, revert to single-sensor evaluation with tightened limits and elevated priority until coverage is restored (WHO GDP continuous control). |
| Clock skew between probes | Readings that did not co-occur are correlated | Reject the window if alignment confidence is low; rely on UTC normalisation from the time-series alignment layer. |
| Over-aggressive suppression | Real excursion masked; false negatives rise | Fail safe to CONFIRMED below min_corroborating_sensors; review suppression logs against confirmed product complaints. |
| Config drift | Suppression decisions no longer reproducible | Fingerprint config into every record; reject readings evaluated under an unregistered config_version (11.10(k)). |
| Correlated common-cause fault | All probes fail the same way, faking consensus | Cross-check against an independent signal class (power continuity, compressor runtime) before confirming agreement. |
Every suppressed event must still generate a low-priority operational log for QA review — suppression is a documented disposition, not a deletion. When an excursion is confirmed, the disposition is handed to the duration-based excursion scoring engine and on into CAPA routing, with the audit chain preserving the full decision path for inspection.
Related
- Reducing false alarms with multi-sensor correlation in Python — the step-by-step async ingestion and consensus DataFrame walkthrough that feeds this engine.
- Duration-Based Scoring for Temperature Excursions — scores the confirmed events this layer lets through.
- Dynamic Threshold Mapping for Multi-Product Pallets — resolves the per-SKU bands the correlation engine reasons against.
- Time-Series Alignment for Multi-Zone Cold Storage — the UTC normalisation that makes correlation across probes valid.
- Designing Secure IoT Gateways for Pharma Logistics — authenticates and forwards the telemetry this layer consumes.
For architectural context, this page sits under Temperature Excursion Detection & Automated Rule Engines.