# EPA UCMR 5 PFAS and lithium results monitor (`keepable_injera/ucmr5-pfas-results-monitor`) Actor

Monitor EPA UCMR 5 drinking water sample results for PFAS and lithium. Filter by state, water system, contaminant, concentration, and collection date, and detect new or changed results.

- **URL**: https://apify.com/keepable_injera/ucmr5-pfas-results-monitor.md
- **Developed by:** [Chee Chiaw Lim](https://apify.com/keepable_injera) (community)
- **Categories:** Open source, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## EPA UCMR 5 PFAS and lithium results monitor

Collect analytical results from the [US EPA UCMR 5 occurrence dataset](https://www.epa.gov/dwucmr/occurrence-data-unregulated-contaminant-monitoring-rule). Each item represents one contaminant result from one public water system sample. Filters select states, water systems, contaminants, detected results, concentrations, and collection dates.

The actor downloads the compressed archive to a temporary file and streams its tab-separated entries. It does not load the uncompressed results file into memory. Latin-1 decoding preserves the source units, `µg/L`. Water system ZIP codes come from `UCMR5_ZIPCodes.txt` when available.

### Input

An empty input returns up to 100 detected results across all states and contaminants.

| Field | Default | Meaning |
| --- | --- | --- |
| `states` | `[]` | Two-letter postal codes, such as `["CA", "NJ"]`. Empty means all states. |
| `pwsIds` | `[]` | Exact public water system IDs. Empty means all systems. |
| `contaminants` | `[]` | Names such as `["PFOA", "PFOS"]`. Empty includes PFAS and lithium. |
| `detectsOnly` | `true` | Keep results with EPA's `=` detection sign. Set false to include `<` non-detects. |
| `minValue` | omitted | Minimum concentration in source units, `µg/L`. |
| `collectedAfter` | omitted | Collection date after an ISO date, such as `2024-01-01`. |
| `onlyNew` | `false` | Set true to emit only new and changed results. |
| `maxItems` | `100` | Maximum emitted results, from 1 to 10000. |
| `stateStoreName` | `ucmr5-pfas-results-monitor-state` | Named key-value store for result history. |

Filters combine across categories. `maxItems` limits emitted results, not rows scanned. A narrow filter or a run with `onlyNew=true` may scan the full file.

### Output

This example uses a detected PFOA result from the supplied EPA sample, joined to its service area ZIP code.

```json
{
	"pwsId": "010106001",
	"pwsName": "Mashantucket Pequot Water System",
	"systemSize": "L",
	"facilityId": "00006",
	"facilityName": "MPTN WTP",
	"facilityWaterType": "GU",
	"samplePointId": "TP1",
	"samplePointName": "Entry point to Dist. System",
	"samplePointType": "EP",
	"collectionDate": "2023-09-27",
	"sampleId": "810-79458-1",
	"contaminant": "PFOA",
	"detected": true,
	"resultValue": 0.0074,
	"units": "µg/L",
	"mrl": 0.004,
	"methodId": "EPA 533",
	"sampleEventCode": "SE1",
	"monitoringRequirement": "AM",
	"epaRegion": 1,
	"state": null,
	"stateCode": "01",
	"exceedsMrl": true,
	"pfasMcl": 4,
	"aboveMcl": true,
	"zipCodes": ["06338"],
	"source": "US EPA UCMR 5 occurrence data (https://www.epa.gov/system/files/other-files/2023-08/ucmr5-occurrence-data.zip)",
	"licence": "US Government work (17 U.S.C. 105)",
	"retrievedAt": "2026-10-22T00:00:00.000Z",
	"changeType": "new",
	"generatedId": "810-79458-1:PFOA:TP1:010106001"
}
```

`resultValue` is null when the source has no numeric result. A non-detect is not zero. `mrl` is the source minimum reporting level in `µg/L`. `exceedsMrl` is true only when a detected value is strictly greater than that reporting level. ZIP codes are strings so leading zeros survive.

The source `State` column contains postal abbreviations and numeric tribal region codes. The [EPA data dictionary](https://nepis.epa.gov/Exe/ZyPURL.cgi?Dockey=P1018522.TXT) identifies numeric values as tribal EPA regions. Numeric tribal codes do not identify a single postal state. They must not be treated as FIPS state codes. The actor uses a postal prefix from `PWSID` when available. Unresolved states are null. `stateCode` preserves the original source code.

`pfasMcl` contains fixed EPA 2024 comparison values in parts per trillion, or null for other contaminants.

| Contaminant | `pfasMcl`, ppt | Equivalent, µg/L |
| --- | --- | --- |
| PFOA | 4 | 0.004 |
| PFOS | 4 | 0.004 |
| PFHxS | 10 | 0.010 |
| PFNA | 10 | 0.010 |
| HFPO-DA | 10 | 0.010 |

`aboveMcl` is true only when a detected individual sample result is strictly greater than this fixed table value after unit conversion. It is null when no comparison applies. The table is a historical screening reference, not a statement of current regulations. Individual sample comparisons do not determine regulatory compliance, which requires the applicable averaging and monitoring rules. The actor does not calculate a mixture hazard index.

### Change detection

`generatedId` is `sampleId:contaminant:samplePointId:pwsId`. The actor compares a hash of the result's value fields with the last saved hash for that ID. Retrieval timestamps and change labels do not cause changes. `changeType` is `new`, `changed`, or `unchanged`.

State saves follow successful dataset pushes. `onlyNew=true` skips unchanged results. Use separate state store names for independent histories. A crash between a dataset push and state save can repeat an item on the next run.

EPA has published quarterly updates. Its occurrence data page describes the August 2026 release as the final UCMR 5 dataset. Future revisions depend on EPA. The actor detects changes only among rows it visits. A capped run can repeatedly return the first matching rows when `onlyNew=false`. With `onlyNew=true`, later runs skip saved results and can reach later rows. The actor does not report deletions or provide a complete archive of prior publications.

`RUN_SUMMARY` in the default key-value store records the run counts and any error. The output schema links to this summary and the dataset.

### Pricing

The actor uses pay per result with the event name `result`. There is no run fee. Every emitted item counts as a result, including unchanged items when `onlyNew=false`. Check the actor's pricing page for the configured price.

### Source credit and licence

Source: US Environmental Protection Agency, UCMR 5 occurrence data, [download archive](https://www.epa.gov/system/files/other-files/2023-08/ucmr5-occurrence-data.zip). Source and licence text from [TERMS.md](TERMS.md):

> EPA-produced data is a US Government work (17 U.S.C. 105). https://www.epa.gov/web-policies-and-procedures/epa-disclaimers notes EPA data "is by default in the public domain" (stated for geospatial data) and that third-party copyrighted items need permission; the "non-commercial" sentence there covers documents, not EPA data. Data has no personal data (public water systems).

No EPA endorsement is implied. The data dictionary PDF is not redistributed. Every item carries the dataset name and URL, licence, and retrieval time. The actor normalizes dates and states, converts numeric fields, adds ZIP codes, and calculates comparison and change fields.

### Personal data and limitations

The actor collects public water system and facility sample data. It does not collect personal contact details or household addresses. Water system ZIP codes describe service areas, not individual sample addresses.

EPA can revise results and publication timing. This actor is not a real-time drinking water alert service. Source codes such as system size, water type, and monitoring requirement remain EPA codes. Non-detects and missing values limit concentration comparisons. Dataset order is source order, not newest collection date or highest concentration.

### Run locally

Use Node.js 20 or later.

```sh
npm install
npm test
npm start
```

Local runs contact EPA. Tests use `node:test`, stub network requests, and use trimmed sample fixtures under `test/fixtures`. Read dataset items under `storage/datasets/default` and the summary at `storage/key_value_stores/default/RUN_SUMMARY.json`.

# Actor input Schema

## `states` (type: `array`):

Two-letter postal codes, such as CA and NJ. Empty means all states.

## `pwsIds` (type: `array`):

Exact PWSID values. Empty means all systems.

## `contaminants` (type: `array`):

Contaminant names, such as PFOA, PFOS, or lithium. Empty means all contaminants.

## `detectsOnly` (type: `boolean`):

Keep results marked = by EPA. Set false to include non-detects.

## `minValue` (type: `number`):

Minimum result value in source units, µg/L. No concentration filter when omitted.

## `collectedAfter` (type: `string`):

Keep collection dates after this ISO date, for example 2024-01-01. No date filter when omitted.

## `onlyNew` (type: `boolean`):

Skip unchanged snapshots from previous successful pushes.

## `maxItems` (type: `integer`):

Maximum records emitted in this run.

## `stateStoreName` (type: `string`):

Named key-value store for result snapshots. Use one store per independent history.

## Actor input object example

```json
{
  "states": [],
  "pwsIds": [],
  "contaminants": [],
  "detectsOnly": true,
  "onlyNew": false,
  "maxItems": 100,
  "stateStoreName": "ucmr5-pfas-results-monitor-state"
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `runSummary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("keepable_injera/ucmr5-pfas-results-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("keepable_injera/ucmr5-pfas-results-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call keepable_injera/ucmr5-pfas-results-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,keepable_injera/ucmr5-pfas-results-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/c9mpjJQ5ZBpXiZjps/builds/T7vAorGQFILvSD1SP/openapi.json
