Weighting Contaminant Health Risk in Severity Scores

Two violations that both breach a maximum contaminant level are rarely equal in consequence: a nitrate spike can sicken a bottle-fed infant within a single day, while an arsenic exceedance expresses its harm over decades of cumulative dose. A useful severity number has to encode that asymmetry rather than treat every parts-per-billion the same, and that is the specific engineering task on this page within the parent Severity Scoring Models section. Here we build a per-contaminant health-risk weight table, separate acute hazards from chronic ones, and fold those weights together with an exceedance magnitude ratio into a single reproducible score. That score is the input that the Public Notification Workflows module reads to decide which notification tier fires, and it composes directly with the population-and-duration term developed in scoring violations by population and exposure duration. The raw exceedance itself is produced upstream by the MCL Exceedance Logic Implementation rules; our job is to grade it.

Prerequisites & Environment Setup

The scoring library targets Python 3.10+ and leans on pydantic v2 to make the weight table a validated, serializable configuration object rather than a scatter of dictionaries. Everything else is standard library. Keep the health-risk table under version control alongside the MCL reference values it depends on, because auditors will want to see exactly which weight was in force on the day a score was computed.

python3 -m venv .venv && source .venv/bin/activate
pip install "pydantic==2.7.*"
# Optional: pandas for scoring a batch of readings at once
pip install "pandas==2.2.*"

You also need one non-code artifact before the first weight is meaningful: an agreed risk-weight rubric. The numeric weights below are defensible defaults grounded in EPA’s acute-versus-chronic health-effect language under 40 CFR Part 141, but the exact values are a policy decision your compliance lead should ratify and sign. Treat the table as calibration, not physics — record who approved each weight and when.

Step-by-Step Implementation

Step 1 — Model the health-risk weight table

Each contaminant carries three things the score needs: its regulatory limit, whether its dominant health effect is acute or chronic, and a normalized health-risk weight in the closed interval [0, 1]. Acute hazards — nitrate, nitrite, and the microbial indicators — earn weights near the top of the range because a single non-compliant sample implies immediate risk. Chronic hazards such as arsenic and total trihalomethanes (TTHM) earn lower weights, reflecting that their harm accrues from long-term average exposure rather than one bad reading. Modeling this with pydantic gives us free validation: an out-of-range weight or a non-positive MCL fails loudly at load time instead of silently corrupting every downstream score.

from __future__ import annotations

from enum import Enum

from pydantic import BaseModel, Field


class RiskClass(str, Enum):
    ACUTE = "acute"      # harm from a single/short exposure (hours-days)
    CHRONIC = "chronic"  # harm from cumulative exposure (years)


class ContaminantRisk(BaseModel):
    """A single row of the health-risk weight table."""

    name: str
    mcl: float = Field(gt=0, description="Maximum Contaminant Level, mg/L")
    risk_class: RiskClass
    weight: float = Field(ge=0.0, le=1.0, description="Normalized health-risk weight")
    presence_based: bool = Field(
        default=False,
        description="True for microbial indicators with no numeric concentration ratio",
    )


# Defaults grounded in 40 CFR Part 141 acute/chronic health-effect language.
RISK_TABLE: dict[str, ContaminantRisk] = {
    row.name: row
    for row in [
        ContaminantRisk(name="nitrate", mcl=10.0, risk_class=RiskClass.ACUTE, weight=0.95),
        ContaminantRisk(name="nitrite", mcl=1.0, risk_class=RiskClass.ACUTE, weight=0.90),
        ContaminantRisk(
            name="ecoli", mcl=1.0, risk_class=RiskClass.ACUTE, weight=1.00,
            presence_based=True,
        ),
        ContaminantRisk(name="arsenic", mcl=0.010, risk_class=RiskClass.CHRONIC, weight=0.60),
        ContaminantRisk(name="tthm", mcl=0.080, risk_class=RiskClass.CHRONIC, weight=0.50),
        ContaminantRisk(name="lead", mcl=0.015, risk_class=RiskClass.CHRONIC, weight=0.70),
    ]
}

