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:
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:
where each is the health-risk weight and each 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 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 , 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_termis 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
ZeroDivisionErroror nonsense ratio, the reading was routed through the concentration path; check thatpresence_based=Trueis set and thedetectedflag 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.
Related
- Severity Scoring Models — the parent section that frames how scores are assembled and consumed
- Scoring Violations by Population and Exposure Duration — the exposure factor that multiplies with this health-risk term
- Public Notification Workflows for SDWA Violations — where the finished score selects a notification tier
- Violation Detection & Rule Engine Logic — the surrounding rule engine this scoring model plugs into