Resolving Threshold Conflicts on Mixed-Product Pallets
When one temperature probe covers a zone holding several products, whose limit applies? A pallet consolidating a 2–8 °C biologic, a 15–25 °C controlled-room-temperature tablet, and a ≤ −20 °C lyophilised vaccine cannot honour three envelopes with one sensor — the products’ safe ranges do not even overlap. Where they do share a monitored zone, the engine must resolve a single binding limit, and the only defensible resolution is the tightest one: the intersection of every co-located product’s safe band. This guide implements that resolution deterministically in Python, as a focused technique under dynamic threshold mapping for multi-product pallets, where the sealed manifest binds sensors to SKUs but a shared zone still forces a conflict decision.
Regulatory hook
The controlling clause is 21 CFR Part 11 §11.10(e): the limit that produced any disposition must survive as a secure, time-stamped, tamper-evident record, so the resolution rule — not merely the resolved value — must be reconstructable. ICH Q9 quality risk management supplies the resolution principle: corrective action, and therefore the protective limit, must reflect the most at-risk product in the zone, because a reading that is safe for a robust tablet may already be degrading a fragile biologic. Choosing the tightest intersection is the risk-based choice; choosing anything looser silently accepts exposure on the most sensitive SKU, which surfaces as a data-integrity and product-quality finding rather than a mere configuration preference.
Prerequisites
- Python 3.11 or newer (the example uses
dataclasses,Decimal, and type unions). - Standard library only:
decimal,hashlib,json, anddataclassescover the resolution engine; fixed-pointDecimalkeeps the resolved band reproducible. - A sealed pallet manifest mapping each sensor to the SKUs in its zone, produced at palletisation as described in dynamic threshold mapping for multi-product pallets.
- Validated per-SKU envelopes — nominal band, tolerance, and cumulative allowance — sourced from establishing temperature excursion thresholds by product.
- Access control: the profile repository must be read over an authenticated channel so a resolved limit is provably built from certified envelopes (§11.10(g)).
- A downstream scorer: the resolved band feeds duration-based scoring for temperature excursions.
The conflict cases at a glance
Before the code, it helps to see the three outcomes a resolver must distinguish. The matrix below shows what happens when two co-located products’ bands relate in each possible way.
Step-by-Step Implementation
Step 1 — Model an envelope and the intersection operation
Represent each product’s safe band as a fixed-point interval and define the intersection as the maximum of the lows and the minimum of the highs. Using Decimal keeps the resolved boundary bit-identical on replay, which matters because the resolved limit is an audited value.
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Envelope:
sku_id: str
low_c: Decimal # validated nominal low, inclusive
high_c: Decimal # validated nominal high, inclusive
profile_version: str
def __post_init__(self):
if self.high_c <= self.low_c:
raise ValueError(f"{self.sku_id}: high must exceed low")
@dataclass(frozen=True)
class BindingBand:
low_c: Decimal
high_c: Decimal
monitorable: bool # False when the intersection is a degenerate point
def intersect(a: Envelope, b: Envelope) -> BindingBand | None:
# ICH Q9 tightest-limit rule: the binding band is the intersection, protecting
# the most at-risk product. Return None when the bands are disjoint.
low = max(a.low_c, b.low_c)
high = min(a.high_c, b.high_c)
if low > high:
return None # disjoint: no single band can protect both
return BindingBand(low, high, monitorable=(low < high)) # low == high is degenerate
Step 2 — Resolve a whole zone deterministically
A zone can hold more than two SKUs, so fold the intersection across every co-located envelope. Sort by sku_id first so the fold order — and therefore any error message — is deterministic and reproducible under §11.10(e).
def resolve_zone(envelopes: list[Envelope]) -> BindingBand:
if not envelopes:
raise ValueError("cannot resolve an empty zone")
# Deterministic order so a replay produces identical resolution and errors.
ordered = sorted(envelopes, key=lambda e: e.sku_id)
band: BindingBand | None = BindingBand(ordered[0].low_c, ordered[0].high_c, True)
running = ordered[0]
for nxt in ordered[1:]:
result = intersect(running, nxt)
if result is None:
# §11.10(e): a disjoint co-location is an integrity error, not a
# silently loosened limit. Reject so the manifest cannot ship blind.
raise ValueError(
f"disjoint envelopes in zone: {running.sku_id} vs {nxt.sku_id}"
)
band = result
# Carry the tightened band forward as a synthetic envelope for the fold.
running = Envelope("_folded_", band.low_c, band.high_c, running.profile_version)
assert band is not None
return band
Verify the three cases from the matrix resolve as specified:
bio = Envelope("BIO-774", Decimal("2"), Decimal("8"), "3.1.0")
crt = Envelope("CRT-112", Decimal("5"), Decimal("15"), "2.4.0")
touch = Envelope("CRT-112", Decimal("8"), Decimal("25"), "2.4.0")
frozen = Envelope("VAC-990", Decimal("-25"), Decimal("-15"), "4.0.0")
assert resolve_zone([bio, crt]) == BindingBand(Decimal("5"), Decimal("8"), True) # overlap
assert resolve_zone([bio, touch]).monitorable is False # degenerate point
try:
resolve_zone([bio, frozen]) # disjoint
assert False, "expected rejection of disjoint co-location"
except ValueError:
pass
Step 3 — Refuse to ship an unmonitorable or disjoint zone
Resolution that yields a degenerate point (bands merely touching) or no intersection is not a limit an engine can enforce with one sensor. Surface it at pallet seal, where a planner can split the load or add a dedicated probe, rather than at 2 a.m. when a reading is already out of band.
def validate_zone_or_raise(envelopes: list[Envelope]) -> BindingBand:
band = resolve_zone(envelopes) # raises on disjoint
if not band.monitorable:
# A single-point band cannot distinguish compliant from excursion, so it
# is unmonitorable with one probe — reject at seal (§11.10(a) reliability).
raise ValueError(
f"zone resolves to a single point {band.low_c} °C; "
"add a dedicated sensor or split the load"
)
return band
Step 4 — Seal the resolution into a tamper-evident record
The binding decision must be provable after the fact, so bind the resolved band, the contributing SKUs, and their pinned profile versions into a fingerprinted record. An investigator can then confirm exactly which limit governed the zone and how it was derived.
def seal_resolution(zone_id: str, envelopes: list[Envelope],
band: BindingBand) -> dict:
record = {
"zone_id": zone_id,
"binding_low_c": str(band.low_c),
"binding_high_c": str(band.high_c),
# Which SKUs and profile versions produced the resolution — §11.10(e)
# makes the derivation, not just the value, reconstructable. Sorted so the
# canonical record and its hash are order-independent.
"contributors": [
{"sku_id": e.sku_id, "version": e.profile_version}
for e in sorted(envelopes, key=lambda e: e.sku_id)
],
}
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"))
# §11.10(e) tamper evidence: a fingerprint over the resolution so a later edit
# of the binding limit is detectable.
record["resolution_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return record
Confirm the sealed record is reproducible for the same inputs:
band = validate_zone_or_raise([bio, crt])
r1 = seal_resolution("ZONE-A", [bio, crt], band)
r2 = seal_resolution("ZONE-A", [crt, bio], band) # input order must not matter
assert r1["resolution_hash"] == r2["resolution_hash"]
Compliance Validation Checklist
Run this as part of computerized-system validation; every item is something an auditor can independently confirm for this control.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| A biologic degrades while readings look compliant | Zone bound to a loose or averaged limit | Bind to the intersection (max(lows), min(highs)), not a mean or the widest band |
| Resolution differs between runs for the same pallet | Non-deterministic fold order or float boundaries | Sort envelopes by sku_id and compute in Decimal |
| A frozen and a chilled SKU share one probe | Disjoint bands accepted at seal | Reject disjoint co-locations; split the load or add a dedicated sensor |
| Engine cannot distinguish compliant from excursion | Bands merely touch, giving a single-point limit | Flag monitorable = False and refuse the zone at seal |
| Auditor cannot prove which limit was active | Only the resolved value stored, not its derivation | Seal contributing SKUs and versions under a resolution_hash (§11.10(e)) |
Related
- Dynamic Threshold Mapping for Multi-Product Pallets — the sealed-manifest model this resolver operates on.
- Duration-Based Scoring for Temperature Excursions — scores deviations against the resolved band.
- Establishing Temperature Excursion Thresholds by Product — the certified envelopes the resolver intersects.
- Warming Rule Caches on Engine Cold Start — hydrates the resolved limits into the hot path.
For architectural context, this guide supports Dynamic Threshold Mapping for Multi-Product Pallets, part of the broader Temperature Excursion Detection & Automated Rule Engines section.