Because the table is keyed on the same contaminant identifiers used when you map EPA MCLs to relational database schemas, a weight lookup and an MCL lookup resolve against one canonical key, which keeps the two tables from drifting apart.

Step 2 — Convert a measurement into an exceedance ratio

The magnitude of a violation is captured by a dimensionless exceedance ratio — the measured concentration divided by the contaminant’s MCL:

xi=CiMCLix_i = \frac{C_i}{\text{MCL}_i}

A ratio of 1.0 sits exactly on the limit; 2.0 means double the limit. Feeding the raw ratio straight into the score lets a single grossly out-of-range sensor dominate everything, so we pass it through a bounded logarithmic transform that keeps large exceedances influential without letting them run away. Values at or below the limit contribute zero — they are not violations and must not accumulate severity.

import math


def exceedance_ratio(measured: float, mcl: float) -> float:
    """Dimensionless measured/MCL ratio; >= 1.0 indicates an exceedance."""
    if mcl <= 0:
        raise ValueError("MCL must be positive")
    return measured / mcl


def magnitude_term(ratio: float) -> float:
    """
    Bounded transform of the exceedance ratio.
    At or below the MCL -> 0.0 (no violation contribution).
    Just above the MCL  -> ~1.0, growing sublinearly with log10(ratio).
    """
    if ratio <= 1.0:
        return 0.0
    return 1.0 + math.log10(ratio)

Step 3 — Combine weight and magnitude into a severity score

The per-contaminant severity multiplies the health-risk weight by the magnitude term, and the overall severity is the sum across every contaminant in the violation event:

S=iwixiS = \sum_{i} w_i \, x_i

where each wiw_i is the health-risk weight and each xix_i is the bounded magnitude term from Step 2. Presence-based microbial indicators have no meaningful concentration ratio — a positive E. coli result is a binary regulatory fact — so we substitute a fixed magnitude of 1.0 for any detection and let the weight alone carry the risk. The flow from measurement to notification tier is shown below.

From measurement and health-risk weight to a severity score and notification tier Measured concentration and MCL form an exceedance ratio, bounded into a magnitude term. The health-risk weight table contributes an acute or chronic weight. Weight times magnitude gives a per-contaminant severity; the sum is the overall score, which selects a notification tier. Measured Cᵢ MCLᵢ Exceedance ratio Magnitude term Weight wᵢ (acute/chronic) Σ wᵢ · xᵢ = S Notification tier
from dataclasses import dataclass


@dataclass(frozen=True)
class Reading:
    contaminant: str
    measured: float          # mg/L; ignored for presence-based indicators
    detected: bool = False   # used only when presence_based is True


def contaminant_severity(reading: Reading, table: dict[str, ContaminantRisk]) -> float:
    risk = table[reading.contaminant]
    if risk.presence_based:
        magnitude = 1.0 if reading.detected else 0.0
    else:
        magnitude = magnitude_term(exceedance_ratio(reading.measured, risk.mcl))
    return risk.weight * magnitude


def severity_score(readings: list[Reading], table: dict[str, ContaminantRisk]) -> float:
    """Overall health-risk-weighted severity S for a violation event."""
    return round(sum(contaminant_severity(r, table) for r in readings), 4)

A nitrate reading of 20 mg/L (ratio 2.0) scores 0.95 * (1 + log10(2)) ≈ 1.24, while an arsenic reading of 0.020 mg/L (also ratio 2.0) scores 0.60 * (1 + log10(2)) ≈ 0.78. Identical exceedance magnitude, materially different severity — exactly the acute-versus-chronic asymmetry the model is built to express. Keeping the aggregation a plain sum has a practical payoff: a multi-contaminant event, such as a distribution-system sample that trips both nitrate and E. coli, accumulates the concern of each hazard rather than reporting only the worst single reading, and the resulting total remains a single comparable number across every system in your inventory.

