Configuring Operational Warning Bands Below MCLs
A maximum contaminant level is a legal ceiling fixed in regulation, not a control setpoint, and the moment a treated-water value actually touches it a utility is already in a reportable condition. The engineering goal on this page, part of the parent Threshold Tuning Frameworks section, is to give operators a margin of reaction time by computing an internal warning limit that sits a deliberate distance below the fixed MCL — a soft boundary that fires an early advisory while the hard regulatory boundary stays exactly where the Code of Federal Regulations puts it. The distinction matters more than it first appears: the warning band is a private operational construct that shapes staffing, sampling cadence, and process adjustment, whereas the MCL is the number that ultimately drives an exceedance determination in the MCL Exceedance Logic Implementation section. Confusing the two — nudging the compliance threshold to buy comfort, or treating a warning trip as a violation — corrupts both the process signal and the regulatory record. This page builds a small, config-driven engine that keeps them cleanly separated.
Prerequisites & Environment Setup
The implementation targets Python 3.10+ and leans on pydantic for typed, validated configuration so that a mistuned band is rejected at load time rather than discovered during an incident. Everything else is standard library. If you are wiring the classifier into a larger evaluation service, pandas is handy for scoring a column of historian readings in one pass, but it is optional for the core logic.
Warning bands are configuration, not code. The regulatory MCL for a given analyte should be sourced from the canonical table maintained in the SDWA MCL Reference Mapping section, and only the band coefficient — the fraction or the absolute margin — belongs to the operational team that owns the process. Keeping those two inputs in separate places is what guarantees an operator can never silently move a compliance limit while tuning a warning.
python3 -m venv .venv && source .venv/bin/activate
pip install "pydantic==2.7.*"
# Optional: vectorized scoring of historian columns
pip install "pandas==2.2.*"
Step-by-Step Implementation
Step 1 — Define the band arithmetic
A warning band is a single scalar distance below the MCL, expressed one of two ways. The fractional form scales the ceiling by a coefficient:
so a coefficient of 0.8 against a 0.010 mg/L lead action level yields a warning limit of 0.008 mg/L. The absolute form subtracts a fixed engineering margin in the analyte’s own units:
which suits contaminants where a proportional band is clumsy — a flat 2 mg/L cushion under a 10 mg/L nitrate MCL, for instance. Both forms must land strictly between zero and the MCL; a band at or above the ceiling is meaningless, and a negative band is a configuration error. Choosing between the two is a domain decision, but the constraint is universal: .
Step 2 — Model the configuration with validation
The configuration object accepts the MCL plus exactly one of the two band specifiers, computes the derived warning limit once, and refuses to construct if the result violates the bound. Enforcing “exactly one” at load time removes an entire class of ambiguous states.
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field, model_validator
class Zone(str, Enum):
NORMAL = "NORMAL"
WARNING = "WARNING"
VIOLATION = "VIOLATION"
class WarningBand(BaseModel):
"""Operational warning band for a single upper-bound MCL analyte.
The MCL is the fixed regulatory ceiling and is never modified.
Provide EITHER a fractional coefficient k (0 < k < 1) OR an
absolute margin m (in the analyte's units); the warning limit
L_w is derived and frozen at construction time.
"""
analyte: str
mcl: float = Field(gt=0, description="Fixed regulatory ceiling; never mutated")
k: float | None = Field(default=None, gt=0, lt=1)
margin: float | None = Field(default=None, gt=0)
@model_validator(mode="after")
def _resolve_band(self) -> "WarningBand":
if (self.k is None) == (self.margin is None):
raise ValueError("Specify exactly one of 'k' or 'margin'")
if self.k is not None:
self._warning_limit = self.k * self.mcl
else:
self._warning_limit = self.mcl - self.margin
if not (0.0 < self._warning_limit < self.mcl):
raise ValueError(
f"Warning limit {self._warning_limit} must fall strictly "
f"between 0 and the MCL {self.mcl}"
)
return self
@property
def warning_limit(self) -> float:
return self._warning_limit
Because _warning_limit is set inside the validator and exposed only through a read-only property, downstream code cannot reach in and shift the boundary after load. The MCL field is validated as strictly positive and is never written to again.
Step 3 — Classify a reading into a zone
The evaluator maps a single measurement onto one of three zones. A reading at or above the MCL is a VIOLATION — the classifier reports the regulatory fact but does not itself raise a public notice; that dispatch belongs to the Public Notification Workflows section. A reading in the interval between the warning limit and the MCL is a WARNING, and anything below the warning limit is NORMAL. The >= boundary at the MCL is deliberate: touching the ceiling is a breach, not a near miss.
from dataclasses import dataclass
@dataclass(frozen=True)
class ZoneResult:
analyte: str
value: float
zone: Zone
warning_limit: float
mcl: float
def classify(band: WarningBand, value: float) -> ZoneResult:
"""Place a single reading into NORMAL / WARNING / VIOLATION.
The MCL comparison is inclusive: value >= MCL is a VIOLATION.
The warning band is a soft, internal boundary only.
"""
if value >= band.mcl:
zone = Zone.VIOLATION
elif value >= band.warning_limit:
zone = Zone.WARNING
else:
zone = Zone.NORMAL
return ZoneResult(
analyte=band.analyte,
value=value,
zone=zone,
warning_limit=band.warning_limit,
mcl=band.mcl,
)
The three zones and their two boundaries are shown below.
Step 4 — Load bands from configuration and score in bulk
In production the bands are declared as data — one entry per analyte — and hydrated into WarningBand objects at startup. Scoring a batch of historian readings then becomes a straightforward map over rows, which slots naturally into the batch evaluation that precedes any escalation decision.
import pandas as pd
BAND_CONFIG = [
{"analyte": "nitrate_mg_l", "mcl": 10.0, "margin": 2.0},
{"analyte": "lead_mg_l", "mcl": 0.015, "k": 0.8},
{"analyte": "tthm_ug_l", "mcl": 80.0, "k": 0.85},
]
BANDS = {c["analyte"]: WarningBand(**c) for c in BAND_CONFIG}
def score_readings(frame: pd.DataFrame) -> pd.DataFrame:
"""Score a long-format frame with columns [analyte, value]."""
out = frame.copy()
out["zone"] = [
classify(BANDS[row.analyte], row.value).zone.value
for row in frame.itertuples(index=False)
]
return out
if __name__ == "__main__":
sample = pd.DataFrame(
[
("nitrate_mg_l", 6.4),
("nitrate_mg_l", 8.5),
("nitrate_mg_l", 10.2),
("lead_mg_l", 0.013),
],
columns=["analyte", "value"],
)
print(score_readings(sample).to_string(index=False))
The nitrate rows illustrate the three outcomes cleanly: 6.4 mg/L is NORMAL, 8.5 mg/L crosses the 8.0 mg/L warning limit into WARNING while remaining fully compliant, and 10.2 mg/L is a VIOLATION. The lead reading at 0.013 mg/L sits above its 0.8 × 0.015 = 0.012 warning limit, so it trips a WARNING even though it is comfortably under the action level.
Configuration Reference
| Parameter | Type | Constraint | Meaning |
|---|---|---|---|
analyte |
str | required | Stable key matching the MCL reference table |
mcl |
float | > 0 |
Fixed regulatory ceiling; read-only after load |
k |
float | 0 < k < 1 |
Fractional coefficient; warning limit is k · MCL |
margin |
float | 0 < m < MCL |
Absolute cushion; warning limit is MCL − m |
warning_limit |
float (derived) | 0 < L_w < MCL |
Computed soft boundary, exposed read-only |
| Zone | Condition | Operational meaning |
|---|---|---|
NORMAL |
value < L_w |
Inside the comfortable operating envelope |
WARNING |
L_w ≤ value < MCL |
Advisory only; compliant but trending toward the ceiling |
VIOLATION |
value ≥ MCL |
Regulatory exceedance; hand off to escalation logic |
Exactly one of k or margin must be supplied per analyte. Supplying both, or neither, raises a validation error at construction rather than producing a silently wrong limit.
Verification & Testing
The behavior that must never regress is the independence of the two boundaries: tuning the band must move the warning limit without ever touching the MCL, and the MCL comparison must stay inclusive. The tests below pin the arithmetic and the zone edges.
import pytest
def test_fractional_band_derives_limit():
band = WarningBand(analyte="tthm_ug_l", mcl=80.0, k=0.85)
assert band.warning_limit == pytest.approx(68.0)
assert band.mcl == 80.0 # ceiling untouched
def test_absolute_margin_band():
band = WarningBand(analyte="nitrate_mg_l", mcl=10.0, margin=2.0)
assert band.warning_limit == pytest.approx(8.0)
def test_zone_edges_are_correct():
band = WarningBand(analyte="nitrate_mg_l", mcl=10.0, margin=2.0)
assert classify(band, 7.99).zone is Zone.NORMAL
assert classify(band, 8.0).zone is Zone.WARNING # inclusive lower edge
assert classify(band, 9.99).zone is Zone.WARNING
assert classify(band, 10.0).zone is Zone.VIOLATION # inclusive MCL
def test_rejects_both_specifiers():
with pytest.raises(ValueError):
WarningBand(analyte="x", mcl=10.0, k=0.8, margin=2.0)
def test_rejects_band_at_or_above_ceiling():
with pytest.raises(ValueError):
WarningBand(analyte="x", mcl=10.0, margin=10.0)
Acceptance criteria before promoting the band engine to production:
Troubleshooting & Gotchas
- The warning band creeping into the compliance record. The single most damaging mistake is to feed
warning_limitinto exceedance evaluation. Keep the soft boundary confined to advisory routing and let only themclvalue reach the MCL Exceedance Logic Implementation section. AVIOLATIONfrom the classifier and a formal exceedance determination should trace back to the identical CFR number. - Lower-bound analytes inverted. This engine assumes higher is worse, which fits most contaminants. For a minimum disinfectant residual, the danger is a value falling too low, so the comparisons and the band direction must be mirrored — warn above a floor, breach below it. Do not reuse the upper-bound classifier unchanged for those parameters.
- A band that never warns. If a coefficient like
k = 0.99is chosen for a noisy analyte, readings jitter across the warning limit constantly and operators tune it out. That is a deadband problem, not a band-placement problem; pair the band with the hysteresis described in tuning deadbands to suppress nuisance alarms so a single trip does not chatter. - Floating-point edge surprises. Values such as
0.8 * 0.015do not land on an exact binary fraction, so an equality check against a warning limit can behave unexpectedly. Compare with>=as the classifier does rather than==, and usepytest.approxin tests. - Units drifting between config and readings. A margin expressed in mg/L applied to a stream reported in µg/L produces a warning limit off by a factor of a thousand. Normalize every analyte to a single canonical unit at ingestion and store the unit alongside the MCL in the reference table.
Related
- Threshold Tuning Frameworks — the parent section covering how detection thresholds are chosen and tuned
- Tuning Deadbands to Suppress Nuisance Alarms — the companion technique that stabilizes a band once a reading sits near its edge
- MCL Exceedance Logic Implementation — where the fixed regulatory ceiling, and only the ceiling, drives a formal determination
- Violation Detection & Rule Engine Logic — the parent architecture these bands feed into