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:

Lw=kMCL,0<k<1L_w = k \cdot \text{MCL}, \quad 0 < k < 1

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:

Lw=MCLm,0<m<MCLL_w = \text{MCL} - m, \quad 0 < m < \text{MCL}

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: 0<Lw<MCL0 < L_w < \text{MCL}.

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.

Three operational zones below and at a fixed MCL A horizontal concentration axis divided by two boundaries. The NORMAL zone runs from zero up to the warning limit L_w. The WARNING zone spans from L_w up to the MCL. The VIOLATION zone begins at the MCL and continues rightward. L_w is derived as k times the MCL and always sits strictly below the fixed regulatory MCL. 0 concentration → NORMAL WARNING VIOLATION Lₛ = k·MCL warning limit (soft) MCL fixed · regulatory

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_limit into exceedance evaluation. Keep the soft boundary confined to advisory routing and let only the mcl value reach the MCL Exceedance Logic Implementation section. A VIOLATION from 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.99 is 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.015 do 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 use pytest.approx in 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.