Configuration Reference

The health-risk table is the tunable surface of the model. Keep these fields in your version-controlled configuration and change weights only through a reviewed pull request.

Contaminant MCL (mg/L) Risk class Default weight Notes
nitrate 10.0 acute 0.95 Methemoglobinemia risk to infants; single-sample hazard
nitrite 1.0 acute 0.90 Same mechanism as nitrate, lower limit
ecoli 1.0 acute 1.00 Presence-based; any detection is a violation
arsenic 0.010 chronic 0.60 Cumulative carcinogen; running-average driven
tthm 0.080 chronic 0.50 Disinfection by-product; locational annual average
lead 0.015 chronic 0.70 Action level; neurotoxic, weighted above other chronics
Parameter Type Default Meaning
weight float Health-risk weight wiw_i, constrained to [0, 1]
risk_class enum acute or chronic; documentation and reporting facet
presence_based bool False Substitutes a fixed magnitude of 1.0 on detection
magnitude cap float 1 + log10(ratio) Bounded transform limiting runaway exceedances

Verification & Testing

The score must be deterministic and must preserve the acute-over-chronic ordering for equal exceedance ratios. The test below pins both properties.

import math

from myscoring import RISK_TABLE, Reading, severity_score, contaminant_severity


def test_equal_ratio_acute_outranks_chronic():
    nitrate = Reading("nitrate", measured=20.0)   # ratio 2.0
    arsenic = Reading("arsenic", measured=0.020)  # ratio 2.0
    assert contaminant_severity(nitrate, RISK_TABLE) > contaminant_severity(
        arsenic, RISK_TABLE
    )


def test_at_or_below_mcl_contributes_zero():
    on_limit = Reading("arsenic", measured=0.010)  # ratio exactly 1.0
    assert severity_score([on_limit], RISK_TABLE) == 0.0


def test_presence_based_indicator_scores_on_detection():
    positive = Reading("ecoli", detected=True)
    negative = Reading("ecoli", detected=False)
    assert severity_score([positive], RISK_TABLE) == 1.0
    assert severity_score([negative], RISK_TABLE) == 0.0


def test_score_is_additive_across_contaminants():
    combo = [Reading("nitrate", 20.0), Reading("arsenic", 0.020)]
    expected = 0.95 * (1 + math.log10(2)) + 0.60 * (1 + math.log10(2))
    assert severity_score(combo, RISK_TABLE) == round(expected, 4)

Acceptance criteria before the score drives any notification decision:

Troubleshooting & Gotchas

  • Chronic contaminants score too low to ever notify. TTHM and arsenic are governed by a running annual average, not a grab sample, so feeding a single high reading understates their real regulatory weight. Compute the exceedance ratio from the locational running annual average rather than an instantaneous value before scoring, or the model will mis-rank a genuine chronic violation.
  • A single sensor glitch dominates the whole score. Without the bounded magnitude transform, one reading at 500× the MCL swamps every other contaminant. Confirm magnitude_term is applied — never the raw ratio — and validate the reading against the exceedance rules upstream so a stuck-high sensor is flagged rather than scored.
  • Presence-based indicators throw a division error. E. coli and total coliform have no numeric concentration to divide by an MCL. If you see a ZeroDivisionError or nonsense ratio, the reading was routed through the concentration path; check that presence_based=True is set and the detected flag is populated.
  • Two runs produce different scores from the same data. Almost always an unpinned weight table. Serialize the table version (a content hash works well) into every stored score so a later audit can reproduce the exact number, and treat any weight change as a new table revision rather than an in-place edit.
  • Weights fight the population term. This model deliberately excludes how many people were exposed and for how long. If severe-but-tiny systems out-rank widespread ones, you are missing the second factor — combine this score with the exposure term from the sibling page before ranking, not instead of it.