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 observations at a fixed cadence and arrived, the completeness ratio is
The classifier then applies a two-level threshold rule against . Let be the operational floor (the completeness a utility wants for confident trending) and be the regulatory minimum implied by the monitoring rule for that parameter, with :
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.
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 is dictated by the applicable monitoring rule and differs by parameter.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
tau_reg |
float | 0.90 |
Regulatory-minimum completeness ; below this a window is a reporting violation |
tau_op |
float | 0.95 |
Operational-floor completeness ; below this a window is flagged at-risk |
required |
int | — | Expected observation count for the window, from the sample calendar |
present |
int | — | Observations actually received, |
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, , no missed sample | Log only |
AT_RISK |
, or unplanned but above floor | Operator warning |
REPORTING_VIOLATION |
Missed required sample, or | 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_causelabels 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
fulfilledflag 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 and an empty window sets 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 is set too close to the regulatory minimum. Widen the band or route
AT_RISKverdicts through the deduplicating logic used when handling missing sensor readings without triggering false violations.
Related
- Monitoring Gap Detection Algorithms — the parent section that produces the gap intervals classified here
- Handling Missing Sensor Readings Without Triggering False Violations — the detection-side counterpart that suppresses false gaps before classification
- Monitoring Frequency Scheduling — where the required-sample calendar and expected observation counts originate
- Severity Scoring Models — consumes the impact class to rank confirmed violations
- Violation Detection & Rule Engine Logic — the parent section for the full detection and rule-engine architecture