# Railroad Grade Crossing Risk Screener - FRA Accident History (`malonestar/ntad-fra-rail-crossing-risk-screener`) Actor

Screens any US coordinate or geography against the 242,108-crossing FRA/NTAD grade crossing inventory joined to 50 years of FRA Form 57 accident history. Returns warning devices, traffic and train exposure, fatalities and a per-crossing risk score. A zero is a verified negative.

- **URL**: https://apify.com/malonestar/ntad-fra-rail-crossing-risk-screener.md
- **Developed by:** [Kyle Maloney](https://apify.com/malonestar) (community)
- **Categories:** Real estate, Developer tools, Agents
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.50 / 1,000 crossing records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Railroad Grade Crossing Risk Screener — FRA Accident History

Screen any US coordinate, or a whole state / county / city, against the **242,108-crossing
FRA National Highway-Rail Crossing Inventory** (published by USDOT/BTS in the National
Transportation Atlas Database) joined to **50 years of FRA Form 57 highway-rail accident
reports** — 251,149 incidents running from 1975 to the present.

For every crossing you get the inventory record (warning devices, public/private, at-grade
vs grade-separated, traffic and train counts, timetable speed, track layout, operating
railroad) plus its actual accident history — how many, how recent, how severe — and an
auditable risk score.

**The point of the product is the negative.** If a crossing next to your site has never had
a reported accident, this actor tells you that as a *verified* fact (`incidents_checked:
true`, `incidents_count: 0`) — and if the FRA source could not be reached, it tells you
*that* instead (`incidents_checked: false`, `incidents_count: null`). Those two answers
never look the same.

***

### Who it's for

- **Environmental and property due diligence** — a rail crossing 300 ft from a site is a
  noise, vibration, access and liability finding. Phase I ESA and ASTM site-recon workflows
  already screen pipelines, tanks and floodplains; this is the rail layer.
- **Logistics, industrial and warehouse siting** — inbound truck routes that cross an
  active at-grade crossing with 40 trains a day and crossbucks only are a real operating
  and insurance cost. `exposure_index` (AADT × trains/day) is the standard measure.
- **Traffic-safety and municipal engineering** — pull every crossing in a county with its
  device inventory and casualty history to prioritise Section 130 / grade-separation
  candidates.
- **Insurance and risk underwriting** — per-location crossing proximity, protection class
  and 50-year loss history.
- **Rail, transit and utility route planning** — crossing density and protection along a
  corridor.

***

### Example input

Coordinate mode — screen specific sites:

```json
{
  "assets": [
    { "label": "Denver Union Station district", "lat": 39.7392, "lon": -104.9903, "radiusMiles": 1 },
    { "label": "RTD Colorado Station, Denver", "lat": 39.776004, "lon": -104.943325, "radiusMiles": 0.5 }
  ],
  "includeCrossingRows": true
}
```

Geography mode — every crossing in a county, with accident history:

```json
{ "state": "CO", "county": "DENVER", "includeCrossingRows": true }
```

Add the individual accident reports (narratives, speeds, casualties, conditions):

```json
{
  "assets": [{ "lat": 41.8781, "lon": -87.6298, "radiusMiles": 1 }],
  "includeIncidentDetail": true,
  "incidentSinceYear": 2005
}
```

### Output

Three record types, all in one dataset, distinguished by `record_type`:

| `record_type` | One row per | Use it for |
|---|---|---|
| `asset_summary` | screened site | headline counts, nearest crossing, radius totals, site risk band |
| `crossing` | grade crossing | the full inventory record plus its accident rollup and risk score |
| `incident` | FRA Form 57 report | narrative-level accident detail (opt-in) |

**173 declared fields.** The highlights:

**Location & identity** — `crossing_id` (the FRA number, which is also the join key to every
other FRA safety product), `crossing_distance_miles`, `crossing_lat` / `crossing_lon`,
`position_status`, `street`, `city_name`, `county_name`, `county_fips`, `state_abbr`,
`fra_inventory_url`, `fra_accident_url`.

**What kind of crossing** — `crossing_position` (At Grade / RR Under / RR Over),
`is_at_grade`, `crossing_type`, `is_public`, `crossing_purpose`.

**Protection** — `has_warning_device`, `warning_device_type`, `warning_device_class`
(active / passive / none), `warning_device_fields_conflict`, `whistle_ban`,
`quiet_zone_indicated`.

**Exposure** — `annual_average_daily_traffic`, `aadt_year`, `total_through_trains_per_day`,
`max_timetable_speed_mph`, `main_tracks`, `total_tracks`, `exposure_index`.

**Railroad** — `railroad_code`, `railroad_name`, `railroad_division`,
`railroad_subdivision`, `parent_railroad_code`, `railroad_milepost`,
`railroad_operation_end_date`, `operating_railroad_ended`.

**Accident history** — `incidents_checked`, `incidents_count`, `incidents_fatalities`,
`incidents_injuries`, `incidents_last_5y`, `incidents_last_10y`, `incidents_first_date`,
`incidents_last_date`, `years_since_last_incident`, `worst_incident_date`,
`worst_incident_fatalities`, `hazmat_incidents`, `pedestrian_incidents`, and the full
`most_recent_incident_*` set including the FRA narrative.

**Risk** — `risk_score` (0-100), `risk_band`, `risk_score_basis`, `risk_factors`.

**Provenance on every row** — `inventory_source_status`, `inventory_vintage`,
`incident_source_status`, `incident_data_updated_at`, `incident_coverage_end_date`,
`incident_reporting_lag_days`, `incident_history_complete`, `data_sources_ok`,
`matched_rows_total`, `results_truncated`.

***

### How this actor refuses to give you a confident wrong answer

Every item below is a measured property of the upstream data, not a hypothetical.

**A zero is either verified or it is null — never both.** `incidents_count: 0` means FRA
answered and holds no report. If the incident source fails, the count is `null`,
`incident_history_complete` is `false`, and `risk_score` is `null` — the actor will not
publish a "low risk" derived from a query that never returned.

**16.3% of the inventory is not at grade.** 21,251 crossings are RR Under and 18,157 are
RR Over. Counting all 242,108 records as at-grade conflict points overstates real exposure
by about a fifth, so `is_at_grade` ships on every row and the summary splits the counts.
A live check on every run fails the actor if the upstream ever stops publishing
grade-separated records, because at that moment `is_at_grade` would silently become a
constant `true`.

**A blank warning-device code does not mean "unprotected."** `WDCODE` is blank on 106,597
crossings (44.0%) — and **44,704 of those have `SignsOrSignals = Yes`**, i.e. a device
exists and its type was simply not reported. A further **13,610 crossings name a device
while `SignsOrSignals` says No**; that contradiction is surfaced in
`warning_device_fields_conflict` rather than silently resolved in one direction.

**288 crossings have no usable position.** BTS moves a crossing to latitude/longitude 0,0
when its reported coordinate falls outside its own designated state. Those records get
`position_status: "unlocatable_moved_to_null_island"`, a `null` position and a `null`
distance — the raw reported pair still ships in `reported_latitude` /
`reported_longitude`, but the actor never quietly falls back to the coordinate BTS itself
rejected. Radius queries exclude them server-side, so a search near the Gulf of Guinea
does not return all 288.

**33.7% of crossings publish no traffic count.** An unreported AADT is `null`, never `0`,
and `exposure_index` is `null` rather than a fabricated zero.

**The accident file runs about 2.5 months behind.** FRA refreshed the dataset today, but
the most recent incident in it is from **2026-05-31**. Every row publishes
`incident_coverage_end_date` and `incident_reporting_lag_days`, and every recency window
(`incidents_last_5y`, `years_since_last_incident`) is anchored to that date rather than to
the clock — so a figure cannot drift on its own between runs.

**Not every accident joins to a live crossing.** In Colorado, 1,302 distinct crossing IDs
appear in the accident file but only 932 (71.6%) are still in the current inventory;
23.3% of Colorado incidents therefore cannot be attributed to an open crossing. That is
because the crossing was closed, consolidated or re-identified after the accident — or
because the ID predates the modern 6-digit-plus-letter format (`466D0`, `0253087`).
A further 5,513 incidents nationally (2.2%) carry no crossing ID at all. So a per-crossing
count is an honest **lower bound on the crossing as it exists today**, not a count of
everything that ever happened at that spot.

**The two-digit year is pivoted from evidence, not assumption.** `incidentyear` is text and
two digits. Its distinct set is exactly `{75..99} ∪ {00..26}` and each band was
cross-checked against the file's own `date` column — 52 of 52 agree. Rule: `yy >= 75 →
19yy`, otherwise `20yy`. The band 27-74 is ambiguous and currently empty; **a live check
fails the run if FRA ever back-loads a value into it**, rather than letting a 1974 record
be published as 2074.

**Nothing is billed unless a live gate passes first.** Before any row is written the actor
measures both upstreams and checks: inventory count floor, the presence of both at-grade
and grade-separated records, the closed `CrossingPosition` and `CrossingType`
vocabularies, the full field contract, inventory freshness, incident count floor, incident
freshness, century-pivot integrity, a **positive canary** (crossing `945876D` at Colorado
Station in Denver, and its 2026-05-30 fatality, which also proves the crossing-ID join is
still intact), and a **negative control** (a 50-mile radius in the open Pacific must return
zero). Any failure ends the run with an explanatory message and **zero rows, zero charges**.

**A partial read is an error, not a result.** Crossing retrieval is paged to completion and
then reconciled against the service's own count; a shortfall aborts rather than publishing
an arbitrary subset. If you cap the result with `maxCrossings`, `matched_rows_total` still
reports the true total and `results_truncated` is set.

***

### Notes and limits

- `risk_score` is **editorial**. FRA publishes no official per-crossing risk rating in this
  dataset. `risk_score_basis` names the model and `risk_factors` itemises every contribution
  so you can audit or re-weight it. It is a screening aid, not a regulatory determination.
- `reason_code` (FRA Form 71 reason-for-update) ships verbatim with `reason_code_label:
  null` and `reason_code_label_basis: "unmapped_fra_reason_code"`. FRA ships no decode
  table inside the NTAD extract, and a guessed label across 242,108 rows would be worse
  than no label.
- Geography mode matches the FRA spelling exactly: uppercase, no "County" suffix
  (`state: "CO"`, `county: "DENVER"`).
- Coverage is the 50 states, DC and territories present in the FRA inventory. There is no
  crossing data for locations outside it, and a radius there correctly returns zero.
- An empty run input fails loudly with an actionable message and bills nothing.

### Use as an MCP tool

This actor is callable by AI agents through Apify's MCP server at `https://mcp.apify.com`.
Billing is unchanged when it is called as a tool, and a run that cannot answer fails
without billing.

```json
{
  "mcpServers": {
    "apify": {
      "command": "npx",
      "args": ["-y", "@apify/actors-mcp-server", "--actors", "malonestar/ntad-fra-rail-crossing-risk-screener"],
      "env": { "APIFY_TOKEN": "your-apify-token" }
    }
  }
}
```

Worked agent prompt:

> "Screen 1200 W 47th Ave, Denver CO for railroad exposure. Give me every grade crossing
> within a mile, whether each is at grade, what warning devices it has, and its FRA
> accident history — and tell me explicitly which crossings were verified to have no
> accidents versus which could not be checked."

Chains naturally with the rest of the site-diligence shelf — pipelines, tanks, floodplain,
wetlands, contaminated sites — since all of them screen the same coordinate.

### FAQ

**Where does the crossing data come from?** The FRA National Highway-Rail Crossing
Inventory, published as the USDOT/BTS NTAD "Railroad Grade Crossings" layer. The vintage is
read from the service on every run and published as `inventory_vintage`.

**Where does the accident data come from?** FRA "Highway-Rail Grade Crossing Incident Data
(Form 57)", the official railroad-reported accident file, 1975 to present.

**Does a crossing with zero incidents really mean zero?** It means FRA holds no Form 57
report joined to that crossing ID through `incident_coverage_end_date`. Read the caveats on
join rates above — and check `incidents_checked` before trusting any zero.

**Can I get every crossing in a state?** Yes — `{ "state": "TX" }`. Use `maxCrossings` to
bound the cost; `matched_rows_total` always reports the true total.

**How is this priced?** Pay per result row. An `asset_summary` row plus one row per crossing
found; incident rows only if you ask for them.

# Actor input Schema

## `assets` (type: `array`):

One object per site: {"lat": 39.7392, "lon": -104.9903, "label": "Denver site", "radiusMiles": 1}. Every railroad grade crossing within the radius is returned, joined to its FRA accident history. Supply either this or a state. Coordinates are decimal degrees (WGS84); 0,0 is rejected because it is the NTAD unlocatable sentinel.

## `radiusMiles` (type: `integer`):

Radius used for any site that does not carry its own radiusMiles. Capped at 25 miles. Left unset, the actor uses 1 mile and records search\_radius\_source on every row so the value is never silently applied.

## `state` (type: `string`):

Two-letter code such as CO, or the full FRA state name such as COLORADO. Lists every crossing in that state instead of screening coordinates. Ignored when assets are supplied.

## `county` (type: `string`):

Optional county filter, matched against the FRA spelling in uppercase with no "County" suffix, e.g. DENVER. Requires state.

## `city` (type: `string`):

Optional city filter, matched against the FRA spelling in uppercase, e.g. DENVER. Requires state.

## `includeCrossingRows` (type: `boolean`):

Emit a detailed row for every crossing found, with inventory attributes, warning devices, exposure and its FRA incident rollup. Turn off to get only the per-site summary rows.

## `includeIncidentDetail` (type: `boolean`):

Also emit a row per individual FRA Form 57 accident report at the matched crossings, with narrative, speeds, casualties and conditions. Increases the row count and therefore the cost.

## `incidentSinceYear` (type: `integer`):

Narrows the accident window, e.g. 2005. Left unset the full FRA corpus from 1975 is used, and incident\_window\_since is published as null so a narrowed answer is never mistaken for the full history.

## `maxCrossings` (type: `integer`):

Safety cap on how many crossings are returned per site (or per geography query). When it bites, matched\_rows\_total still reports the true total and results\_truncated is set, so a capped answer is never read as a complete one.

## Actor input object example

```json
{
  "assets": [
    {
      "label": "Denver Union Station district",
      "lat": 39.7392,
      "lon": -104.9903,
      "radiusMiles": 1
    },
    {
      "label": "RTD Colorado Station, Denver",
      "lat": 39.776004,
      "lon": -104.943325,
      "radiusMiles": 0.5
    }
  ],
  "radiusMiles": 1,
  "includeCrossingRows": true,
  "maxCrossings": 500
}
```

# Actor output Schema

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

The default dataset.

# 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 = {
    "assets": [
        {
            "label": "Denver Union Station district",
            "lat": 39.7392,
            "lon": -104.9903,
            "radiusMiles": 1
        },
        {
            "label": "RTD Colorado Station, Denver",
            "lat": 39.776004,
            "lon": -104.943325,
            "radiusMiles": 0.5
        }
    ],
    "radiusMiles": 1,
    "includeCrossingRows": true,
    "includeIncidentDetail": false,
    "maxCrossings": 500
};

