Classifying Monitoring Gaps by Regulatory Impact

A missing block of readings in a compliance dataset is not, by itself, a violation — its meaning depends entirely on why the data is absent, how long it lasted, and whether a required sample fell inside the gap. A three-hour outage during a planned analyzer calibration is an operational footnote; the same three hours spanning the only scheduled monthly bacteriological sample can become a monitoring-and-reporting violation under 40 CFR Part 141. This page sits inside the Monitoring Gap Detection Algorithms section and takes the raw gap intervals produced upstream, then decides what each one means to a regulator. Detecting a gap is arithmetic; classifying it correctly is where the Violation Detection & Rule Engine Logic earns its keep, because a mislabeled gap either buries a real reporting failure or floods operators with alerts for maintenance they scheduled themselves.

The classifier joins each detected gap against two authoritative context sources — the maintenance work-order log and the required-sample calendar — computes a data-completeness ratio for the affected monitoring window, and assigns one of three regulatory-impact classes. Three dimensions drive that verdict: the cause of the gap (an approved calibration or maintenance window versus an unannounced telemetry loss), its duration relative to the monitoring cadence, and its regulatory consequence — whether the interruption stayed operational or crossed into a monitoring-and-reporting violation. Duration matters because it is what erodes the completeness ratio: a gap shorter than a single sample interval is invisible to the numerator, while a multi-day telemetry loss can pull an entire monthly window below the regulatory floor even when no individual grab-sample deadline was missed. The output is a structured verdict that downstream severity scoring models and public notification workflows can act on without re-deriving intent.

Prerequisites & Environment Setup

The implementation targets Python 3.10+ and leans on pandas for interval joins and pydantic for validating the work-order and calendar records before they influence a compliance verdict. The gap intervals themselves are assumed to arrive as a list of start/end timestamps from the upstream detector; this page is concerned only with what happens after the gaps exist. Timestamps must already be timezone-aware UTC — mixing naive local time into an interval join is the single most common way to misclassify a gap that straddles a daylight-saving boundary.

You need three inputs before the classifier can run: the detected gaps, the work-order metadata (planned maintenance windows, each with a cause code and an affected point), and the required-sample calendar derived from the utility’s monitoring frequency scheduling. The calendar is what turns a duration into a regulatory consequence, because it tells you whether a mandatory sample deadline lived inside the gap.

python3 -m venv .venv && source .venv/bin/activate
pip install "pandas==2.2.*" "pydantic==2.7.*"
# Optional: pyarrow for reading the required-sample calendar from Parquet
pip install "pyarrow==16.*"

Step-by-Step Implementation

Step 1 — Model the inputs and the impact classes

Every gap will be resolved into exactly one of three classes. OPERATIONAL_ONLY means the gap is fully explained by an approved work order and no required sample was missed — it is logged but never escalates. AT_RISK means the completeness ratio dropped below the operational floor but the regulatory minimum is still intact, so it warrants an operator warning. REPORTING_VIOLATION means a mandatory sample deadline fell inside an unexplained gap, or completeness fell below the regulatory minimum — this is the class that must reach a compliance officer.

from __future__ import annotations

from datetime import datetime, timezone
from enum import Enum
from typing import Optional

import pandas as pd
from pydantic import BaseModel, field_validator


class ImpactClass(str, Enum):
    OPERATIONAL_ONLY = "OPERATIONAL_ONLY"
    AT_RISK = "AT_RISK"
    REPORTING_VIOLATION = "REPORTING_VIOLATION"


class GapCause(str, Enum):
    PLANNED_CALIBRATION = "PLANNED_CALIBRATION"
    PLANNED_MAINTENANCE = "PLANNED_MAINTENANCE"
    UNPLANNED_TELEMETRY_LOSS = "UNPLANNED_TELEMETRY_LOSS"


class WorkOrder(BaseModel):
    point_id: str
    start: datetime
    end: datetime
    cause: GapCause

    @field_validator("start", "end")
    @classmethod
    def must_be_utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("work-order timestamps must be timezone-aware UTC")
        return v.astimezone(timezone.utc)


class Gap(BaseModel):
    point_id: str
    start: datetime
    end: datetime

    @property
    def duration_minutes(self) -> float:
        return (self.end - self.start).total_seconds() / 60.0

Step 2 — Attribute each gap to a cause

The first join asks a single question: is this gap covered by an approved work order for the same monitoring point? A gap is treated as planned only when a work order for that point fully contains the gap interval. Partial overlap is deliberately not enough — if telemetry dropped an hour before the technician arrived, that leading hour is unplanned and must be treated as such. This strictness is what stops a legitimate calibration window from becoming a blanket excuse for surrounding data loss; the technician’s paperwork explains the minutes the analyzer was offline for service, not the unrelated network fault that happened to overlap it. The cause code carried on the work order (PLANNED_CALIBRATION versus PLANNED_MAINTENANCE) is preserved on the verdict so an auditor can later reconstruct exactly why a given interruption was ruled operational.

