Automating OSHA chemical hygiene inventory checks
On this page
- Problem statement
- Prerequisites
- Step-by-step implementation
- Step 1 — Load and validate inventory rows with CAS normalization
- Step 2 — Join to the CHP rule set and evaluate the checks
- Step 3 — Emit findings and write to the ledger idempotently
- Schema and field reference
- Verification
- Troubleshooting
- Frequently asked questions
- Related
Problem statement
You need a Python routine that reads a laboratory chemical inventory, joins each container to the rules in your Chemical Hygiene Plan, and decides — deterministically and the same way every time — whether that container breaches the OSHA Laboratory Standard, then records the verdict in an audit ledger you can hand an inspector without a duplicate row or a silent gap.
This task sits under OSHA Laboratory Standard Automation, part of the broader Hazardous Material & Chemical Inventory Compliance practice. The scope here is a single evaluation pass: load rows, normalize identifiers, apply 29 CFR 1910.1450 checks, and write findings idempotently. It does not adjudicate remediation — a flag is surfaced for the chemical hygiene officer, never auto-cleared — consistent with the flag-don’t-fix contract the parent cluster establishes.
Prerequisites
Before running the checker, confirm the environment and policy configuration:
- Python 3.10+ (the code uses
X | Nonetype hints anddatetime.now(timezone.utc)). - Libraries:
pydantic>=2.5for strict inventory-row validation andSQLAlchemy>=2.0withpsycopg[binary]for the transactional ledger upsert. Install withpip install "pydantic>=2.5" "SQLAlchemy>=2.0" "psycopg[binary]". - Environment variables (never hard-code credentials):
HAZMAT_DB_URL— the SQLAlchemy connection string for the compliance ledger, e.g.postgresql+psycopg://svc_hazmat:***@db.internal:5432/ehs. - Policy config: a version-controlled CHP rule set keyed by CAS number and hazard class, carrying exposure thresholds, required controls, and peroxide-class expiry windows. Keep it beside the inventory feed produced by Equipment Calibration & Lab Inventory Tracking; the safety-data-sheet linkage each row needs is resolved by Safety Data Sheet Management.
Step-by-step implementation
The flow below is enforced by the checker: raw inventory rows are validated and CAS-normalized, joined to the CHP rule set, evaluated against 1910.1450 quantity, control, expiry, and SDS rules, and written to the ledger through an idempotent upsert keyed on an audit hash. The hash is what makes a re-run safe.
Step 1 — Load and validate inventory rows with CAS normalization
The Pydantic model is the boundary where a container row becomes trustworthy. Its validators normalize the CAS number to canonical NNNNNNN-NN-N form and verify the check digit, because a mistyped CAS number would silently miss the join into the CHP rule set and leave a hazardous container un-evaluated. A row that cannot be normalized is rejected here, never passed downstream.
import hashlib
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
logging.basicConfig(level=logging.INFO, format="%(asctime)s [OSHA-CHK] %(message)s")
logger = logging.getLogger(__name__)
class ContainerRow(BaseModel):
model_config = ConfigDict(populate_by_name=True)
container_id: str = Field(..., min_length=1, max_length=64)
cas_number: str = Field(..., alias="cas")
chemical_name: str = Field(..., min_length=2, max_length=200)
quantity: float = Field(..., ge=0)
unit: str = Field(..., pattern=r"^(g|kg|mL|L)$")
control_present: str | None = None
received_date: date
expiry_date: date | None = None
sds_id: str | None = None
@field_validator("cas_number")
@classmethod
def normalize_and_checksum_cas(cls, v: str) -> str:
# Strip stray whitespace/prefixes, then verify the CAS check digit:
# the last digit equals (sum of each preceding digit times its
# position from the right, starting at 1) mod 10. A bad checksum
# means a typo that would detach the container from CHP policy.
digits = v.strip().upper().removeprefix("CAS").strip(" -")
parts = digits.split("-")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
raise ValueError(f"Malformed CAS number: {v!r}")
body = parts[0] + parts[1]
check = int(parts[2])
total = sum(int(d) * i for i, d in enumerate(reversed(body), start=1))
if total % 10 != check:
raise ValueError(f"CAS checksum failed: {v!r}")
return f"{parts[0]}-{parts[1]}-{parts[2]}"
def load_inventory(raw_rows: list[dict[str, Any]]) -> tuple[list[ContainerRow], list[dict]]:
"""Validate every row; return (accepted, rejected) so a bad CAS never
silently drops a hazardous container from the evaluation."""
accepted: list[ContainerRow] = []
rejected: list[dict] = []
for raw in raw_rows:
try:
accepted.append(ContainerRow.model_validate(raw))
except Exception as exc: # ValidationError and CAS ValueError
rejected.append({"row": raw, "error": str(exc)})
logger.warning("rejected container %s: %s", raw.get("container_id"), exc)
return accepted, rejectedStep 2 — Join to the CHP rule set and evaluate the checks
Each validated container is looked up in the CHP rule set by its normalized CAS number. The evaluator applies the four substantive checks the Laboratory Standard requires — quantity against the exposure threshold, presence of the required control for particularly hazardous substances, peroxide-former expiry, and SDS linkage — and returns a list of reason codes. A container with no matching rule is itself flagged, because a chemical the CHP does not cover is an exposure the plan cannot bound.
# Peroxide-former expiry windows in days, keyed by peroxide class.
# Sourced from the CHP — Class I formers (e.g. isopropyl ether) expire fast;
# stabilized Class III formers get the longest window.
PEROXIDE_WINDOWS_DAYS: dict[str, int] = {"I": 90, "II": 180, "III": 365}
def evaluate(container: ContainerRow, rule: dict[str, Any] | None, today: date) -> list[str]:
"""Return reason codes for a single container per 29 CFR 1910.1450."""
if rule is None:
return ["unmapped_hazard_class"] # 1910.1450(e): outside the CHP
reasons: list[str] = []
# 1910.1450(d): exposure determination via quantity threshold.
if container.quantity > rule["threshold_qty"]:
reasons.append("over_threshold")
# 1910.1450 App A: particularly hazardous substances need the declared
# control physically present and attested.
required = rule.get("control_required")
if required and container.control_present != required:
reasons.append("control_missing")
# Peroxide-former expiry: 3/6/12-month window by peroxide class.
pclass = rule.get("peroxide_class")
if pclass:
window = timedelta(days=PEROXIDE_WINDOWS_DAYS[pclass])
past_window = today > (container.received_date + window)
past_stamp = container.expiry_date is not None and today > container.expiry_date
if container.expiry_date is None or past_stamp or past_window:
reasons.append("expired")
# 1910.1200: hazard communication requires a linked SDS.
if not container.sds_id:
reasons.append("no_sds")
return reasonsStep 3 — Emit findings and write to the ledger idempotently
The driver validates, joins, evaluates, and writes. Each (container, reason, matrix_version) triple is fingerprinted into a deterministic audit_hash that serves as the ledger’s natural key, so the PostgreSQL ON CONFLICT DO NOTHING clause turns a re-run over an unchanged inventory into a no-op. Flagged containers are copied to a quarantine list for the officer; compliant containers still record a single ledger row so coverage can be proven.
from sqlalchemy import Column, String, DateTime, create_engine
from sqlalchemy.orm import declarative_base, Session
from sqlalchemy.dialects.postgresql import insert as pg_insert
Base = declarative_base()
class ComplianceLedger(Base):
__tablename__ = "chp_ledger"
audit_hash = Column(String(64), primary_key=True) # natural idempotency key
container_id = Column(String(64), nullable=False)
cas_number = Column(String(16), nullable=False)
reason = Column(String(32), nullable=False)
matrix_version = Column(String(20), nullable=False)
recorded_at = Column(DateTime, nullable=False)
def run_checks(
db_url: str,
rows: list[dict[str, Any]],
chp_rules: dict[str, dict[str, Any]],
matrix_version: str,
) -> dict[str, int]:
engine = create_engine(db_url, pool_pre_ping=True)
Base.metadata.create_all(engine)
today = datetime.now(timezone.utc).date()
stats = {"recorded": 0, "skipped": 0, "quarantined": 0, "rejected": 0}
accepted, rejected = load_inventory(rows)
stats["rejected"] = len(rejected)
with Session(engine) as session:
# Sort by container_id so processing order is stable and reproducible.
for container in sorted(accepted, key=lambda c: c.container_id):
rule = chp_rules.get(container.cas_number)
reasons = evaluate(container, rule, today) or ["compliant"]
if reasons != ["compliant"]:
stats["quarantined"] += 1
for reason in reasons:
audit_hash = hashlib.sha256(
f"{container.container_id}:{reason}:{matrix_version}".encode("utf-8")
).hexdigest()
stmt = pg_insert(ComplianceLedger).values(
audit_hash=audit_hash,
container_id=container.container_id,
cas_number=container.cas_number,
reason=reason,
matrix_version=matrix_version,
recorded_at=datetime.now(timezone.utc),
).on_conflict_do_nothing(index_elements=["audit_hash"])
result = session.execute(stmt)
stats["recorded" if result.rowcount else "skipped"] += 1
session.commit()
logger.info("run complete: %s", stats)
return statsThe same validated container feeds the broader evaluation described in the parent guide on OSHA Laboratory Standard Automation; this page is the single-pass implementation of that design.
Schema and field reference
The checker reads these container fields and joins them to the CHP rule set. Widen the set in version-controlled policy config, not in code.
| Field | Type | Constraint | Source rule (29 CFR) |
|---|---|---|---|
container_id |
str | required, unique per bottle | inventory key; idempotency anchor |
cas_number |
str | normalized NNNNNNN-NN-N, checksum-valid |
1910.1450(d) CHP join key |
chemical_name |
str | 2–200 chars | 1910.1200 GHS label name |
quantity |
float | ≥ 0; compared to threshold | 1910.1450(d) exposure determination |
control_present |
str | None | must match control_required when set |
1910.1450 App A / 1910.1000 PEL |
received_date |
date | required; anchors peroxide window | 1910.1450 App A peroxide formers |
expiry_date |
date | None | required for peroxide formers | 3/6/12-month window by class |
sds_id |
str | None | non-null | 1910.1200 hazard communication |
audit_hash |
str | 64-char SHA-256; ledger key | non-repudiation audit trail |
Verification
Confirm a run behaved correctly before trusting its output:
- Validate in isolation: run
ContainerRow.model_validate(row)in a REPL against a sample with a deliberately mistyped CAS number and confirm the checksum validator raises — proof the normalization gate is active. - Reproduce the hash: recompute
sha256(f"{container_id}:{reason}:{matrix_version}")for a known finding and confirm it equals the storedaudit_hash—SELECT audit_hash FROM chp_ledger WHERE container_id = :cid. - Dry-run idempotency: execute
run_checkstwice over the identical inventory. The second call must returnrecorded = 0andskippedequal to the first call’srecorded, proving no duplicate rows landed. - Coverage parity: confirm the count of distinct
container_idin the ledger equals the number of accepted rows; any shortfall is a silently dropped container.
Troubleshooting
Three failure modes specific to OSHA chemical-hygiene checks:
- CAS-number normalization or checksum failures. Inventories imported from spreadsheets carry CAS numbers with stray spaces, a
CASprefix, or a transposed digit that passes visual inspection but fails the check-digit rule. Let the Pydantic validator reject these into therejectedlist rather than coercing them — a container with an un-verifiable CAS number cannot be safely joined to CHP policy, so it must be corrected at the source and re-run, not force-fitted. - Peroxide-former expiry windows applied inconsistently. The three-, six-, and twelve-month windows correspond to peroxide classes, not to a single global default. If every former is treated as Class III, a Class I ether can sit six months past when it should have been discarded. Confirm each peroxide-forming CAS carries an explicit
peroxide_classin the rule set, and anchor the window onreceived_date(or the opened date, if you track it) so the clock starts when the container was actually exposed to air. - Particularly-hazardous-substance designation missing from the rule set. If a select carcinogen or reproductive toxin has no
control_requiredvalue, thecontrol_missingcheck never fires and the container passes silently. When the CHP is revised to designate a new particularly hazardous substance, bumpmatrix_versionand re-evaluate, so previously compliant containers are re-judged under the stricter rule rather than grandfathered.
Frequently asked questions
Why validate the CAS check digit instead of just matching the format?
108-88-3 (toluene) can be mistyped as 108-88-4 and still look like a valid NNN-NN-N string, but the transposed check digit means it will miss the join into the CHP rule set — leaving a regulated solvent evaluated as if the plan never mentioned it. The check-digit rule recomputes the last digit from the preceding ones, so a single-digit error is rejected at the validation boundary rather than surfacing later as a mysteriously un-flagged hazardous container.
Is it safe to re-run the checker over the same inventory?
audit_hash computed from the container, the reason code, and the CHP matrix_version, and the write uses ON CONFLICT DO NOTHING. A second run over an unchanged inventory inserts nothing and returns recorded = 0; a container that newly expired since the last run inserts exactly one expired row. Re-running to pick up an inventory change is always safe.