π priyatham9.github.io/ehs-benchmarks: percentile lookup, company search, and injury-pattern analysis.
Superseded panel. The figures in this README come from the CY2023-2025 ITA files under a 100-4,000 hours-per-employee screen. The successor repository, ehs-osha-analysis, pools CY2016-2024 (2,801,064 filings, 120-4,500 screen) and reports an aggregate TRIR of 0.134 unscreened against 3.983 screened, a 29.7x ratio driven mostly by the 2019 file. Recomputing this repository's panel with that pipeline (CY2023 and CY2024 files only, 100-4,000 screen) gives 0.473 / 3.374 / 7.14x against the 0.45 / 3.41 / 7.58x below: the screened rate reproduces within about 1%, the ratio within 6%. Both multipliers are panel-specific. See the "Relationship to ehs-benchmarks" section of that README and its
outputs/tables/reconciliation_panels.csv.
Part of the Grounded research programme: https://priyatham9.github.io/grounded/
Open OSHA injury-rate benchmarks, built from 1.18 million public establishment filings.
Two things live here:
ehs-metricsβ a tested TypeScript library for OSHA incident-rate calculation (TRIR, DART, LTIR, severity rate) with the data-quality guards these calculations actually need.- A published benchmark dataset β TRIR and DART percentiles for 3,842 NAICS Γ size-band peer groups, derived from OSHA's Injury Tracking Application, plus a static site that makes them usable.
MIT licensed. No API keys, no server, no database.
At a glance
- Status: superseded (see panel above); MIT licensed, no API keys, no server, no database
- Data: real, from OSHA's Injury Tracking Application (public establishment filings, CY2023-2025)
- Tests:
npm test, 104 tests (vitest) - Quickstart:
npm install github:priyatham9/ehs-benchmarks - Licence: MIT (see LICENSE)
Every EHS leader eventually asks "is our TRIR any good?" The honest answer requires comparing against establishments of similar size in the same industry. That data is public β OSHA publishes establishment-level 300A filings every year β and it is close to unusable straight out of the download.
Here is what we found processing CY2023βCY2025.
Employers self-report hours worked, and a small number of filings are wrong by many orders of magnitude. One establishment reported 1.2 Γ 10ΒΉΒΉ hours for 60 employees β more labour than the entire US economy performs in a year.
Only 2.24% of filings fail a basic plausibility test. Those filings carry 86.9% of every hour reported to OSHA.
| Aggregate TRIR | |
|---|---|
| Sum the hours column as published | 0.45 |
| Exclude implausible records | 3.41 |
A 7.58Γ error, and it silently makes every organisation look excellent. Any benchmark built on the raw file is wrong by roughly that factor.
The test this library applies: hours worked Γ· average employees must fall between 100 and 4,000 per year. Below that, hours were filed in the wrong unit; above it, a keying error. Bounds were derived from the observed distribution, not chosen by taste β the real p1βp99 range is 163 to 3,716.
Injury-rate distributions are not bell curves; they spike at zero. In chemical manufacturing (NAICS 325), 38% of establishments report no recordable case at all while the 90th percentile sits at 5.54. Reporting a mean against a distribution shaped like that is meaningless, and "we had zero" says as much about establishment size as about safety performance.
This is why rankAgainst() reports a zero rate at the midpoint of the zero mass rather than as best-in-class, and why percentiles use nearest-rank instead of interpolation β interpolating across a 38% spike invents rates nobody reported.
688,367 cases carry OIICS coding; 546,590 record both shift start and incident time. Injuries peak in hour 2 of the shift, and 47.8% occur in the first four hours β not at the fatigued end of the day, where fatigue-management programmes usually aim.
Not published to npm yet. Install straight from the repository:
npm install github:priyatham9/ehs-benchmarksimport { trir, dart, rankAgainst, checkPlausibility } from 'ehs-metrics';
const cases = {
deaths: 0,
daysAwayCases: 3,
jobTransferCases: 2,
otherRecordableCases: 7,
};
trir(cases, { employeeHours: 480_000 }).value; // 5.0
dart(cases, { employeeHours: 480_000 }).value; // 2.083β¦Two organisations quoting "TRIR 4.0" may not be quoting the same metric. OSHA requires supervised contractors on the host's log (29 CFR 1904.31), and whether their hours land in the denominator moves the number substantially:
const hours = { employeeHours: 500_000, contractorHours: 500_000 };
trir(c, hours).value; // 4.0 (employee-only, default)
trir(c, hours, { contractorBasis: 'combined' }).value; // 2.0 (exactly half)A common double-count. The 200,000-hour basis already normalises for time, because the denominator shrinks with the period. Multiplying a six-month rate by two is wrong:
trir(c, hours, { monthsCovered: 6 }).value; // identical to the 12-month value
// β¦and returns a PARTIAL_PERIOD flag noting the rate is volatile, not that it needs scalingA site that reported no hours has no rate. Returning 0 would render it as a perfect safety record:
trir(c, { employeeHours: 0 }).value; // null, plus a ZERO_HOURS error flag
nearMissRatio(50, 0); // null β not InfinitycheckPlausibility({ hours: 1.2e11, employees: 60 });
// [{ code: 'IMPLAUSIBLE_HOURS_PER_EMPLOYEE', severity: 'error', message: 'β¦' }]
checkPlausibility({ hours: 5_000, employees: 3 });
// [{ code: 'LOW_HOUR_BASE', severity: 'warn',
// message: 'Only 5,000 hours worked. A single recordable case would produce a rate of 40.0β¦' }]isPublishable(flags) is false when any flag is an error β that single call is what the pipeline uses to decide inclusion, so the website's calculator and the published benchmark tables cannot disagree about what counts as a usable record.
| Function | Returns |
|---|---|
trir(cases, hours, opts?) |
Total recordable incident rate |
dart(cases, hours, opts?) |
Days away, restricted, or transferred |
ltir(cases, hours, opts?) |
Days-away cases only |
fatalityRate(cases, hours, opts?) |
Deaths per 100 FTE |
severityRate(cases, hours, opts?) |
Lost days, not cases |
nearMissRatio(nearMisses, recordables) |
Leading indicator, null if no recordables |
checkPlausibility(input) / isPublishable(flags) |
Data-quality screening |
buildDistribution(rates, meta) / rankAgainst(rate, dist) |
Percentile construction and lookup |
sizeBandFor(employees) / percentile(sorted, p) |
Peer-group helpers |
normalizeName(raw) / scoreMatch(q, candidate) / searchCompanies(q, list) |
Company-name matching |
npm install
npm test # 104 tests
npm run build
node scripts/build-benchmarks.mjs <rawDataDir><rawDataDir> should contain the OSHA files listed in SUMMARY_FILES / CASE_FILE at the top of the script, downloaded from OSHA's establishment-specific data page. They are not committed here β they are ~700 MB and freely redistributable from the source.
The pipeline emits everything under docs/data/.
Company name is not a company identifier. The field frequently embeds the site: Dollar Tree Stores, Inc. DC3 JOLIET, IL and Dollar Tree Stores, Inc. DC11 COWPENS, SC are one company under two keys β 14,619 of them in CY2025 alone. Grouping by EIN fixes that, but EIN cannot be trusted blindly: one value in the CY2025 file carries 9,320 establishments with 9,320 unrelated names, a placeholder that would fuse unrelated employers into a fictitious conglomerate.
So an EIN is accepted only when the names filed under it agree, measured as the share sharing a modal leading token pair. Lowe's passes despite 1,788 distinct name strings (all begin Lowe's Companies, INC); the placeholder fails and its records fall back to name grouping. Display names are the longest token prefix common to the group, which strips the embedded store identifier.
Naive substring search is wrong on this data. Searching DOW matches "Preferred Window and Door"; OLIN matches "Carolina Heating". Both are real CY2025 records. scoreMatch() scores on word boundaries, so neither can happen.
All aggregation happens at build time. The browser never sees a raw record.
| Payload | Size | When |
|---|---|---|
| Initial page load | 340 KB raw / ~70 KB gzipped | on open |
| Benchmark tables | 884 KB across 28 sector files | one sector on demand |
| Company index | 48.9 MB across 1,880 shards | one ~50 KB shard per search |
Company shards split recursively by name prefix until each is under 400 KB, so a common prefix cannot produce a slow search. No database, no serverless functions β it is a folder of JSON on GitHub Pages.
Read these before quoting any number from this project.
- ITA is not a random sample. It covers establishments required to submit: generally 100+ employees, or 20β99 in designated higher-hazard industries. Its aggregate rate runs above the BLS national figure for exactly that reason. Compare within a peer group, never against the site-wide total.
- Everything is self-reported and unaudited. Under-recording is a known, unmeasured problem. A low rate may reflect reporting culture rather than injury frequency.
- The plausibility test catches unit errors, not scope errors. An employer filing an entire system's hours under one establishment passes the test and is counted as one very large site.
- Year-over-year is not a fixed panel. Which establishments file changes annually; in some sectors reported hours per establishment swing by ~2Γ. Sector trends conflate real change with changes in who reported, and the site flags the sectors where this is largest.
- OIICS codes are model-predicted by OSHA (the
_predcolumns), not human-assigned. Reliable in aggregate; not for any individual case. - Single-establishment rates are noisy. At 200,000 hours, one extra case moves TRIR by a full point.
Data: U.S. OSHA Injury Tracking Application, establishment-specific 300A summary and case detail files, CY2023βCY2025, retrieved from osha.gov. US Government public domain.
Code: MIT β see LICENSE.
Not affiliated with or endorsed by OSHA.