def attribute_cause(gap: Gap, work_orders: list[WorkOrder]) -> GapCause:
    """Return the planned cause only when a work order for the same point
    fully contains the gap; otherwise the gap is unplanned telemetry loss."""
    for wo in work_orders:
        if wo.point_id != gap.point_id:
            continue
        if wo.start <= gap.start and wo.end >= gap.end:
            return wo.cause
    return GapCause.UNPLANNED_TELEMETRY_LOSS

Step 3 — Compute the data-completeness ratio

Completeness is the fraction of expected observations that actually arrived over the monitoring window. If a window expects nrequiredn_{required} observations at a fixed cadence and npresentn_{present} arrived, the completeness ratio is

C=npresentnrequiredC = \frac{n_{present}}{n_{required}}

The classifier then applies a two-level threshold rule against CC. Let τop\tau_{op} be the operational floor (the completeness a utility wants for confident trending) and τreg\tau_{reg} be the regulatory minimum implied by the monitoring rule for that parameter, with τregτop\tau_{reg} \le \tau_{op}:

status(C)={okCτopat_riskτregC<τopviolationC<τreg\text{status}(C) = \begin{cases} \textsf{ok} & C \ge \tau_{op} \\ \textsf{at\_risk} & \tau_{reg} \le C < \tau_{op} \\ \textsf{violation} & C < \tau_{reg} \end{cases}

Expected counts come from the same cadence the monitoring frequency scheduling section defines, so a 15-minute continuous chlorine analyzer expects 96 readings per day while a grab-sample parameter may expect one per month.

def completeness_ratio(present: int, required: int) -> float:
    if required <= 0:
        raise ValueError("required observation count must be positive")
    return min(present / required, 1.0)

Step 4 — Assign the regulatory-impact class

The final step fuses cause, completeness, and the required-sample calendar into a single verdict. The overriding rule is the missed mandatory sample: if any required deadline fell strictly inside the gap and no valid substitute sample exists, the gap is a reporting violation regardless of how healthy the surrounding completeness looks — a single skipped coliform sample is a monitoring violation even at 99% completeness. This asymmetry reflects how 40 CFR Part 141 actually treats monitoring obligations: continuous-analyzer completeness and discrete sample deadlines are separate duties, and satisfying one does not cover a lapse in the other. Absent a missed deadline, the completeness thresholds and the planned/unplanned attribution decide the class, with an unplanned gap that still clears the operational floor landing in AT_RISK rather than being waved through, so that a creeping pattern of small telemetry losses surfaces before it compounds into a reportable shortfall.

def missed_required_sample(
    gap: Gap, sample_calendar: pd.DataFrame
) -> bool:
    """True if a required-sample deadline for this point falls inside the gap."""
    due = sample_calendar[sample_calendar["point_id"] == gap.point_id]
    inside = due[(due["due_at"] > gap.start) & (due["due_at"] < gap.end)]
    # A deadline is only "missed" if no fulfilled sample covers it.
    return bool((~inside["fulfilled"]).any())


def classify_gap(
    gap: Gap,
    work_orders: list[WorkOrder],
    sample_calendar: pd.DataFrame,
    present: int,
    required: int,
    tau_reg: float = 0.90,
    tau_op: float = 0.95,
) -> dict:
    cause = attribute_cause(gap, work_orders)
    completeness = completeness_ratio(present, required)
    planned = cause in (GapCause.PLANNED_CALIBRATION, GapCause.PLANNED_MAINTENANCE)

    if missed_required_sample(gap, sample_calendar):
        impact = ImpactClass.REPORTING_VIOLATION
    elif completeness < tau_reg:
        impact = ImpactClass.REPORTING_VIOLATION
    elif completeness < tau_op:
        impact = ImpactClass.AT_RISK
    elif planned:
        impact = ImpactClass.OPERATIONAL_ONLY
    else:
        # Unplanned but short and above the operational floor.
        impact = ImpactClass.AT_RISK

    return {
        "point_id": gap.point_id,
        "start": gap.start,
        "end": gap.end,
        "duration_minutes": round(gap.duration_minutes, 1),
        "cause": cause.value,
        "completeness": round(completeness, 4),
        "impact": impact.value,
    }

The decision order below is deliberate: the missed-sample check runs first because it can promote an otherwise healthy window straight to a violation, and the planned-cause check runs last because a work order can only downgrade an already-compliant gap, never excuse a missed deadline.

Decision flow assigning a monitoring gap to one of three regulatory-impact classes A gap enters three ordered tests. First, did a required sample deadline fall inside the gap; yes leads to Reporting Violation. Second, is completeness below the regulatory minimum; yes leads to Reporting Violation, and a value between the regulatory minimum and the operational floor leads to At Risk. Third, for completeness at or above the operational floor, coverage by a planned work order leads to Operational Only while an unplanned gap leads to At Risk. Detected gap Required sample missed? C < regulatory minimum? Covered by work order? REPORTING_VIOLATION AT_RISK OPERATIONAL_ONLY yes yes at_risk band yes no

Configuration Reference

The thresholds and cause codes below are the only knobs the classifier exposes. Store them as versioned configuration keyed by monitoring point, never as inline literals, because τreg\tau_{reg} is dictated by the applicable monitoring rule and differs by parameter.

Parameter Type Default Meaning
tau_reg float 0.90 Regulatory-minimum completeness τreg\tau_{reg}; below this a window is a reporting violation
tau_op float 0.95 Operational-floor completeness τop\tau_{op}; below this a window is flagged at-risk
required int Expected observation count nrequiredn_{required} for the window, from the sample calendar
present int Observations actually received, npresentn_{present}
PLANNED_CALIBRATION cause Approved analyzer calibration window; downgrades to operational-only
PLANNED_MAINTENANCE cause Approved maintenance window; downgrades to operational-only
UNPLANNED_TELEMETRY_LOSS cause No covering work order; cannot excuse a missed deadline
Impact class Trigger Downstream action
OPERATIONAL_ONLY Planned cause, CτopC \ge \tau_{op}, no missed sample Log only
AT_RISK τregC<τop\tau_{reg} \le C < \tau_{op}, or unplanned but above floor Operator warning
REPORTING_VIOLATION Missed required sample, or C<τregC < \tau_{reg} Route to compliance officer

Verification & Testing

The classifier’s contract is that a missed required sample always wins, and that a planned work order can only downgrade a healthy window. The test below pins both invariants with deterministic fixtures.

import pandas as pd
from datetime import datetime, timezone


def _dt(hour: int) -> datetime:
    return datetime(2026, 7, 17, hour, tzinfo=timezone.utc)


def _calendar(due_hour: int, fulfilled: bool) -> pd.DataFrame:
    return pd.DataFrame(
        [{"point_id": "CL2-01", "due_at": _dt(due_hour), "fulfilled": fulfilled}]
    )


def test_missed_sample_forces_violation_despite_high_completeness():
    gap = Gap(point_id="CL2-01", start=_dt(9), end=_dt(11))
    wo = [WorkOrder(point_id="CL2-01", start=_dt(9), end=_dt(11),
                    cause=GapCause.PLANNED_CALIBRATION)]
    result = classify_gap(gap, wo, _calendar(10, fulfilled=False),
                          present=99, required=100)
    assert result["impact"] == ImpactClass.REPORTING_VIOLATION.value


def test_planned_gap_with_no_missed_sample_is_operational_only():
    gap = Gap(point_id="CL2-01", start=_dt(9), end=_dt(11))
    wo = [WorkOrder(point_id="CL2-01", start=_dt(8), end=_dt(12),
                    cause=GapCause.PLANNED_MAINTENANCE)]
    result = classify_gap(gap, wo, _calendar(20, fulfilled=False),
                          present=96, required=96)
    assert result["impact"] == ImpactClass.OPERATIONAL_ONLY.value


def test_unplanned_gap_below_regulatory_minimum_is_violation():
    gap = Gap(point_id="CL2-01", start=_dt(0), end=_dt(6))
    result = classify_gap(gap, [], _calendar(20, fulfilled=True),
                          present=80, required=96)
    assert result["impact"] == ImpactClass.REPORTING_VIOLATION.value

Acceptance criteria before promoting the classifier to production:

Troubleshooting & Gotchas

  • Every planned maintenance gap still escalates. The work order does not fully contain the gap interval, so attribute_cause labels it unplanned. Check for clock skew between the maintenance system and the historian, and confirm the work order was closed with actual start/end times rather than the scheduled ones.
  • A short outage is flagged as a violation at high completeness. A required sample deadline fell inside the gap. This is correct behavior — a missed mandatory sample is a monitoring violation on its own — but confirm the sample calendar’s fulfilled flag is updated when a make-up sample is collected, or the gap will stay flagged after the utility has remediated it.
  • Completeness reads above 1.0 or divides by zero. Duplicate readings inflate npresentn_{present} and an empty window sets nrequiredn_{required} to zero. The min(..., 1.0) clamp and the positive-count guard handle both, but the real fix is de-duplicating on the timestamp key before counting.
  • Daylight-saving boundaries shift a gap across a sample deadline. A naive local timestamp in the calendar can place a deadline just outside a gap that actually contained it. Normalize every input to UTC at ingestion so the interval comparison is unambiguous.
  • Alert fatigue from at-risk warnings. If unplanned-but-short gaps dominate the queue, the operational floor τop\tau_{op} is set too close to the regulatory minimum. Widen the band or route AT_RISK verdicts through the deduplicating logic used when handling missing sensor readings without triggering false violations.