// Run the Actor and wait for it to finish
const run = await client.actor("malonestar/ntad-fra-rail-crossing-risk-screener").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 = {
    "assets": [
        {
            "label": "Denver Union Station district",
            "lat": 39.7392,
            "lon": -104.9903,
            "radiusMiles": 1,
        },
        {
            "label": "RTD Colorado Station, Denver",
            "lat": 39.776004,
            "lon": -104.943325,
            "radiusMiles": 0.5,
        },
    ],
    "radiusMiles": 1,
    "includeCrossingRows": True,
    "includeIncidentDetail": False,
    "maxCrossings": 500,
}

# Run the Actor and wait for it to finish
run = client.actor("malonestar/ntad-fra-rail-crossing-risk-screener").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 '{
  "assets": [
    {
      "label": "Denver Union Station district",
      "lat": 39.7392,
      "lon": -104.9903,
      "radiusMiles": 1
    },
    {
      "label": "RTD Colorado Station, Denver",
      "lat": 39.776004,
      "lon": -104.943325,
      "radiusMiles": 0.5
    }
  ],
  "radiusMiles": 1,
  "includeCrossingRows": true,
  "includeIncidentDetail": false,
  "maxCrossings": 500
}' |
apify call malonestar/ntad-fra-rail-crossing-risk-screener --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,malonestar/ntad-fra-rail-crossing-risk-screener"
        }
    }
}

```

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/mTgAfEqqSQmpf78a0/builds/VC4tbrijUf1U8HNhX/openapi.json
