Validating Endcap Promotional Compliance Windows
This walkthrough sits under Promotional Display Alignment Checks and solves one precise, time-shaped task: judge an endcap compliant only within its scheduled campaign window — present and correct while the promotion runs, and taken down or reset once it ends — without ever penalizing the permanent bay planogram that lives on that fixture the rest of the year. The parent stage owns fixture geometry, zone mapping, and identity scoring; this page owns the clock. A promotional endcap that is spatially perfect is still a violation if it was built two days early, and a bare fixture is perfectly compliant the morning after teardown. Score those two captures with the same present-or-absent rule and you will flag half your estate for no reason. The fix is to gate every grade by capture_timestamp against a modelled compliance window with an explicit grace buffer, then emit distinct temporal states — compliant, early_set, late_teardown, and missing_promo — so a category manager can tell an eager store from a negligent one. This page builds that gate step by step, and each step is independently verifiable.
Prerequisites & Context Jump to heading
Before applying this page, confirm the following are already in place. Everything here operates on timestamps, not pixels — the display has already been detected and its zones scored by the parent stage; all this gate adds is the temporal verdict.
- Runtime: Python
3.11+. No numeric libraries are required — the whole gate isdatetimearithmetic — but every timestamp it touches must be timezone-aware. Naive datetimes are the single largest source of window bugs and are rejected outright. - A campaign calendar: one authoritative record per promotional display carrying an
effective_startandeffective_endin UTC, keyed byplanogram_idandfixture_id. This is the temporal slice of the campaign manifest the parent stage already loads; here you consume only its start and end. - A
capture_timestampon every detection: stamped at the edge when the frame was taken, in UTC, and carried unchanged through scoring. If your captures are stamped at ingest rather than capture, fix that first — a queue backlog on reset morning will shift every timestamp forward and silently convertlate_teardownintocompliant. - The promo display’s own schema: a boolean or graded signal saying whether the promotional build was detected present and whether the hero SKU was correct. That signal is the output of Promotional Display Alignment Checks; the spatial geometry that produced it stays the concern of Position Validation Algorithms for Planograms, which is exactly why promotional fixtures route their geometry through a separate profile and only their timing is decided here.
- A
registry_revisionon the calendar row: so a capture can be reconciled against the campaign definition that was live when it was taken, not the one live now.
A note on terms: the compliance window is the interval during which the promotional build is expected to be present; the grace period is a symmetric buffer on each side of that interval in which an early build or a late teardown is tolerated rather than flagged.
Step 1 — Model the Compliance Window With a Grace Period Jump to heading
A campaign window is not a single interval — it is a core interval plus a tolerance buffer on each end. Stores do not build and strike endcaps to the minute; a display raised the evening before launch or struck the morning after close is operationally fine, and penalizing it manufactures noise that buries the genuinely early and genuinely late offenders. Model the window as effective_start, effective_end, and a single symmetric grace timedelta, and derive five phases from any capture_timestamp: before setup grace, inside setup grace, active, inside teardown grace, and after teardown grace.
Keep the model frozen and validate it hard, because a window with effective_end before effective_start, or with a naive timestamp, will grade every capture wrong in a way that looks plausible on a dashboard.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
class WindowPhase(Enum):
BEFORE_SETUP = "before_setup" # earlier than start - grace
SETUP_GRACE = "setup_grace" # [start - grace, start)
ACTIVE = "active" # [start, end]
TEARDOWN_GRACE = "teardown_grace" # (end, end + grace]
AFTER_TEARDOWN = "after_teardown" # later than end + grace
@dataclass(frozen=True)
class CampaignWindow:
"""The scheduled compliance interval for one promotional display."""
planogram_id: str
fixture_id: str
effective_start: datetime # tz-aware UTC
effective_end: datetime # tz-aware UTC
registry_revision: int
grace: timedelta = timedelta(hours=12)
def __post_init__(self) -> None:
for name in ("effective_start", "effective_end"):
ts = getattr(self, name)
if ts.tzinfo is None or ts.utcoffset() is None:
raise ValueError(f"{name} must be timezone-aware (got naive datetime)")
if self.effective_end <= self.effective_start:
raise ValueError("effective_end must be after effective_start")
if self.grace < timedelta(0):
raise ValueError("grace must be non-negative")
def phase(self, captured_at: datetime) -> WindowPhase:
if captured_at.tzinfo is None or captured_at.utcoffset() is None:
raise ValueError("capture_timestamp must be timezone-aware")
t = captured_at.astimezone(timezone.utc)
if t < self.effective_start - self.grace:
return WindowPhase.BEFORE_SETUP
if t < self.effective_start:
return WindowPhase.SETUP_GRACE
if t <= self.effective_end:
return WindowPhase.ACTIVE
if t <= self.effective_end + self.grace:
return WindowPhase.TEARDOWN_GRACE
return WindowPhase.AFTER_TEARDOWNNormalizing every incoming timestamp to UTC with astimezone before any comparison is what makes the gate correct across a chain that spans time zones — the calendar stays in one frame and the capture is projected into it, so a 07:00 local build in two different zones does not grade differently.
Step 2 — Gate Scoring by capture_timestamp Against the Active Window Jump to heading
With phases defined, the second step is to decide, for a given capture, whether the promotional rule even applies. Outside the window the baseline planogram governs the fixture, so a promo-specific verdict must not run at all — that is how you avoid dinging the permanent bay. Represent the observation the parent stage produced as a small typed record and let the phase decide whether it is in scope.
from typing import Literal
WindowState = Literal[
"compliant", "early_set", "late_teardown", "missing_promo", "inactive"
]
@dataclass(frozen=True)
class PromoObservation:
fixture_id: str
capture_timestamp: datetime # tz-aware
promo_present: bool # promotional build detected on the fixture
sku_correct: bool # detected hero SKU matches the campaign
def in_scope(phase: WindowPhase) -> bool:
"""Only the active window scores presence as pass/fail; grace is a buffer,
and the baseline segments belong to the permanent planogram, not the promo."""
return phase is WindowPhase.ACTIVEThe design decision that matters here is that presence is graded as a hard requirement in exactly one phase — ACTIVE. In the two grace phases presence is tolerated but never demanded, and in the two baseline phases the promo rule is silent. This keeps a capture that lands the day before a campaign, or a week after it closes, from ever producing a promotional penalty; those captures reconcile against the baseline profile instead.
Step 3 — Score Present-and-Correct During, Absent-After Jump to heading
Now combine phase with the observed presence signal into a single verdict. During the active window the display must be present and showing the correct hero SKU; anywhere in the grace buffers a present build is acceptable and an absent one is a non-event; before setup grace or after teardown grace any presence at all is the violation, because the fixture should be showing its baseline planogram, not the promo.
def grade_window(win: CampaignWindow, obs: PromoObservation) -> dict:
"""Grade one promotional capture against its scheduled compliance window."""
if obs.fixture_id != win.fixture_id:
raise ValueError(
f"observation {obs.fixture_id} does not match window {win.fixture_id}"
)
phase = win.phase(obs.capture_timestamp)
present_and_correct = obs.promo_present and obs.sku_correct
if phase is WindowPhase.ACTIVE:
state: WindowState = "compliant" if present_and_correct else "missing_promo"
elif phase in (WindowPhase.SETUP_GRACE, WindowPhase.TEARDOWN_GRACE):
# inside the buffer: a present build is fine, an absent one is a non-event
state = "compliant" if present_and_correct else "inactive"
elif phase is WindowPhase.BEFORE_SETUP:
state = "early_set" if obs.promo_present else "inactive"
else: # AFTER_TEARDOWN
state = "late_teardown" if obs.promo_present else "inactive"
return {
"fixture_id": obs.fixture_id,
"planogram_id": win.planogram_id,
"registry_revision": win.registry_revision,
"capture_timestamp": obs.capture_timestamp.astimezone(timezone.utc).isoformat(),
"window_phase": phase.value,
"window_state": state,
"penalized": state in {"early_set", "late_teardown", "missing_promo"},
}The inactive state is deliberate and load-bearing: it is the verdict for every capture that the promotional rule does not govern — a bare fixture before or after the campaign, or an empty grace buffer. It carries penalized: False, so downstream reporting can drop it without it ever touching a compliance_percentage.
Step 4 — Emit Distinct States and Wire the Batch Path Jump to heading
The final step is a thin batch wrapper that grades a stream of captures and keeps the distinct states intact rather than collapsing them to a boolean. The four penalized-or-clean states — compliant, early_set, late_teardown, missing_promo — are what make the output actionable: an early_set store gets a note, a late_teardown store gets a reset task, and a missing_promo during the paid window is the one that feeds a vendor conversation. These states surface directly in the morning reset briefings assembled by Category Manager Briefing Automation, which is why the enum must stay stable across the batch.
def grade_batch(
windows: dict[str, CampaignWindow], observations: list[PromoObservation]
) -> list[dict]:
"""Grade many captures, skipping any fixture with no scheduled campaign."""
graded: list[dict] = []
for obs in observations:
win = windows.get(obs.fixture_id)
if win is None:
# no active campaign for this fixture -> the promo gate does not apply
continue
try:
graded.append(grade_window(win, obs))
except ValueError as exc:
graded.append({
"fixture_id": obs.fixture_id,
"window_state": "error",
"detail": str(exc),
})
return graded
if __name__ == "__main__":
win = CampaignWindow(
planogram_id="PROMO-2026-SUMMER-ENDCAP-07",
fixture_id="STORE-0421-ENDCAP-A3",
effective_start=datetime(2026, 6, 22, tzinfo=timezone.utc),
effective_end=datetime(2026, 7, 5, 23, 59, 59, tzinfo=timezone.utc),
registry_revision=12,
grace=timedelta(hours=12),
)
captures = [
PromoObservation("STORE-0421-ENDCAP-A3",
datetime(2026, 6, 19, 8, tzinfo=timezone.utc), True, True),
PromoObservation("STORE-0421-ENDCAP-A3",
datetime(2026, 6, 28, 8, tzinfo=timezone.utc), True, True),
PromoObservation("STORE-0421-ENDCAP-A3",
datetime(2026, 6, 28, 8, tzinfo=timezone.utc), False, False),
PromoObservation("STORE-0421-ENDCAP-A3",
datetime(2026, 7, 8, 8, tzinfo=timezone.utc), True, True),
]
for row in grade_batch({win.fixture_id: win}, captures):
print(f"{row['capture_timestamp']} {row['window_phase']:>15} "
f"{row['window_state']}")Verification & Testing Jump to heading
Confirm each rule deterministically rather than trusting a launch-day dashboard:
- A capture before the window is not penalized. Grade a
capture_timestampwell beforeeffective_start - gracewithpromo_present=Falseand assert the state isinactiveandpenalizedisFalse. A bare fixture ahead of the campaign must never move the compliance score. - Early setup past the grace buffer is flagged. Grade the same pre-window timestamp with
promo_present=Trueand assertearly_set. Then move the timestamp toeffective_start - grace + timedelta(minutes=1)and assert it flips tocompliant, proving the buffer boundary is inclusive. - Missing promo during the active window is flagged. Grade a mid-window capture with
promo_present=Falseand assertmissing_promowithpenalizedtrue, even though the same absence one day later gradesinactive. - Late teardown is flagged, but the grace period is honored. Grade a present build at
effective_end + grace - timedelta(hours=1)and assertcompliant; move it toeffective_end + grace + timedelta(hours=1)and assert it flips tolate_teardown. The two asserts together pin the teardown boundary. - Naive timestamps are rejected. Pass a
datetimewith notzinfoand assertphaseraisesValueErrorrather than silently comparing against a UTC window.
A healthy run over a real campaign shows compliant dominating the active window, early_set and late_teardown confined to a thin band at the edges, and inactive covering the long baseline stretches — with no promotional penalty ever landing on a capture taken outside the campaign.
Troubleshooting Jump to heading
| Symptom | Likely root cause | Remediation |
|---|---|---|
Whole regions grade late_teardown or early_set an hour off, seasonally |
capture_timestamp stamped in local time and compared against a UTC window across a DST change |
Stamp captures in UTC at the edge and normalize with astimezone(timezone.utc) before comparing; never store naive local times |
| Genuine early builds and late strikes never get flagged | grace set too wide, so real offenders fall inside the buffer |
Tighten grace toward the operational build time (a few hours, not days) and reconcile the boundary against audited reset logs |
One fixture grades compliant and unauthorized for two campaigns at once |
Overlapping campaigns share a fixture_id and the batch picks one window arbitrarily |
Key the calendar by fixture_id plus active interval and resolve the window whose range contains capture_timestamp, not the first match |
| States look right today but wrong when re-scored later | Capture reconciled against the current calendar after an effective_end edit |
Pin each grade to the row whose registry_revision was live at capture, so window edits never rewrite history |
| Late-teardown counts spike only on reset mornings | Timestamps stamped at ingest, so a queue backlog shifts captures past effective_end |
Move stamping to capture time at the edge and partition the scoring queue by store_id to preserve ordering |
Related Jump to heading
- Promotional Display Alignment Checks — the parent stage that detects the display and produces the presence signal this gate times
- Position Validation Algorithms for Planograms — why promotional geometry routes through a separate profile while only its timing is decided here
- Category Manager Briefing Automation — where the
early_set,late_teardown, andmissing_promostates surface as reset tasks