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.

Campaign timeline: capture_timestamp mapped to a compliance-window state Time runs left to right. A neutral baseline segment precedes a narrow setup-grace buffer that ends at valid_from, then the active promotional window runs to valid_to, followed by a teardown-grace buffer of equal width and a return to baseline. Vertical dashed lines mark valid_from and valid_to, with fainter lines marking start minus grace and end plus grace. Five capture markers sit above the band. A filled marker before the setup-grace buffer, where the promo is present, is graded early-set and flagged. A filled marker inside the active window, present and correct, is graded compliant. A hollow marker inside the active window, where the promo is absent, is graded missing-promo and flagged. A filled marker inside the teardown-grace buffer is graded compliant because the grace period is honored. A filled marker after teardown grace, where the promo is still up, is graded late-teardown and flagged. Captures that fall inside either grace buffer are never penalized. Campaign timeline · capture_timestamp mapped to a window state time baseline active window promo present + correct baseline setup grace teardown grace valid_from valid_to start − grace end + grace present · flagged early-set present · ok compliant absent · flagged missing-promo present · within grace compliant present · flagged late-teardown detected present absent a capture inside either grace buffer is never penalized — only presence before setup grace or after teardown grace is flagged

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 is datetime arithmetic — 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_start and effective_end in UTC, keyed by planogram_id and fixture_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_timestamp on 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 convert late_teardown into compliant.
  • 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_revision on 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_TEARDOWN

Normalizing 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.ACTIVE

The 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:

  1. A capture before the window is not penalized. Grade a capture_timestamp well before effective_start - grace with promo_present=False and assert the state is inactive and penalized is False. A bare fixture ahead of the campaign must never move the compliance score.
  2. Early setup past the grace buffer is flagged. Grade the same pre-window timestamp with promo_present=True and assert early_set. Then move the timestamp to effective_start - grace + timedelta(minutes=1) and assert it flips to compliant, proving the buffer boundary is inclusive.
  3. Missing promo during the active window is flagged. Grade a mid-window capture with promo_present=False and assert missing_promo with penalized true, even though the same absence one day later grades inactive.
  4. Late teardown is flagged, but the grace period is honored. Grade a present build at effective_end + grace - timedelta(hours=1) and assert compliant; move it to effective_end + grace + timedelta(hours=1) and assert it flips to late_teardown. The two asserts together pin the teardown boundary.
  5. Naive timestamps are rejected. Pass a datetime with no tzinfo and assert phase raises ValueError rather 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
Back to top