← Back to FARS Dataset

Neo4j Implementation

Deployment and Query logic for Neo4j Enterprise 2026.04.0.

Schema Diagram and Graph Statistics

FARS Neo4j Schema

Node Statistics

Node Label Count
Accident 1,798,398
CrashRF 30
Weather 14
Event 1,282,627
Vehicle 2,707,404
Damage 26
Distract 22
DrImpair 14
DriverRF 80
Factor 24
Maneuver 10
Trailer 9,665
VehicleSF 37
Violatn 77
Vision 19
Person 4,673,559
Drug 816
DrugEvent 1,475,978
DrugTest 12
NMDistract 19
NMCrash 24
NMImpair 13
NMPrior 17
PersonRF 81
Race 20
SafetyEquipment 6

Edge Statistics

Relationship Count
COMMITTED_BY 503,258
CRASH_FACTOR_OF 75,484
DISTRACTION_OF 324,970
DRIVER_RF_OF 2,323,085
DRUG_EVENT_OF 1,475,978
DRUG_TEST_OF 1,891,459
FACTOR_OF 195,958
HAS_OCCUPANT 4,304,217
HAS_SAFETY_EQUIPMENT 4,425
HAS_TRAILER 9,665
HAS_VICTIM 4,673,559
IMPAIRMENT_OF 433,813
INVOLVES 2,707,404
MANEUVER_OF 682,710
NMCRASH_OF 247,805
NMDISTRACT_OF 29,550
NMIMPAIR_OF 70,181
NMPRIOR_OF 99,042
OBJECT_OF 240,611
OCCURRED_IN 1,913,545
PERSON_RF_OF 90,186
POSITION_OF 4,691,688
RACE_OF 928,011
RESULT_OF 1,451,749
RESULTED_IN 1,282,627
SUBJECT_OF 1,282,627
VEHICLE_SF_OF 195,314
VISION_OF 112,838

Disk space: 18.66 GiB

Total import time: 13,567.84 Seconds

Data Loading

Select a button below to view its how to import this data to the database.

Under the subtitles for some specific node/edge labels, we included average import time for a specific period of time. We separate these time based on years, since the FARS dataset evolved over time. If there has been a significant change to the contents and structure of a certain FARS table, it will be reflected in the import time accordingly.

Core Entities
Additional Entities
Relationships

Accident Nodes (1038.627s)

Average import time per year: 21.638s across 1975-2022.

python
#!/usr/bin/env python3
"""
Import all FARS accident.csv files into Neo4j as :Accident nodes.

This reads the normalized raw files at:

    <base>/<year>/National/accident.csv

It builds a master property list from the union of every ACCIDENT column across
1975-2022. If a year does not have a column, the importer writes a blank string
for that property so all :Accident nodes share the same property surface.

The Neo4j table id is accident_id = <year>_<ST_CASE>. The original FARS
ST_CASE column is kept unchanged.
"""

from __future__ import annotations

import argparse
import math
import os
import re
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 5000
YEAR_MIN = 1975
YEAR_MAX = 2022

MERGE_QUERY = """
UNWIND $rows AS row
MERGE (a:Accident {accident_id: row.accident_id})
SET a += row
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Import all FARS accident tables into Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument(
        "--create-database",
        action="store_true",
        help="Create the target Neo4j database from the system database if it does not exist.",
    )
    parser.add_argument(
        "--ensure-constraint-only",
        action="store_true",
        help="Only ensure the :Accident(accident_id) unique constraint, then exit without importing rows.",
    )
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument(
        "--missing-mode",
        choices=("empty", "null"),
        default=os.getenv("FARS_MISSING_MODE", "empty"),
        help=(
            "Use 'empty' to store blank strings for missing columns. "
            "Use 'null' to omit missing properties in Neo4j."
        ),
    )
    parser.add_argument("--dry-run", action="store_true", help="Inspect files/columns without connecting to Neo4j.")
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def normalize_column(name: Any) -> str:
    return (
        str(name)
        .replace("\ufeff", "")
        .replace("", "")
        .strip()
        .upper()
    )


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}

    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)

    return normalized


def find_accident_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if YEAR_MIN <= year <= YEAR_MAX:
            accident_path = year_dir / "National" / "accident.csv"
            if accident_path.exists():
                files.append((year, accident_path))
            else:
                print(f"WARNING: missing {accident_path}")
    return files


def read_header(csv_path: Path) -> list[str]:
    df = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    return normalize_columns(df.columns)


def build_master_columns(files: list[tuple[int, Path]]) -> tuple[list[str], dict[str, set[int]]]:
    master_columns: list[str] = []
    seen: set[str] = set()
    presence: dict[str, set[int]] = {}

    for year, csv_path in files:
        columns = read_header(csv_path)
        if "ST_CASE" not in columns:
            raise ValueError(f"{csv_path} does not contain ST_CASE")

        for column in columns:
            presence.setdefault(column, set()).add(year)
            if column not in seen:
                seen.add(column)
                master_columns.append(column)

    for extra in ("accident_id", "DATA_YEAR"):
        if extra not in seen:
            master_columns.append(extra)
            presence[extra] = {year for year, _ in files}

    return master_columns, presence


def count_rows(csv_path: Path) -> int:
    with csv_path.open("r", encoding="latin-1", errors="replace", newline="") as fh:
        return max(sum(1 for _ in fh) - 1, 0)


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def clean_value(value: Any, missing_value: Any) -> Any:
    if value is None:
        return missing_value

    try:
        if pd.isna(value):
            return missing_value
    except TypeError:
        pass

    if hasattr(value, "item") and not isinstance(value, str):
        value = value.item()

    if isinstance(value, str):
        text = value.strip()
        return text if text else missing_value

    if isinstance(value, float):
        if math.isnan(value):
            return missing_value
        if math.isfinite(value) and value.is_integer():
            return int(value)
        return float(value)

    return value


def clean_row(row: dict[str, Any], missing_value: Any) -> dict[str, Any]:
    return {key: clean_value(value, missing_value) for key, value in row.items()}


def add_import_columns(chunk: pd.DataFrame, year: int) -> pd.DataFrame:
    st_case = chunk["ST_CASE"].map(identifier_part)
    chunk["accident_id"] = st_case.map(lambda value: f"{year}_{value}")
    chunk["DATA_YEAR"] = year
    return chunk


def iter_clean_batches(
    csv_path: Path,
    year: int,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=batch_size,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        chunk = add_import_columns(chunk, year)

        for column in master_columns:
            if column not in chunk.columns:
                chunk[column] = missing_value

        chunk = chunk[master_columns]
        chunk = chunk.where(pd.notna(chunk), missing_value)

        yield [
            clean_row(row, missing_value)
            for row in chunk.to_dict(orient="records")
        ]


def import_batch(tx: Any, rows: list[dict[str, Any]]) -> None:
    tx.run(MERGE_QUERY, rows=rows).consume()


def write_batch(session: Any, rows: list[dict[str, Any]]) -> None:
    if hasattr(session, "execute_write"):
        session.execute_write(import_batch, rows)
    else:
        session.write_transaction(import_batch, rows)


def quote_database_name(database: str) -> str:
    if not re.fullmatch(r"[A-Za-z0-9_.-]+", database):
        raise ValueError(f"Unsafe Neo4j database name: {database!r}")
    return f"`{database}`"


def get_databases(driver: Any) -> dict[str, str]:
    with driver.session(database="system") as session:
        result = session.run("SHOW DATABASES YIELD name, currentStatus RETURN name, currentStatus")
        return {record["name"]: record["currentStatus"] for record in result}


def wait_for_database(driver: Any, database: str, timeout_seconds: int = 120) -> None:
    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        databases = get_databases(driver)
        status = databases.get(database)
        if status == "online":
            return
        time.sleep(2)

    status = get_databases(driver).get(database)
    raise TimeoutError(f"Database {database!r} did not become online; last status was {status!r}")


def ensure_database(driver: Any, database: str, create_database: bool) -> None:
    databases = get_databases(driver)
    status = databases.get(database)

    if status == "online":
        return

    if status is not None:
        print(f"Database {database!r} exists with status {status!r}; waiting for online...")
        wait_for_database(driver, database)
        return

    if not create_database:
        raise SystemExit(
            f"Neo4j database {database!r} does not exist. "
            f"Re-run with --create-database if you want this script to create it."
        )

    quoted_database = quote_database_name(database)
    print(f"Creating Neo4j database {database!r}...")
    with driver.session(database="system") as session:
        try:
            session.run(f"CREATE DATABASE {quoted_database} IF NOT EXISTS").consume()
        except Exception as exc:
            raise SystemExit(
                f"Could not create Neo4j database {database!r}. "
                "The server or current user does not support CREATE DATABASE. "
                "Create the database manually, or run this importer against an existing database."
            ) from exc

    wait_for_database(driver, database)


def ensure_constraint(session: Any) -> None:
    queries = [
        "CREATE CONSTRAINT accident_accident_id IF NOT EXISTS FOR (a:Accident) REQUIRE a.accident_id IS UNIQUE",
        "CREATE CONSTRAINT ON (a:Accident) ASSERT a.accident_id IS UNIQUE",
    ]

    last_error: Exception | None = None
    for query in queries:
        try:
            session.run(query).consume()
            return
        except Exception as exc:  # Neo4j syntax differs by version.
            last_error = exc

    print(f"WARNING: could not create unique constraint on :Accident(accident_id): {last_error}")


def print_dry_run(files: list[tuple[int, Path]], master_columns: list[str], presence: dict[str, set[int]]) -> None:
    years = [year for year, _ in files]
    missing_some_years = [
        column
        for column, column_years in presence.items()
        if len(column_years) != len(files)
    ]
    total_rows = sum(count_rows(path) for _, path in files)

    print(f"Accident files: {len(files)} ({min(years)}-{max(years)})")
    print(f"Accident rows : {total_rows:,}")
    print(f"Properties    : {len(master_columns)}")
    print(f"Vary by year  : {len(missing_some_years)} columns")
    print(f"First columns : {', '.join(master_columns[:12])}")
    print(f"Last columns  : {', '.join(master_columns[-12:])}")

    if missing_some_years:
        examples = ", ".join(missing_some_years[:20])
        print(f"Examples with year gaps: {examples}")


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()
    missing_value = "" if args.missing_mode == "empty" else None

    start = time.time()
    if args.ensure_constraint_only:
        try:
            from neo4j import GraphDatabase
        except ModuleNotFoundError as exc:
            raise SystemExit(
                "Missing Python package 'neo4j'. Install it with: "
                "python -m pip install --user neo4j"
            ) from exc

        driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
        try:
            driver.verify_connectivity()
            ensure_database(driver, args.database, args.create_database)
            with driver.session(database=args.database) as session:
                ensure_constraint(session)
            print(f"Constraint ensured on database {args.database!r}: :Accident(accident_id)")
        finally:
            driver.close()
        return

    files = find_accident_files(args.base)
    if not files:
        raise SystemExit(f"No accident.csv files found under {args.base}")

    master_columns, presence = build_master_columns(files)

    if args.dry_run:
        print_dry_run(files, master_columns, presence)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    driver.verify_connectivity()
    ensure_database(driver, args.database, args.create_database)

    total_nodes = 0
    print(f"Accident files : {len(files)}")
    print(f"Properties     : {len(master_columns)}")
    print(f"Missing values : {repr(missing_value)}")
    print(f"Neo4j URI      : {args.uri}")
    print(f"Neo4j database : {args.database}")
    print(f"Script started UTC : {utc_now()}")
    print("")

    load_start = time.time()
    print(f"Node load started UTC : {utc_now()}")

    try:
        with driver.session(database=args.database) as session:
            ensure_constraint(session)

            for index, (year, csv_path) in enumerate(files, 1):
                year_start = time.time()
                year_nodes = 0

                try:
                    for rows in iter_clean_batches(csv_path, year, master_columns, args.batch_size, missing_value):
                        if not rows:
                            continue
                        write_batch(session, rows)
                        year_nodes += len(rows)
                        total_nodes += len(rows)

                    elapsed = time.time() - year_start
                    print(
                        f"[{index}/{len(files)}] Done {year}: "
                        f"{year_nodes:,} nodes in {seconds_text(elapsed)} "
                        f"({format_duration(elapsed)})"
                    )
                except Exception as exc:
                    elapsed = time.time() - year_start
                    print(
                        f"[{index}/{len(files)}] FAILED {year} after "
                        f"{seconds_text(elapsed)} ({format_duration(elapsed)}): {exc}"
                    )
    finally:
        driver.close()

    load_elapsed = time.time() - load_start
    total_elapsed = time.time() - start
    print(f"Node load finished UTC : {utc_now()}")
    print(f"Node load seconds      : {load_elapsed:.3f}")
    print(f"Total script seconds   : {total_elapsed:.3f}")
    print(f"\nTotal Accident nodes: {total_nodes:,} in {seconds_text(load_elapsed)} ({format_duration(load_elapsed)})")


if __name__ == "__main__":
    main()

Vehicle Nodes (4050.073s)

Average combined import time per year: 84.376s across 1975-2022.

python
#!/usr/bin/env python3
"""
Import all FARS vehicle-family CSV files into Neo4j as :Vehicle nodes.

This reads the normalized raw files at:

    <base>/<year>/National/vehicle.csv
    <base>/<year>/National/vehnit.csv
    <base>/<year>/National/parkwork.csv

It builds a master property list from the union of every source column across
VEHICLE, VEHNIT, and PARKWORK. PARKWORK's parked/working vehicle columns are
canonicalized to their parallel VEHICLE names, e.g. PMAKE -> MAKE and
PVEH_SEV -> DEFORMED. If a source file does not have a column, the importer
writes a blank string for that property so all :Vehicle nodes share the same
property surface.

The Neo4j table id is vehicle_id = <year>_<ST_CASE>_<VEH_NO>. The original FARS
ST_CASE and VEH_NO columns are kept unchanged. accident_id = <year>_<ST_CASE>
matches the :Accident(accident_id) key.

source_table preserves the physical source table. transport_status and
vehicle_source_class provide easier graph query predicates:

    VEHICLE  -> in_transport
    VEHNIT   -> not_in_transport / parked_working
    PARKWORK -> not_in_transport / parked_working
"""

from __future__ import annotations

import argparse
import math
import os
import re
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 5000
YEAR_MIN = 1975
YEAR_MAX = 2022
TABLES = ("vehicle", "vehnit", "parkwork")

# PARKWORK is the 2010+ replacement/parallel table for not-in-transport
# parked/working vehicles. Most columns are a simple leading-P form of the
# VEHICLE column. These irregular names are documented by the FARS manual.
PARKWORK_COLUMN_MAP = {
    "PTYPE": "UNITTYPE",
    "PTYPENAME": "UNITTYPENAME",
    "PBODYTYP": "BODY_TYP",
    "PBODYTYPNAME": "BODY_TYPNAME",
    "PMODYEAR": "MOD_YEAR",
    "PMODYEARNAME": "MOD_YEARNAME",
    "PFUECODE": "FUELCODE",
    "PTRAILER": "TOW_VEH",
    "PTRAILERNAME": "TOW_VEHNAME",
    "PCARGTYP": "CARGO_BT",
    "PCARGTYPNAME": "CARGO_BTNAME",
    "PHAZPLAC": "HAZ_PLAC",
    "PHAZPLACNAME": "HAZ_PLACNAME",
    "PSP_USE": "SPEC_USE",
    "PSP_USENAME": "SPEC_USENAME",
    "PEM_USE": "EMER_USE",
    "PEM_USENAME": "EMER_USENAME",
    "PVEH_SEV": "DEFORMED",
    "PVEH_SEVNAME": "DEFORMEDNAME",
    "PFIRE": "FIRE_EXP",
    "PFIRENAME": "FIRE_EXPNAME",
}

MERGE_QUERY = """
UNWIND $rows AS row
MERGE (v:Vehicle {vehicle_id: row.vehicle_id})
SET v += row
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Import all FARS vehicle-family tables into Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument(
        "--create-database",
        action="store_true",
        help="Create the target Neo4j database from the system database if it does not exist.",
    )
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Vehicle constraints/indexes, then exit without importing rows.",
    )
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument(
        "--missing-mode",
        choices=("empty", "null"),
        default=os.getenv("FARS_MISSING_MODE", "empty"),
        help=(
            "Use 'empty' to store blank strings for missing columns. "
            "Use 'null' to omit missing properties in Neo4j."
        ),
    )
    parser.add_argument("--check-ids", action="store_true", help="During dry-run, verify vehicle_id uniqueness.")
    parser.add_argument("--dry-run", action="store_true", help="Inspect files/columns without connecting to Neo4j.")
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def normalize_column(name: Any) -> str:
    return (
        str(name)
        .replace("\ufeff", "")
        .replace("", "")
        .strip()
        .upper()
    )


def canonicalize_column(name: Any, table: str | None = None) -> str:
    column = normalize_column(name)
    if table == "PARKWORK":
        if column in PARKWORK_COLUMN_MAP:
            return PARKWORK_COLUMN_MAP[column]
        if column not in {"STATE", "STATENAME", "ST_CASE", "VEH_NO"} and column.startswith("P"):
            return column[1:]
    return column


def normalize_columns(columns: Iterable[Any], table: str | None = None) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}

    for column in columns:
        name = canonicalize_column(column, table)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)

    return normalized


def find_vehicle_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if YEAR_MIN <= year <= YEAR_MAX:
            national_dir = year_dir / "National"
            for table in TABLES:
                csv_path = national_dir / f"{table}.csv"
                if csv_path.exists():
                    files.append((year, table.upper(), csv_path))
    return files


def read_header(csv_path: Path, table: str) -> list[str]:
    df = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    return normalize_columns(df.columns, table)


def build_master_columns(files: list[tuple[int, str, Path]]) -> tuple[list[str], dict[str, set[str]]]:
    master_columns: list[str] = []
    seen: set[str] = set()
    presence: dict[str, set[str]] = {}

    for year, table, csv_path in files:
        columns = read_header(csv_path, table)
        missing_keys = {"ST_CASE", "VEH_NO"} - set(columns)
        if missing_keys:
            raise ValueError(f"{csv_path} is missing required columns: {sorted(missing_keys)}")

        source_key = f"{year}:{table}"
        for column in columns:
            presence.setdefault(column, set()).add(source_key)
            if column not in seen:
                seen.add(column)
                master_columns.append(column)

    for extra in (
        "vehicle_id",
        "accident_id",
        "DATA_YEAR",
        "source_table",
        "transport_status",
        "vehicle_source_class",
    ):
        if extra not in seen:
            master_columns.append(extra)
            presence[extra] = {f"{year}:{table}" for year, table, _ in files}

    return master_columns, presence


def count_rows(csv_path: Path) -> int:
    with csv_path.open("r", encoding="latin-1", errors="replace", newline="") as fh:
        return max(sum(1 for _ in fh) - 1, 0)


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def clean_value(value: Any, missing_value: Any) -> Any:
    if value is None:
        return missing_value

    try:
        if pd.isna(value):
            return missing_value
    except TypeError:
        pass

    if hasattr(value, "item") and not isinstance(value, str):
        value = value.item()

    if isinstance(value, str):
        text = value.strip()
        return text if text else missing_value

    if isinstance(value, float):
        if math.isnan(value):
            return missing_value
        if math.isfinite(value) and value.is_integer():
            return int(value)
        return float(value)

    return value


def clean_row(row: dict[str, Any], missing_value: Any) -> dict[str, Any]:
    return {key: clean_value(value, missing_value) for key, value in row.items()}


def transport_status_for(table: str) -> str:
    return "in_transport" if table == "VEHICLE" else "not_in_transport"


def vehicle_source_class_for(table: str) -> str:
    return "in_transport" if table == "VEHICLE" else "parked_working"


def add_import_columns(chunk: pd.DataFrame, year: int, table: str) -> pd.DataFrame:
    st_case = chunk["ST_CASE"].map(identifier_part)
    veh_no = chunk["VEH_NO"].map(identifier_part)
    chunk["accident_id"] = st_case.map(lambda value: f"{year}_{value}")
    chunk["vehicle_id"] = [
        f"{accident_id}_{vehicle_number}"
        for accident_id, vehicle_number in zip(chunk["accident_id"], veh_no)
    ]
    chunk["DATA_YEAR"] = year
    chunk["source_table"] = table
    chunk["transport_status"] = transport_status_for(table)
    chunk["vehicle_source_class"] = vehicle_source_class_for(table)
    return chunk


def iter_clean_batches(
    csv_path: Path,
    year: int,
    table: str,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=batch_size,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns, table)
        chunk = add_import_columns(chunk, year, table)

        chunk = chunk.reindex(columns=master_columns, fill_value=missing_value)
        chunk = chunk.where(pd.notna(chunk), missing_value)

        yield [
            clean_row(row, missing_value)
            for row in chunk.to_dict(orient="records")
        ]


def import_batch(tx: Any, rows: list[dict[str, Any]]) -> None:
    tx.run(MERGE_QUERY, rows=rows).consume()


def write_batch(session: Any, rows: list[dict[str, Any]]) -> None:
    if hasattr(session, "execute_write"):
        session.execute_write(import_batch, rows)
    else:
        session.write_transaction(import_batch, rows)


def quote_database_name(database: str) -> str:
    if not re.fullmatch(r"[A-Za-z0-9_.-]+", database):
        raise ValueError(f"Unsafe Neo4j database name: {database!r}")
    return f"`{database}`"


def get_databases(driver: Any) -> dict[str, str]:
    with driver.session(database="system") as session:
        result = session.run("SHOW DATABASES YIELD name, currentStatus RETURN name, currentStatus")
        return {record["name"]: record["currentStatus"] for record in result}


def wait_for_database(driver: Any, database: str, timeout_seconds: int = 120) -> None:
    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        databases = get_databases(driver)
        status = databases.get(database)
        if status == "online":
            return
        time.sleep(2)

    status = get_databases(driver).get(database)
    raise TimeoutError(f"Database {database!r} did not become online; last status was {status!r}")


def ensure_database(driver: Any, database: str, create_database: bool) -> None:
    databases = get_databases(driver)
    status = databases.get(database)

    if status == "online":
        return

    if status is not None:
        print(f"Database {database!r} exists with status {status!r}; waiting for online...")
        wait_for_database(driver, database)
        return

    if not create_database:
        raise SystemExit(
            f"Neo4j database {database!r} does not exist. "
            f"Re-run with --create-database if you want this script to create it."
        )

    quoted_database = quote_database_name(database)
    print(f"Creating Neo4j database {database!r}...")
    with driver.session(database="system") as session:
        try:
            session.run(f"CREATE DATABASE {quoted_database} IF NOT EXISTS").consume()
        except Exception as exc:
            raise SystemExit(
                f"Could not create Neo4j database {database!r}. "
                "The server or current user does not support CREATE DATABASE. "
                "Create the database manually, or run this importer against an existing database."
            ) from exc

    wait_for_database(driver, database)


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE INDEX vehicle_accident_id IF NOT EXISTS FOR (v:Vehicle) ON (v.accident_id)",
        ":Vehicle(accident_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX vehicle_source_table IF NOT EXISTS FOR (v:Vehicle) ON (v.source_table)",
        ":Vehicle(source_table) index",
    )
    run_ddl(
        session,
        "CREATE INDEX vehicle_transport_status IF NOT EXISTS FOR (v:Vehicle) ON (v.transport_status)",
        ":Vehicle(transport_status) index",
    )


def check_vehicle_ids(files: list[tuple[int, str, Path]]) -> tuple[int, int]:
    seen: set[str] = set()
    duplicates = 0

    for year, table, csv_path in files:
        chunks = pd.read_csv(
            csv_path,
            encoding="latin-1",
            low_memory=False,
            chunksize=100_000,
            usecols=lambda c: normalize_column(c) in {"ST_CASE", "VEH_NO"},
        )
        for chunk in chunks:
            chunk.columns = normalize_columns(chunk.columns, table)
            st_case = chunk["ST_CASE"].map(identifier_part)
            veh_no = chunk["VEH_NO"].map(identifier_part)
            for accident_case, vehicle_number in zip(st_case, veh_no):
                vehicle_id = f"{year}_{accident_case}_{vehicle_number}"
                if vehicle_id in seen:
                    duplicates += 1
                else:
                    seen.add(vehicle_id)

    return len(seen), duplicates


def print_dry_run(
    files: list[tuple[int, str, Path]],
    master_columns: list[str],
    presence: dict[str, set[str]],
    check_ids: bool,
) -> None:
    table_counts = Counter(table for _, table, _ in files)
    row_counts = Counter()
    total_rows = 0

    for _, table, csv_path in files:
        rows = count_rows(csv_path)
        row_counts[table] += rows
        total_rows += rows

    missing_some_sources = [
        column
        for column, source_files in presence.items()
        if len(source_files) != len(files)
    ]

    years = [year for year, _, _ in files]
    print(f"Vehicle-family files: {len(files)} ({min(years)}-{max(years)})")
    for table in TABLES:
        label = table.upper()
        print(f"  {label:<8} files: {table_counts[label]:>2} rows: {row_counts[label]:,}")
    print(f"Total rows          : {total_rows:,}")
    print(f"Properties          : {len(master_columns)}")
    print(f"Vary by source/year : {len(missing_some_sources)} columns")
    print(f"First columns       : {', '.join(master_columns[:12])}")
    print(f"Last columns        : {', '.join(master_columns[-12:])}")

    if missing_some_sources:
        examples = ", ".join(missing_some_sources[:20])
        print(f"Examples with gaps  : {examples}")

    if check_ids:
        unique_ids, duplicates = check_vehicle_ids(files)
        print(f"Unique vehicle_id   : {unique_ids:,}")
        print(f"Duplicate vehicle_id: {duplicates:,}")


def import_files(
    session: Any,
    files: list[tuple[int, str, Path]],
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> int:
    total_rows = 0

    for index, (year, table, csv_path) in enumerate(files, 1):
        file_start = time.time()
        file_rows = 0

        try:
            for rows in iter_clean_batches(csv_path, year, table, master_columns, batch_size, missing_value):
                if not rows:
                    continue
                write_batch(session, rows)
                file_rows += len(rows)
                total_rows += len(rows)

            elapsed = time.time() - file_start
            print(
                f"[{index}/{len(files)}] Done {year} {table}: "
                f"{file_rows:,} rows in {seconds_text(elapsed)} ({format_duration(elapsed)})"
            )
        except Exception as exc:
            elapsed = time.time() - file_start
            print(
                f"[{index}/{len(files)}] FAILED {year} {table} after "
                f"{seconds_text(elapsed)} ({format_duration(elapsed)}): {exc}"
            )

    return total_rows


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()
    missing_value = "" if args.missing_mode == "empty" else None

    start = time.time()
    files = find_vehicle_files(args.base)
    if not files:
        raise SystemExit(f"No vehicle-family CSV files found under {args.base}")

    master_columns, presence = build_master_columns(files)

    if args.dry_run:
        print_dry_run(files, master_columns, presence, args.check_ids)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        ensure_database(driver, args.database, args.create_database)

        if args.ensure_constraints_only:
            with driver.session(database=args.database) as session:
                ensure_constraints(session)
            print(f"Constraints/indexes ensured on database {args.database!r}")
            return

        print(f"Vehicle-family files : {len(files)}")
        print(f"Properties           : {len(master_columns)}")
        print(f"Missing values       : {repr(missing_value)}")
        print(f"Neo4j URI            : {args.uri}")
        print(f"Neo4j database       : {args.database}")
        print(f"Script started UTC   : {utc_now()}")
        print("")

        load_start = time.time()
        print(f"Node load started UTC: {utc_now()}")

        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            total_rows = import_files(session, files, master_columns, args.batch_size, missing_value)

        load_elapsed = time.time() - load_start
        total_elapsed = time.time() - start
        print(f"Node load finished UTC: {utc_now()}")
        print(f"Node load seconds     : {load_elapsed:.3f}")
        print(f"Total script seconds  : {total_elapsed:.3f}")
        print(f"\nTotal Vehicle rows processed: {total_rows:,} in {seconds_text(load_elapsed)} ({format_duration(load_elapsed)})")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Person Nodes (4771.704s)

Average combined import time per year: 99.398s across 1975-2022.

python
#!/usr/bin/env python3
"""
Import FARS PERSON data and person-level supplements into Neo4j.

Model:
  (:Person {person_id: <year>_<ST_CASE>_<VEH_NO>_<PER_NO>})

PERSON rows are imported as Person nodes with every source column preserved.
PBTYPE is one-to-one with Person, so PBTYPE columns are appended to Person with
a PBTYPE_ prefix to avoid overwriting PERSON columns such as AGE or SEX.

SAFETYEQ is mapped onto a small controlled set of SafetyEquipment category
nodes. From 2010-2016, MSAFEQMT rows are mapped by code. From 2017 onward, NM*
flag columns are mapped by their "Yes" values:

  (:Person)-[:HAS_SAFETY_EQUIPMENT]->(:SafetyEquipment)

The 2010-2016 code 7 means "other safety equipment"; because that combines
"other protective" and "other preventive" concepts, the importer creates both
edges and sets or_flag=1 only on those two relationships.

Run this after Accident and Vehicle nodes are present if you want the Person
nodes to carry accident_id and vehicle_id keys that can be linked later. The
script itself only creates Person and SafetyEquipment nodes plus the direct
Person-to-SafetyEquipment relationship.
"""

from __future__ import annotations

import argparse
import math
import os
import re
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 5000

PERSON_TABLE = "person"
PBTYPE_TABLE = "pbtype"
SAFETYEQ_TABLE = "safetyeq"

SAFETY_EQUIPMENT_TYPES: dict[str, str] = {
    "helmet": "Helmet",
    "protective_pads": "Protective Pads",
    "other_protective": "Other Protective Equipment",
    "reflective_clothing": "Reflective Clothing",
    "lighting": "Lighting",
    "other_preventive": "Other Preventive Equipment",
}

OLD_SAFETYEQ_CODE_MAP: dict[str, list[tuple[str, int | None]]] = {
    "2": [("helmet", None)],
    "3": [("reflective_clothing", None)],
    "4": [("protective_pads", None)],
    "5": [("lighting", None)],
    "7": [("other_protective", 1), ("other_preventive", 1)],
}

NEW_SAFETYEQ_FLAG_MAP: dict[str, tuple[str, str]] = {
    "NMHELMET": ("helmet", "NMHELMETNAME"),
    "NMPROPAD": ("protective_pads", "NMPROPADNAME"),
    "NMOTHPRO": ("other_protective", "NMOTHPRONAME"),
    "NMREFCLO": ("reflective_clothing", "NMREFCLONAME"),
    "NMLIGHT": ("lighting", "NMLIGHTNAME"),
    "NMOTHPRE": ("other_preventive", "NMOTHPRENAME"),
}

MERGE_PERSON_QUERY = """
UNWIND $rows AS row
MERGE (p:Person {person_id: row.person_id})
SET p += row
RETURN count(p) AS matched
"""

UPDATE_PBTYPE_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
SET p += row
RETURN count(p) AS matched
"""

IMPORT_SAFETYEQ_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
MERGE (s:SafetyEquipment {safety_equipment_id: row.safety_equipment_id})
SET s.name = row.safety_equipment_name,
    s.source_table = "SAFETYEQ"
MERGE (p)-[r:HAS_SAFETY_EQUIPMENT]->(s)
SET p.safetyeq_present = true,
    r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_column = row.source_column,
    r.source_value = row.source_value,
    r.source_name = row.source_name
FOREACH (_ IN CASE WHEN row.or_flag = 1 THEN [1] ELSE [] END | SET r.or_flag = 1)
FOREACH (_ IN CASE WHEN row.or_flag = 1 THEN [] ELSE [1] END | REMOVE r.or_flag)
RETURN count(r) AS matched
"""

UPDATE_SAFETYEQ_COUNTS_QUERY = """
MATCH (p:Person)-[:HAS_SAFETY_EQUIPMENT]->(s:SafetyEquipment)
WITH p, count(s) AS safetyeq_count
SET p.safetyeq_count = safetyeq_count
RETURN count(p) AS persons_with_safetyeq
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Import FARS Person, PBTYPE, and SAFETYEQ data into Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument(
        "--missing-mode",
        choices=("empty", "null"),
        default=os.getenv("FARS_MISSING_MODE", "empty"),
        help=(
            "Use 'empty' to store blank strings for missing columns. "
            "Use 'null' to omit missing properties in Neo4j."
        ),
    )
    parser.add_argument("--skip-person", action="store_true")
    parser.add_argument("--skip-pbtype", action="store_true")
    parser.add_argument("--skip-safetyeq", action="store_true")
    parser.add_argument("--ensure-constraints-only", action="store_true")
    parser.add_argument("--dry-run", action="store_true")
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return (
        str(name)
        .replace("\ufeff", "")
        .replace("", "")
        .strip()
        .upper()
    )


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def prefixed_pbtype_column(column: str) -> str:
    return f"PBTYPE_{column}"


def find_files(base: Path, table: str) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        csv_path = year_dir / "National" / f"{table}.csv"
        if csv_path.exists():
            files.append((int(year_dir.name), csv_path))
    return files


def read_header(csv_path: Path) -> list[str]:
    df = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    return normalize_columns(df.columns)


def build_person_columns(files: list[tuple[int, Path]]) -> list[str]:
    columns: list[str] = []
    seen: set[str] = set()
    for _, csv_path in files:
        for column in read_header(csv_path):
            if column not in seen:
                seen.add(column)
                columns.append(column)
    for extra in ("person_id", "accident_id", "vehicle_id", "DATA_YEAR", "source_table"):
        if extra not in seen:
            seen.add(extra)
            columns.append(extra)
    return columns


def build_pbtype_columns(files: list[tuple[int, Path]]) -> list[str]:
    columns: list[str] = []
    seen: set[str] = set()
    for _, csv_path in files:
        for column in read_header(csv_path):
            prefixed = prefixed_pbtype_column(column)
            if prefixed not in seen:
                seen.add(prefixed)
                columns.append(prefixed)
    for extra in ("person_id", "accident_id", "vehicle_id", "pbtype_year", "pbtype_present"):
        if extra not in seen:
            seen.add(extra)
            columns.append(extra)
    return columns


def build_safetyeq_relationship_columns() -> list[str]:
    return [
        "person_id",
        "safety_equipment_id",
        "safety_equipment_name",
        "DATA_YEAR",
        "source_table",
        "source_column",
        "source_value",
        "source_name",
        "or_flag",
    ]


def count_rows(csv_path: Path) -> int:
    with csv_path.open("r", encoding="latin-1", errors="replace", newline="") as fh:
        return max(sum(1 for _ in fh) - 1, 0)


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def clean_value(value: Any, missing_value: Any) -> Any:
    if value is None:
        return missing_value
    try:
        if pd.isna(value):
            return missing_value
    except (TypeError, ValueError):
        pass
    if hasattr(value, "item") and not isinstance(value, str):
        value = value.item()
    if isinstance(value, str):
        text = value.strip()
        return text if text else missing_value
    if isinstance(value, float):
        if math.isnan(value):
            return missing_value
        if math.isfinite(value) and value.is_integer():
            return int(value)
        return float(value)
    return value


def clean_row(row: dict[str, Any], missing_value: Any) -> dict[str, Any]:
    return {key: clean_value(value, missing_value) for key, value in row.items()}


def add_person_keys(chunk: pd.DataFrame, year: int) -> pd.DataFrame:
    st_case = chunk["ST_CASE"].map(identifier_part)
    veh_no = chunk["VEH_NO"].map(identifier_part)
    per_no = chunk["PER_NO"].map(identifier_part)
    chunk["accident_id"] = st_case.map(lambda value: f"{year}_{value}")
    chunk["vehicle_id"] = [
        f"{accident_id}_{vehicle_number}"
        for accident_id, vehicle_number in zip(chunk["accident_id"], veh_no)
    ]
    chunk["person_id"] = [
        f"{vehicle_id}_{person_number}"
        for vehicle_id, person_number in zip(chunk["vehicle_id"], per_no)
    ]
    return chunk


def is_yes_flag(value: Any, name_value: Any = None) -> bool:
    code = identifier_part(value)
    if code == "2":
        return True
    if name_value is not None:
        return str(name_value).strip().upper() == "YES"
    return False


def iter_person_batches(
    csv_path: Path,
    year: int,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(csv_path, encoding="latin-1", low_memory=False, chunksize=batch_size)
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        chunk = add_person_keys(chunk, year)
        chunk["DATA_YEAR"] = year
        chunk["source_table"] = "PERSON"
        chunk = chunk.reindex(columns=master_columns, fill_value=missing_value)
        chunk = chunk.where(pd.notna(chunk), missing_value)
        yield [clean_row(row, missing_value) for row in chunk.to_dict(orient="records")]


def iter_pbtype_batches(
    csv_path: Path,
    year: int,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(csv_path, encoding="latin-1", low_memory=False, chunksize=batch_size)
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        chunk = add_person_keys(chunk, year)
        chunk["pbtype_year"] = year
        chunk["pbtype_present"] = True
        rename = {
            column: prefixed_pbtype_column(column)
            for column in chunk.columns
            if column not in {"person_id", "accident_id", "vehicle_id", "pbtype_year", "pbtype_present"}
        }
        chunk = chunk.rename(columns=rename)
        chunk = chunk.reindex(columns=master_columns, fill_value=missing_value)
        chunk = chunk.where(pd.notna(chunk), missing_value)
        yield [clean_row(row, missing_value) for row in chunk.to_dict(orient="records")]


def iter_safetyeq_batches(
    csv_path: Path,
    year: int,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    del master_columns, missing_value

    chunks = pd.read_csv(csv_path, encoding="latin-1", low_memory=False, chunksize=batch_size)
    pending: list[dict[str, Any]] = []

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        chunk = add_person_keys(chunk, year)

        for row in chunk.to_dict(orient="records"):
            person_id = row["person_id"]
            if "MSAFEQMT" in row:
                source_value = identifier_part(row.get("MSAFEQMT"))
                source_name = clean_value(row.get("MSAFEQMTNAME"), "")
                for equipment_id, or_flag in OLD_SAFETYEQ_CODE_MAP.get(source_value, []):
                    pending.append(
                        {
                            "person_id": person_id,
                            "safety_equipment_id": equipment_id,
                            "safety_equipment_name": SAFETY_EQUIPMENT_TYPES[equipment_id],
                            "DATA_YEAR": year,
                            "source_table": "SAFETYEQ",
                            "source_column": "MSAFEQMT",
                            "source_value": source_value,
                            "source_name": source_name,
                            "or_flag": or_flag,
                        }
                    )
            else:
                for source_column, (equipment_id, name_column) in NEW_SAFETYEQ_FLAG_MAP.items():
                    if source_column not in row:
                        continue
                    source_name = row.get(name_column)
                    if not is_yes_flag(row.get(source_column), source_name):
                        continue
                    pending.append(
                        {
                            "person_id": person_id,
                            "safety_equipment_id": equipment_id,
                            "safety_equipment_name": SAFETY_EQUIPMENT_TYPES[equipment_id],
                            "DATA_YEAR": year,
                            "source_table": "SAFETYEQ",
                            "source_column": source_column,
                            "source_value": identifier_part(row.get(source_column)),
                            "source_name": clean_value(source_name, ""),
                            "or_flag": None,
                        }
                    )

            if len(pending) >= batch_size:
                yield [clean_row(rel, None) for rel in pending]
                pending = []

    if pending:
        yield [clean_row(rel, None) for rel in pending]


def quote_database_name(database: str) -> str:
    if not re.fullmatch(r"[A-Za-z0-9_.-]+", database):
        raise ValueError(f"Unsafe Neo4j database name: {database!r}")
    return f"`{database}`"


def get_databases(driver: Any) -> dict[str, str]:
    with driver.session(database="system") as session:
        result = session.run("SHOW DATABASES YIELD name, currentStatus RETURN name, currentStatus")
        return {record["name"]: record["currentStatus"] for record in result}


def ensure_database(driver: Any, database: str) -> None:
    status = get_databases(driver).get(database)
    if status == "online":
        return
    raise SystemExit(f"Neo4j database {database!r} is not online; current status is {status!r}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE",
        ":Person(person_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE INDEX person_accident_id IF NOT EXISTS FOR (p:Person) ON (p.accident_id)",
        ":Person(accident_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX person_vehicle_id IF NOT EXISTS FOR (p:Person) ON (p.vehicle_id)",
        ":Person(vehicle_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX person_pbtype_present IF NOT EXISTS FOR (p:Person) ON (p.pbtype_present)",
        ":Person(pbtype_present) index",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT safety_equipment_id IF NOT EXISTS FOR (s:SafetyEquipment) REQUIRE s.safety_equipment_id IS UNIQUE",
        ":SafetyEquipment(safety_equipment_id) unique constraint",
    )


def execute_batch(session: Any, query: str, rows: list[dict[str, Any]]) -> int:
    result = session.run(query, rows=rows)
    record = result.single()
    return int(record["matched"]) if record else 0


def load_files(
    session: Any,
    files: list[tuple[int, Path]],
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
    label: str,
    batch_iter: Any,
    query: str,
) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0

    for index, (year, csv_path) in enumerate(files, 1):
        start = time.time()
        year_rows = 0
        year_matched = 0

        for rows in batch_iter(csv_path, year, master_columns, batch_size, missing_value):
            matched = execute_batch(session, query, rows)
            year_rows += len(rows)
            year_matched += matched
            total_rows += len(rows)
            total_matched += matched

        elapsed = time.time() - start
        print(
            f"[{label} {index}/{len(files)}] Done {year}: "
            f"{year_matched:,}/{year_rows:,} matched in {seconds_text(elapsed)} ({format_duration(elapsed)})"
        )

    return total_rows, total_matched


def safetyeq_stats(files: list[tuple[int, Path]]) -> tuple[int, int, int, int, Counter[str]]:
    total_rows = 0
    person_ids: set[str] = set()
    duplicate_rows = 0
    relationship_count = 0
    equipment_counts: Counter[str] = Counter()

    for year, csv_path in files:
        df = pd.read_csv(csv_path, encoding="latin-1", low_memory=False)
        df.columns = normalize_columns(df.columns)
        st_case = df["ST_CASE"].map(identifier_part)
        veh_no = df["VEH_NO"].map(identifier_part)
        per_no = df["PER_NO"].map(identifier_part)
        person_id_values = [
            f"{year}_{case_number}_{vehicle_number}_{person_number}"
            for case_number, vehicle_number, person_number in zip(st_case, veh_no, per_no)
        ]
        total_rows += len(person_id_values)
        unique_this_file = set(person_id_values)
        duplicate_rows += len(person_id_values) - len(unique_this_file)
        person_ids.update(unique_this_file)

        if "MSAFEQMT" in df.columns:
            for value in df["MSAFEQMT"].map(identifier_part):
                for equipment_id, _ in OLD_SAFETYEQ_CODE_MAP.get(value, []):
                    relationship_count += 1
                    equipment_counts[equipment_id] += 1
        else:
            for source_column, (equipment_id, name_column) in NEW_SAFETYEQ_FLAG_MAP.items():
                if source_column not in df.columns:
                    continue
                names = df[name_column] if name_column in df.columns else [None] * len(df)
                for value, name_value in zip(df[source_column], names):
                    if is_yes_flag(value, name_value):
                        relationship_count += 1
                        equipment_counts[equipment_id] += 1

    return total_rows, len(person_ids), duplicate_rows, relationship_count, equipment_counts


def print_dry_run(
    person_files: list[tuple[int, Path]],
    pbtype_files: list[tuple[int, Path]],
    safetyeq_files: list[tuple[int, Path]],
    person_columns: list[str],
    pbtype_columns: list[str],
    safetyeq_columns: list[str],
) -> None:
    print(f"PERSON files           : {len(person_files)}")
    print(f"PERSON rows            : {sum(count_rows(path) for _, path in person_files):,}")
    print(f"PERSON properties      : {len(person_columns)}")
    print(f"PBTYPE files           : {len(pbtype_files)}")
    print(f"PBTYPE rows            : {sum(count_rows(path) for _, path in pbtype_files):,}")
    print(f"PBTYPE appended props  : {len(pbtype_columns)}")
    (
        safetyeq_rows,
        safetyeq_persons,
        safetyeq_duplicate_rows,
        safetyeq_relationships,
        safetyeq_equipment_counts,
    ) = safetyeq_stats(safetyeq_files)
    print(f"SAFETYEQ files         : {len(safetyeq_files)}")
    print(f"SAFETYEQ rows          : {safetyeq_rows:,}")
    print(f"SAFETYEQ persons       : {safetyeq_persons:,}")
    print(f"SAFETYEQ duplicate rows: {safetyeq_duplicate_rows:,}")
    print(f"SAFETYEQ relationships : {safetyeq_relationships:,}")
    print(f"SafetyEquipment nodes  : {len(SAFETY_EQUIPMENT_TYPES)}")
    print(f"SAFETYEQ by equipment  : {dict(sorted(safetyeq_equipment_counts.items()))}")
    print(f"PERSON years           : {person_files[0][0] if person_files else None}-{person_files[-1][0] if person_files else None}")
    print(f"PBTYPE years           : {pbtype_files[0][0] if pbtype_files else None}-{pbtype_files[-1][0] if pbtype_files else None}")
    print(f"SAFETYEQ years         : {safetyeq_files[0][0] if safetyeq_files else None}-{safetyeq_files[-1][0] if safetyeq_files else None}")
    print(f"First PERSON props     : {', '.join(person_columns[:10])}")
    print(f"Last PERSON props      : {', '.join(person_columns[-10:])}")
    print(f"First PBTYPE props     : {', '.join(pbtype_columns[:10])}")
    print(f"Last PBTYPE props      : {', '.join(pbtype_columns[-10:])}")
    print(f"SAFETYEQ rel props     : {', '.join(safetyeq_columns)}")


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()
    missing_value = "" if args.missing_mode == "empty" else None

    person_files = [] if args.skip_person else find_files(args.base, PERSON_TABLE)
    pbtype_files = [] if args.skip_pbtype else find_files(args.base, PBTYPE_TABLE)
    safetyeq_files = [] if args.skip_safetyeq else find_files(args.base, SAFETYEQ_TABLE)

    person_columns = build_person_columns(person_files) if person_files else []
    pbtype_columns = build_pbtype_columns(pbtype_files) if pbtype_files else []
    safetyeq_columns = build_safetyeq_relationship_columns() if safetyeq_files else []

    if args.dry_run:
        print_dry_run(
            person_files,
            pbtype_files,
            safetyeq_files,
            person_columns,
            pbtype_columns,
            safetyeq_columns,
        )
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        ensure_database(driver, args.database)

        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            if args.ensure_constraints_only:
                print(f"Constraints/indexes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI            : {args.uri}")
            print(f"Neo4j database       : {args.database}")
            print(f"Missing values       : {repr(missing_value)}")
            print(f"Script started UTC   : {utc_now()}")
            print("")

            person_rows = person_matched = 0
            pbtype_rows = pbtype_matched = 0
            safetyeq_rows = safetyeq_matched = 0

            if person_files:
                start = time.time()
                print(f"PERSON started UTC   : {utc_now()}")
                person_rows, person_matched = load_files(
                    session,
                    person_files,
                    person_columns,
                    args.batch_size,
                    missing_value,
                    "PERSON",
                    iter_person_batches,
                    MERGE_PERSON_QUERY,
                )
                print(f"PERSON seconds       : {time.time() - start:.3f}")
                print("")

            if pbtype_files:
                start = time.time()
                print(f"PBTYPE started UTC   : {utc_now()}")
                pbtype_rows, pbtype_matched = load_files(
                    session,
                    pbtype_files,
                    pbtype_columns,
                    args.batch_size,
                    missing_value,
                    "PBTYPE",
                    iter_pbtype_batches,
                    UPDATE_PBTYPE_QUERY,
                )
                print(f"PBTYPE seconds       : {time.time() - start:.3f}")
                print("")

            if safetyeq_files:
                start = time.time()
                print(f"SAFETYEQ started UTC : {utc_now()}")
                safetyeq_rows, safetyeq_matched = load_files(
                    session,
                    safetyeq_files,
                    safetyeq_columns,
                    args.batch_size,
                    missing_value,
                    "SAFETYEQ",
                    iter_safetyeq_batches,
                    IMPORT_SAFETYEQ_QUERY,
                )
                result = session.run(UPDATE_SAFETYEQ_COUNTS_QUERY).single()
                persons_with_safetyeq = result["persons_with_safetyeq"] if result else 0
                print(f"Updated safetyeq_count on {persons_with_safetyeq:,} Person nodes")
                print(f"SAFETYEQ seconds     : {time.time() - start:.3f}")

            total_elapsed = time.time() - total_start
            print("")
            print(f"Script finished UTC  : {utc_now()}")
            print(f"Total script seconds : {total_elapsed:.3f}")
            print(f"PERSON matched       : {person_matched:,}/{person_rows:,}")
            print(f"PBTYPE matched       : {pbtype_matched:,}/{pbtype_rows:,}")
            print(f"SAFETYEQ rels matched: {safetyeq_matched:,}/{safetyeq_rows:,}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Event Nodes (163.549s)

Average import time per year: 12.581s across 2010-2022.

python
#!/usr/bin/env python3
"""
Import FARS CEVENT rows into Neo4j as :Event nodes.

CEVENT starts in 2010 and stores the crash-level sequence of events. The key is:

    event_id = <year>_<ST_CASE>_<EVENTNUM>

The original FARS ST_CASE and EVENTNUM columns are kept as source properties.
accident_id = <year>_<ST_CASE> matches :Accident(accident_id). VNUMBER1,
VNUMBER2, AOI1, and AOI2 are preserved as properties for later relationship
creation to Vehicle nodes if we decide to model those edges.

This importer uses the union of CEVENT columns across years. Missing columns are
stored as blank strings by default so every :Event node has the same property
surface, including the 2015+ NAME columns that are absent in 2010-2014.
"""

from __future__ import annotations

import argparse
import math
import os
import re
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 5000
YEAR_MIN = 2010
YEAR_MAX = 2022
TABLE = "cevent"
REQUIRED_COLUMNS = {"ST_CASE", "EVENTNUM"}

MERGE_EVENT_QUERY = """
UNWIND $rows AS row
MERGE (e:Event {event_id: row.event_id})
SET e += row
RETURN count(e) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Import all FARS CEVENT columns into Neo4j as Event nodes.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument(
        "--missing-mode",
        choices=("empty", "null"),
        default=os.getenv("FARS_MISSING_MODE", "empty"),
        help=(
            "Use 'empty' to store blank strings for missing columns. "
            "Use 'null' to omit missing properties in Neo4j."
        ),
    )
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Event constraints/indexes, then exit without importing rows.",
    )
    parser.add_argument(
        "--check-ids",
        action="store_true",
        help="During dry-run, verify event_id uniqueness across CEVENT files.",
    )
    parser.add_argument("--dry-run", action="store_true", help="Inspect files/columns without connecting to Neo4j.")
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return (
        str(name)
        .replace("\ufeff", "")
        .replace("", "")
        .strip()
        .upper()
    )


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def find_cevent_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue

        year = int(year_dir.name)
        if not (YEAR_MIN <= year <= YEAR_MAX):
            continue

        national_dir = year_dir / "National"
        if not national_dir.is_dir():
            continue

        candidates = sorted(
            path
            for path in national_dir.iterdir()
            if path.is_file()
            and path.suffix.lower() == ".csv"
            and path.stem.lower() == TABLE
        )
        if candidates:
            files.append((year, candidates[0]))

    return files


def read_header(csv_path: Path) -> list[str]:
    df = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    return normalize_columns(df.columns)


def build_master_columns(files: list[tuple[int, Path]]) -> tuple[list[str], dict[str, set[int]]]:
    master_columns: list[str] = []
    seen: set[str] = set()
    presence: dict[str, set[int]] = {}

    for year, csv_path in files:
        columns = read_header(csv_path)
        missing_keys = REQUIRED_COLUMNS - set(columns)
        if missing_keys:
            raise ValueError(f"{csv_path} is missing required columns: {sorted(missing_keys)}")

        for column in columns:
            presence.setdefault(column, set()).add(year)
            if column not in seen:
                seen.add(column)
                master_columns.append(column)

    for extra in ("event_id", "accident_id", "DATA_YEAR", "source_table"):
        if extra not in seen:
            seen.add(extra)
            master_columns.append(extra)
            presence[extra] = {year for year, _ in files}

    return master_columns, presence


def count_rows(csv_path: Path) -> int:
    with csv_path.open("r", encoding="latin-1", errors="replace", newline="") as fh:
        return max(sum(1 for _ in fh) - 1, 0)


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def clean_value(value: Any, missing_value: Any) -> Any:
    if value is None:
        return missing_value
    try:
        if pd.isna(value):
            return missing_value
    except (TypeError, ValueError):
        pass
    if hasattr(value, "item") and not isinstance(value, str):
        value = value.item()
    if isinstance(value, str):
        text = value.strip()
        return text if text else missing_value
    if isinstance(value, float):
        if math.isnan(value):
            return missing_value
        if math.isfinite(value) and value.is_integer():
            return int(value)
        return float(value)
    return value


def clean_row(row: dict[str, Any], missing_value: Any) -> dict[str, Any]:
    return {key: clean_value(value, missing_value) for key, value in row.items()}


def add_event_keys(chunk: pd.DataFrame, year: int) -> pd.DataFrame:
    st_case = chunk["ST_CASE"].map(identifier_part)
    eventnum = chunk["EVENTNUM"].map(identifier_part)
    chunk["accident_id"] = st_case.map(lambda value: f"{year}_{value}")
    chunk["event_id"] = [
        f"{accident_id}_{event_number}"
        for accident_id, event_number in zip(chunk["accident_id"], eventnum)
    ]
    chunk["DATA_YEAR"] = year
    chunk["source_table"] = "CEVENT"
    return chunk


def iter_event_batches(
    csv_path: Path,
    year: int,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(csv_path, encoding="latin-1", low_memory=False, chunksize=batch_size)
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        chunk = add_event_keys(chunk, year)
        chunk = chunk.reindex(columns=master_columns, fill_value=missing_value)
        chunk = chunk.where(pd.notna(chunk), missing_value)
        yield [clean_row(row, missing_value) for row in chunk.to_dict(orient="records")]


def quote_database_name(database: str) -> str:
    if not re.fullmatch(r"[A-Za-z0-9_.-]+", database):
        raise ValueError(f"Unsafe Neo4j database name: {database!r}")
    return f"`{database}`"


def get_databases(driver: Any) -> dict[str, str]:
    with driver.session(database="system") as session:
        result = session.run("SHOW DATABASES YIELD name, currentStatus RETURN name, currentStatus")
        return {record["name"]: record["currentStatus"] for record in result}


def ensure_database(driver: Any, database: str) -> None:
    status = get_databases(driver).get(database)
    if status == "online":
        return
    raise SystemExit(f"Neo4j database {database!r} is not online; current status is {status!r}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT event_id IF NOT EXISTS FOR (e:Event) REQUIRE e.event_id IS UNIQUE",
        ":Event(event_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE INDEX event_accident_id IF NOT EXISTS FOR (e:Event) ON (e.accident_id)",
        ":Event(accident_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX event_source_table IF NOT EXISTS FOR (e:Event) ON (e.source_table)",
        ":Event(source_table) index",
    )


def execute_batch(session: Any, rows: list[dict[str, Any]]) -> int:
    result = session.run(MERGE_EVENT_QUERY, rows=rows)
    record = result.single()
    return int(record["matched"]) if record else 0


def load_files(
    session: Any,
    files: list[tuple[int, Path]],
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0

    for index, (year, csv_path) in enumerate(files, 1):
        start = time.time()
        year_rows = 0
        year_matched = 0

        for rows in iter_event_batches(csv_path, year, master_columns, batch_size, missing_value):
            matched = execute_batch(session, rows)
            year_rows += len(rows)
            year_matched += matched
            total_rows += len(rows)
            total_matched += matched

        elapsed = time.time() - start
        print(
            f"[CEVENT {index}/{len(files)}] Done {year}: "
            f"{year_matched:,}/{year_rows:,} matched in {seconds_text(elapsed)} ({format_duration(elapsed)})"
        )

    return total_rows, total_matched


def check_event_ids(files: list[tuple[int, Path]]) -> tuple[int, int]:
    seen: set[str] = set()
    duplicates = 0

    for year, csv_path in files:
        chunks = pd.read_csv(
            csv_path,
            encoding="latin-1",
            low_memory=False,
            chunksize=100_000,
            usecols=lambda c: normalize_column(c) in REQUIRED_COLUMNS,
        )
        for chunk in chunks:
            chunk.columns = normalize_columns(chunk.columns)
            st_case = chunk["ST_CASE"].map(identifier_part)
            eventnum = chunk["EVENTNUM"].map(identifier_part)
            for case_number, event_number in zip(st_case, eventnum):
                event_id = f"{year}_{case_number}_{event_number}"
                if event_id in seen:
                    duplicates += 1
                else:
                    seen.add(event_id)

    return len(seen), duplicates


def print_dry_run(
    files: list[tuple[int, Path]],
    master_columns: list[str],
    presence: dict[str, set[int]],
    check_ids: bool,
) -> None:
    row_counts = Counter()
    total_rows = 0
    for year, csv_path in files:
        rows = count_rows(csv_path)
        row_counts[year] = rows
        total_rows += rows

    missing_some_years = [
        column
        for column, years in presence.items()
        if len(years) != len(files)
    ]
    years = [year for year, _ in files]

    print(f"CEVENT files       : {len(files)}")
    print(f"CEVENT years       : {min(years) if years else None}-{max(years) if years else None}")
    print(f"CEVENT rows        : {total_rows:,}")
    print(f"Event properties   : {len(master_columns)}")
    print(f"Vary by year       : {len(missing_some_years)} columns")
    print(f"First columns      : {', '.join(master_columns[:12])}")
    print(f"Last columns       : {', '.join(master_columns[-12:])}")

    if missing_some_years:
        examples = ", ".join(missing_some_years[:20])
        print(f"Examples with gaps : {examples}")

    print("Rows by year       :")
    for year in sorted(row_counts):
        print(f"  {year}: {row_counts[year]:,}")

    if check_ids:
        unique_ids, duplicates = check_event_ids(files)
        print(f"Unique event_id    : {unique_ids:,}")
        print(f"Duplicate event_id : {duplicates:,}")


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()
    missing_value = "" if args.missing_mode == "empty" else None

    start = time.time()
    files = find_cevent_files(args.base)
    if not files:
        raise SystemExit(f"No CEVENT CSV files found under {args.base}")

    master_columns, presence = build_master_columns(files)

    if args.dry_run:
        print_dry_run(files, master_columns, presence, args.check_ids)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        ensure_database(driver, args.database)

        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            if args.ensure_constraints_only:
                print(f"Constraints/indexes ensured on database {args.database!r}")
                return

            print(f"CEVENT files          : {len(files)}")
            print(f"Event properties      : {len(master_columns)}")
            print(f"Missing values        : {repr(missing_value)}")
            print(f"Neo4j URI             : {args.uri}")
            print(f"Neo4j database        : {args.database}")
            print(f"Script started UTC    : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Event load started UTC: {utc_now()}")
            rows_processed, rows_matched = load_files(
                session,
                files,
                master_columns,
                args.batch_size,
                missing_value,
            )

        load_elapsed = time.time() - load_start
        total_elapsed = time.time() - start
        print(f"Event load finished UTC: {utc_now()}")
        print(f"Event load seconds     : {load_elapsed:.3f}")
        print(f"Total script seconds   : {total_elapsed:.3f}")
        print(
            f"\nTotal Event rows processed: {rows_processed:,}; "
            f"matched/merged: {rows_matched:,} in {seconds_text(load_elapsed)} ({format_duration(load_elapsed)})"
        )
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Append VPIC to Vehicle Nodes (1200.171s)

Average VPICDECODE time per year: 70.263s across 2006-2022.

Average VPICTRAILERDECODE time per year: 0.656s across 2016-2022.

python
#!/usr/bin/env python3
"""
Append VPIC decode data to existing FARS :Vehicle nodes and create trailer nodes.

VPICDECODE is one row per decoded vehicle, so its columns are appended directly
to :Vehicle with a VPICDECODE_ prefix to avoid overwriting core FARS columns
such as MAKE, MODEL, BODY_TYP, or MOD_YEAR.

VPICTRAILERDECODE is one row per decoded trailer attached to a towing vehicle.
It is keyed by DATA_YEAR, ST_CASE, VEH_NO, and TRAILER_NO, where VEH_NO is the
towing vehicle and TRAILER_NO is the trailer slot. These rows are imported as
:Trailer nodes with:

    trailer_id = <year>_<ST_CASE>_<VEH_NO>_<TRAILER_NO>

and connected to their towing vehicle:

    (:Vehicle)-[:HAS_TRAILER]->(:Trailer)

Run this after the base Vehicle import has completed. The script uses MATCH on
Vehicle(vehicle_id), so it does not create incomplete Vehicle nodes.
"""

from __future__ import annotations

import argparse
import math
import os
import re
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 5000

VPICDECODE_TABLE = "vpicdecode"
VPICTRAILERDECODE_TABLE = "vpictrailerdecode"

UPDATE_VPICDECODE_QUERY = """
UNWIND $rows AS row
MATCH (v:Vehicle {vehicle_id: row.vehicle_id})
SET v += row
RETURN count(v) AS matched
"""

IMPORT_TRAILER_QUERY = """
UNWIND $rows AS row
MATCH (v:Vehicle {vehicle_id: row.vehicle_id})
MERGE (t:Trailer {trailer_id: row.trailer_id})
SET t += row
MERGE (v)-[r:HAS_TRAILER]->(t)
SET r.trailer_no = row.TRAILER_NO,
    r.source_table = row.source_table,
    r.DATA_YEAR = row.DATA_YEAR,
    v.has_vpic_trailer_decode = true
RETURN count(t) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Append VPIC decode data to FARS Vehicle nodes.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument(
        "--missing-mode",
        choices=("empty", "null"),
        default=os.getenv("FARS_MISSING_MODE", "empty"),
        help=(
            "Use 'empty' to store blank strings for missing columns. "
            "Use 'null' to omit missing properties in Neo4j."
        ),
    )
    parser.add_argument("--skip-vpicdecode", action="store_true")
    parser.add_argument("--skip-trailers", action="store_true")
    parser.add_argument("--ensure-constraints-only", action="store_true")
    parser.add_argument("--dry-run", action="store_true")
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def normalize_column(name: Any) -> str:
    return (
        str(name)
        .replace("\ufeff", "")
        .replace("", "")
        .strip()
        .upper()
    )


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}

    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)

    return normalized


def prefixed_vpicdecode_column(column: str) -> str:
    return f"VPICDECODE_{column}"


def find_files(base: Path, table: str) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        csv_path = year_dir / "National" / f"{table}.csv"
        if csv_path.exists():
            files.append((int(year_dir.name), csv_path))
    return files


def read_header(csv_path: Path) -> list[str]:
    df = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    return normalize_columns(df.columns)


def build_vpicdecode_columns(files: list[tuple[int, Path]]) -> list[str]:
    columns: list[str] = []
    seen: set[str] = set()

    for _, csv_path in files:
        for column in read_header(csv_path):
            prefixed = prefixed_vpicdecode_column(column)
            if prefixed not in seen:
                seen.add(prefixed)
                columns.append(prefixed)

    for extra in ("vehicle_id", "accident_id", "vpicdecode_year", "vpicdecode_present"):
        if extra not in seen:
            columns.append(extra)

    return columns


def build_trailer_columns(files: list[tuple[int, Path]]) -> list[str]:
    columns: list[str] = []
    seen: set[str] = set()

    for _, csv_path in files:
        for column in read_header(csv_path):
            if column not in seen:
                seen.add(column)
                columns.append(column)

    for extra in (
        "trailer_id",
        "vehicle_id",
        "accident_id",
        "DATA_YEAR",
        "source_table",
    ):
        if extra not in seen:
            seen.add(extra)
            columns.append(extra)

    return columns


def count_rows(csv_path: Path) -> int:
    with csv_path.open("r", encoding="latin-1", errors="replace", newline="") as fh:
        return max(sum(1 for _ in fh) - 1, 0)


def trailer_id_stats(files: list[tuple[int, Path]]) -> tuple[int, int, int]:
    total_rows = 0
    trailer_ids: set[str] = set()

    for year, csv_path in files:
        df = pd.read_csv(
            csv_path,
            encoding="latin-1",
            low_memory=False,
            usecols=["ST_CASE", "VEH_NO", "TRAILER_NO"],
        )
        df.columns = normalize_columns(df.columns)
        st_case = df["ST_CASE"].map(identifier_part)
        veh_no = df["VEH_NO"].map(identifier_part)
        trailer_no = df["TRAILER_NO"].map(identifier_part)
        ids = [
            f"{year}_{case_number}_{vehicle_number}_{trailer_number}"
            for case_number, vehicle_number, trailer_number in zip(st_case, veh_no, trailer_no)
        ]
        total_rows += len(ids)
        trailer_ids.update(ids)

    return total_rows, len(trailer_ids), total_rows - len(trailer_ids)


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def clean_value(value: Any, missing_value: Any) -> Any:
    if value is None:
        return missing_value

    if isinstance(value, list):
        cleaned_list = []
        for item in value:
            cleaned_item = clean_value(item, missing_value)
            if cleaned_item not in (None, ""):
                cleaned_list.append(cleaned_item)
        return cleaned_list

    try:
        if pd.isna(value):
            return missing_value
    except (TypeError, ValueError):
        pass

    if hasattr(value, "item") and not isinstance(value, str):
        value = value.item()

    if isinstance(value, str):
        text = value.strip()
        return text if text else missing_value

    if isinstance(value, float):
        if math.isnan(value):
            return missing_value
        if math.isfinite(value) and value.is_integer():
            return int(value)
        return float(value)

    return value


def clean_row(row: dict[str, Any], missing_value: Any) -> dict[str, Any]:
    return {key: clean_value(value, missing_value) for key, value in row.items()}


def complete_rows(
    rows: list[dict[str, Any]],
    master_columns: list[str],
    missing_value: Any,
) -> list[dict[str, Any]]:
    return [
        clean_row(
            {column: row.get(column, missing_value) for column in master_columns},
            missing_value,
        )
        for row in rows
    ]


def add_vehicle_keys(chunk: pd.DataFrame, year: int) -> pd.DataFrame:
    st_case = chunk["ST_CASE"].map(identifier_part)
    veh_no = chunk["VEH_NO"].map(identifier_part)
    chunk["accident_id"] = st_case.map(lambda value: f"{year}_{value}")
    chunk["vehicle_id"] = [
        f"{accident_id}_{vehicle_number}"
        for accident_id, vehicle_number in zip(chunk["accident_id"], veh_no)
    ]
    return chunk


def iter_vpicdecode_batches(
    csv_path: Path,
    year: int,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(csv_path, encoding="latin-1", low_memory=False, chunksize=batch_size)

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        chunk = add_vehicle_keys(chunk, year)
        chunk["vpicdecode_year"] = year
        chunk["vpicdecode_present"] = True

        rename = {
            column: prefixed_vpicdecode_column(column)
            for column in chunk.columns
            if column not in {"vehicle_id", "accident_id", "vpicdecode_year", "vpicdecode_present"}
        }
        chunk = chunk.rename(columns=rename)
        chunk = chunk.reindex(columns=master_columns, fill_value=missing_value)
        chunk = chunk.where(pd.notna(chunk), missing_value)

        yield [clean_row(row, missing_value) for row in chunk.to_dict(orient="records")]


def add_trailer_keys(chunk: pd.DataFrame, year: int) -> pd.DataFrame:
    st_case = chunk["ST_CASE"].map(identifier_part)
    veh_no = chunk["VEH_NO"].map(identifier_part)
    trailer_no = chunk["TRAILER_NO"].map(identifier_part)
    chunk["accident_id"] = st_case.map(lambda value: f"{year}_{value}")
    chunk["vehicle_id"] = [
        f"{accident_id}_{vehicle_number}"
        for accident_id, vehicle_number in zip(chunk["accident_id"], veh_no)
    ]
    chunk["trailer_id"] = [
        f"{vehicle_id}_{trailer_number}"
        for vehicle_id, trailer_number in zip(chunk["vehicle_id"], trailer_no)
    ]
    return chunk


def iter_trailer_batches(
    csv_path: Path,
    year: int,
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(csv_path, encoding="latin-1", low_memory=False, chunksize=batch_size)

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        chunk = add_trailer_keys(chunk, year)
        chunk["DATA_YEAR"] = year
        chunk["source_table"] = "VPICTRAILERDECODE"
        chunk = chunk.reindex(columns=master_columns, fill_value=missing_value)
        chunk = chunk.where(pd.notna(chunk), missing_value)

        yield [clean_row(row, missing_value) for row in chunk.to_dict(orient="records")]


def quote_database_name(database: str) -> str:
    if not re.fullmatch(r"[A-Za-z0-9_.-]+", database):
        raise ValueError(f"Unsafe Neo4j database name: {database!r}")
    return f"`{database}`"


def get_databases(driver: Any) -> dict[str, str]:
    with driver.session(database="system") as session:
        result = session.run("SHOW DATABASES YIELD name, currentStatus RETURN name, currentStatus")
        return {record["name"]: record["currentStatus"] for record in result}


def ensure_database(driver: Any, database: str) -> None:
    status = get_databases(driver).get(database)
    if status == "online":
        return
    raise SystemExit(f"Neo4j database {database!r} is not online; current status is {status!r}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE INDEX vehicle_vpicdecode_present IF NOT EXISTS FOR (v:Vehicle) ON (v.vpicdecode_present)",
        ":Vehicle(vpicdecode_present) index",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT trailer_id IF NOT EXISTS FOR (t:Trailer) REQUIRE t.trailer_id IS UNIQUE",
        ":Trailer(trailer_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE INDEX trailer_vehicle_id IF NOT EXISTS FOR (t:Trailer) ON (t.vehicle_id)",
        ":Trailer(vehicle_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX trailer_data_year IF NOT EXISTS FOR (t:Trailer) ON (t.DATA_YEAR)",
        ":Trailer(DATA_YEAR) index",
    )


def execute_batch(session: Any, query: str, rows: list[dict[str, Any]]) -> int:
    result = session.run(query, rows=rows)
    record = result.single()
    return int(record["matched"]) if record else 0


def print_dry_run(
    vpic_files: list[tuple[int, Path]],
    trailer_files: list[tuple[int, Path]],
    vpic_columns: list[str],
    trailer_columns: list[str],
) -> None:
    print(f"VPICDECODE files        : {len(vpic_files)}")
    print(f"VPICDECODE rows         : {sum(count_rows(path) for _, path in vpic_files):,}")
    print(f"VPICDECODE properties   : {len(vpic_columns)}")
    print(f"VPICTRAILER files       : {len(trailer_files)}")
    trailer_rows, distinct_trailers, duplicate_trailers = trailer_id_stats(trailer_files)
    print(f"VPICTRAILER rows        : {trailer_rows:,}")
    print(f"Trailer nodes           : {distinct_trailers:,}")
    print(f"Duplicate trailer IDs   : {duplicate_trailers:,}")
    print(f"Trailer properties      : {len(trailer_columns)}")

    table_years = Counter()
    for year, _ in vpic_files:
        table_years["vpicdecode"] += 1
    for year, _ in trailer_files:
        table_years["vpictrailerdecode"] += 1
    print(f"File counts by table    : {dict(table_years)}")
    print(f"First VPIC properties   : {', '.join(vpic_columns[:10])}")
    print(f"Last VPIC properties    : {', '.join(vpic_columns[-10:])}")
    print(f"Trailer properties      : {', '.join(trailer_columns[:10])} ... {', '.join(trailer_columns[-10:])}")


def append_vpicdecode(
    session: Any,
    files: list[tuple[int, Path]],
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0

    for index, (year, csv_path) in enumerate(files, 1):
        start = time.time()
        year_rows = 0
        year_matched = 0

        for rows in iter_vpicdecode_batches(csv_path, year, master_columns, batch_size, missing_value):
            matched = execute_batch(session, UPDATE_VPICDECODE_QUERY, rows)
            year_rows += len(rows)
            year_matched += matched
            total_rows += len(rows)
            total_matched += matched

        elapsed = time.time() - start
        print(
            f"[VPICDECODE {index}/{len(files)}] Done {year}: "
            f"{year_matched:,}/{year_rows:,} matched in {seconds_text(elapsed)} ({format_duration(elapsed)})"
        )

    return total_rows, total_matched


def append_trailerdecode(
    session: Any,
    files: list[tuple[int, Path]],
    master_columns: list[str],
    batch_size: int,
    missing_value: Any,
) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0

    for index, (year, csv_path) in enumerate(files, 1):
        start = time.time()
        year_rows = 0
        year_matched = 0

        for rows in iter_trailer_batches(csv_path, year, master_columns, batch_size, missing_value):
            matched = execute_batch(session, IMPORT_TRAILER_QUERY, rows)
            year_rows += len(rows)
            year_matched += matched
            total_rows += len(rows)
            total_matched += matched

        elapsed = time.time() - start
        print(
            f"[VPICTRAILER {index}/{len(files)}] Done {year}: "
            f"{year_matched:,}/{year_rows:,} Trailer nodes matched in "
            f"{seconds_text(elapsed)} ({format_duration(elapsed)})"
        )

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()
    missing_value = "" if args.missing_mode == "empty" else None

    vpic_files = [] if args.skip_vpicdecode else find_files(args.base, VPICDECODE_TABLE)
    trailer_files = [] if args.skip_trailers else find_files(args.base, VPICTRAILERDECODE_TABLE)
    vpic_columns = build_vpicdecode_columns(vpic_files) if vpic_files else []
    trailer_columns = build_trailer_columns(trailer_files) if trailer_files else []

    if args.dry_run:
        print_dry_run(vpic_files, trailer_files, vpic_columns, trailer_columns)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        ensure_database(driver, args.database)

        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            if args.ensure_constraints_only:
                print(f"Constraints/indexes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI              : {args.uri}")
            print(f"Neo4j database         : {args.database}")
            print(f"Missing values         : {repr(missing_value)}")
            print(f"Script started UTC     : {utc_now()}")
            print("")

            vpic_rows = vpic_matched = 0
            trailer_rows = trailer_matched = 0

            if vpic_files:
                vpic_start = time.time()
                print(f"VPICDECODE started UTC : {utc_now()}")
                vpic_rows, vpic_matched = append_vpicdecode(
                    session, vpic_files, vpic_columns, args.batch_size, missing_value
                )
                print(f"VPICDECODE seconds     : {time.time() - vpic_start:.3f}")
                print("")

            if trailer_files:
                trailer_start = time.time()
                print(f"VPICTRAILER started UTC: {utc_now()}")
                trailer_rows, trailer_matched = append_trailerdecode(
                    session, trailer_files, trailer_columns, args.batch_size, missing_value
                )
                print(f"VPICTRAILER seconds    : {time.time() - trailer_start:.3f}")

            total_elapsed = time.time() - total_start
            print("")
            print(f"Script finished UTC    : {utc_now()}")
            print(f"Total script seconds   : {total_elapsed:.3f}")
            print(f"VPICDECODE matched     : {vpic_matched:,}/{vpic_rows:,}")
            print(f"VPICTRAILER matched    : {trailer_matched:,}/{trailer_rows:,} Trailer nodes")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Creating Edges Between Core Nodes (220.524s)

  • ACCIDENT-[:INVOLVES]->VEHICLE: 41.224s
  • ACCIDENT-[:HAS_VICTIM]->PERSON: 63.819s
  • ACCIDENT-[:RESULTED_IN]->EVENT: 18.994s
  • VEHICLE-[:HAS_OCCUPANT]->PERSON: 64.910s
  • VEHICLE-[:SUBJECT_OF]->EVENT: 24.709s
  • VEHICLE-[:OBJECT_OF]->EVENT: 6.868s
python
#!/usr/bin/env python3
"""
Create core FARS graph relationships in Neo4j.

This script assumes the node importers have already created:

  :Accident(accident_id)
  :Vehicle(vehicle_id, accident_id)
  :Person(person_id, accident_id, vehicle_id)
  :Event(event_id, accident_id)

Important: raw ST_CASE is not globally unique across years. All joins here use
the generated year-prefixed IDs so relationships cannot cross-link different
years that reused the same ST_CASE value.

Relationships created:

  (:Accident)-[:INVOLVES]->(:Vehicle)
  (:Accident)-[:HAS_VICTIM]->(:Person)
  (:Accident)-[:RESULTED_IN]->(:Event)
  (:Vehicle)-[:HAS_OCCUPANT]->(:Person)
  (:Vehicle)-[:SUBJECT_OF]->(:Event)
  (:Vehicle)-[:OBJECT_OF]->(:Event)

Vehicle-to-Event relationships here are based on CEVENT's VNUMBER1/VNUMBER2.
VEVENT should be imported separately later if we want vehicle-specific event
sequence order through VEVENTNUM.
"""

from __future__ import annotations

import argparse
import os
import re
import sys
import time
from datetime import datetime, timezone
from typing import Any


DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

APOC_ITERATE_QUERY = """
CALL apoc.periodic.iterate(
  $source,
  $action,
  {batchSize: $batch_size, parallel: false, iterateList: true}
)
YIELD batches, total, timeTaken, committedOperations, failedOperations, failedBatches, retries, errorMessages
RETURN batches, total, timeTaken, committedOperations, failedOperations, failedBatches, retries, errorMessages
"""


EDGES: list[dict[str, str]] = [
    {
        "label": "ACCIDENT-[:INVOLVES]->VEHICLE",
        "source": """
            MATCH (v:Vehicle)
            WHERE v.accident_id IS NOT NULL AND v.accident_id <> ''
            MATCH (a:Accident {accident_id: v.accident_id})
            RETURN a, v
        """,
        "action": """
            MERGE (a)-[r:INVOLVES]->(v)
            SET r.source_key = 'vehicle.accident_id',
                r.created_from = 'core_edges'
        """,
        "count_query": """
            MATCH (v:Vehicle)
            WHERE v.accident_id IS NOT NULL AND v.accident_id <> ''
            MATCH (:Accident {accident_id: v.accident_id})
            RETURN count(v) AS expected
        """,
    },
    {
        "label": "ACCIDENT-[:HAS_VICTIM]->PERSON",
        "source": """
            MATCH (p:Person)
            WHERE p.accident_id IS NOT NULL AND p.accident_id <> ''
            MATCH (a:Accident {accident_id: p.accident_id})
            RETURN a, p
        """,
        "action": """
            MERGE (a)-[r:HAS_VICTIM]->(p)
            SET r.source_key = 'person.accident_id',
                r.created_from = 'core_edges'
        """,
        "count_query": """
            MATCH (p:Person)
            WHERE p.accident_id IS NOT NULL AND p.accident_id <> ''
            MATCH (:Accident {accident_id: p.accident_id})
            RETURN count(p) AS expected
        """,
    },
    {
        "label": "ACCIDENT-[:RESULTED_IN]->EVENT",
        "source": """
            MATCH (e:Event)
            WHERE e.accident_id IS NOT NULL AND e.accident_id <> ''
            MATCH (a:Accident {accident_id: e.accident_id})
            RETURN a, e
        """,
        "action": """
            MERGE (a)-[r:RESULTED_IN]->(e)
            SET r.source_key = 'event.accident_id',
                r.created_from = 'core_edges'
        """,
        "count_query": """
            MATCH (e:Event)
            WHERE e.accident_id IS NOT NULL AND e.accident_id <> ''
            MATCH (:Accident {accident_id: e.accident_id})
            RETURN count(e) AS expected
        """,
    },
    {
        "label": "VEHICLE-[:HAS_OCCUPANT]->PERSON",
        "source": """
            MATCH (p:Person)
            WHERE p.vehicle_id IS NOT NULL AND p.vehicle_id <> ''
            MATCH (v:Vehicle {vehicle_id: p.vehicle_id})
            RETURN v, p
        """,
        "action": """
            MERGE (v)-[r:HAS_OCCUPANT]->(p)
            SET r.source_key = 'person.vehicle_id',
                r.created_from = 'core_edges'
        """,
        "count_query": """
            MATCH (p:Person)
            WHERE p.vehicle_id IS NOT NULL AND p.vehicle_id <> ''
            MATCH (:Vehicle {vehicle_id: p.vehicle_id})
            RETURN count(p) AS expected
        """,
    },
    {
        "label": "VEHICLE-[:SUBJECT_OF]->EVENT",
        "source": """
            MATCH (e:Event)
            WITH e, toInteger(e.VNUMBER1) AS vehicle_number
            WHERE vehicle_number >= 1 AND vehicle_number <= 999
            MATCH (v:Vehicle {vehicle_id: e.accident_id + '_' + toString(vehicle_number)})
            RETURN v, e, vehicle_number
        """,
        "action": """
            MERGE (v)-[r:SUBJECT_OF]->(e)
            SET r.role = 'this_vehicle',
                r.vehicle_number = vehicle_number,
                r.area_of_impact = e.AOI1,
                r.eventnum = e.EVENTNUM,
                r.DATA_YEAR = e.DATA_YEAR,
                r.source_table = 'CEVENT',
                r.source_columns = 'VNUMBER1,AOI1',
                r.created_from = 'core_edges'
        """,
        "count_query": """
            MATCH (e:Event)
            WITH e, toInteger(e.VNUMBER1) AS vehicle_number
            WHERE vehicle_number >= 1 AND vehicle_number <= 999
            MATCH (:Vehicle {vehicle_id: e.accident_id + '_' + toString(vehicle_number)})
            RETURN count(e) AS expected
        """,
    },
    {
        "label": "VEHICLE-[:OBJECT_OF]->EVENT",
        "source": """
            MATCH (e:Event)
            WITH e, toInteger(e.VNUMBER2) AS vehicle_number
            WHERE vehicle_number >= 1 AND vehicle_number <= 999
            MATCH (v:Vehicle {vehicle_id: e.accident_id + '_' + toString(vehicle_number)})
            RETURN v, e, vehicle_number
        """,
        "action": """
            MERGE (v)-[r:OBJECT_OF]->(e)
            SET r.role = 'other_vehicle',
                r.vehicle_number = vehicle_number,
                r.area_of_impact = e.AOI2,
                r.eventnum = e.EVENTNUM,
                r.DATA_YEAR = e.DATA_YEAR,
                r.source_table = 'CEVENT',
                r.source_columns = 'VNUMBER2,AOI2',
                r.created_from = 'core_edges'
        """,
        "count_query": """
            MATCH (e:Event)
            WITH e, toInteger(e.VNUMBER2) AS vehicle_number
            WHERE vehicle_number >= 1 AND vehicle_number <= 999
            MATCH (:Vehicle {vehicle_id: e.accident_id + '_' + toString(vehicle_number)})
            RETURN count(e) AS expected
        """,
    },
]

POST_COUNT_QUERIES: list[tuple[str, str]] = [
    ("INVOLVES", "MATCH ()-[r:INVOLVES]->() RETURN count(r) AS count"),
    ("HAS_VICTIM", "MATCH ()-[r:HAS_VICTIM]->() RETURN count(r) AS count"),
    ("RESULTED_IN", "MATCH ()-[r:RESULTED_IN]->() RETURN count(r) AS count"),
    ("HAS_OCCUPANT", "MATCH ()-[r:HAS_OCCUPANT]->() RETURN count(r) AS count"),
    ("SUBJECT_OF", "MATCH ()-[r:SUBJECT_OF]->() RETURN count(r) AS count"),
    ("OBJECT_OF", "MATCH ()-[r:OBJECT_OF]->() RETURN count(r) AS count"),
]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create core FARS relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Print expected relationship counts only.")
    parser.add_argument(
        "--ensure-indexes-only",
        action="store_true",
        help="Only ensure supporting indexes/constraints, then exit.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def quote_database_name(database: str) -> str:
    if not re.fullmatch(r"[A-Za-z0-9_.-]+", database):
        raise ValueError(f"Unsafe Neo4j database name: {database!r}")
    return f"`{database}`"


def get_databases(driver: Any) -> dict[str, str]:
    with driver.session(database="system") as session:
        result = session.run("SHOW DATABASES YIELD name, currentStatus RETURN name, currentStatus")
        return {record["name"]: record["currentStatus"] for record in result}


def ensure_database(driver: Any, database: str) -> None:
    status = get_databases(driver).get(database)
    if status == "online":
        return
    raise SystemExit(f"Neo4j database {database!r} is not online; current status is {status!r}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_indexes(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT accident_id IF NOT EXISTS FOR (a:Accident) REQUIRE a.accident_id IS UNIQUE",
        ":Accident(accident_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE",
        ":Person(person_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT event_id IF NOT EXISTS FOR (e:Event) REQUIRE e.event_id IS UNIQUE",
        ":Event(event_id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE INDEX vehicle_accident_id IF NOT EXISTS FOR (v:Vehicle) ON (v.accident_id)",
        ":Vehicle(accident_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX person_accident_id IF NOT EXISTS FOR (p:Person) ON (p.accident_id)",
        ":Person(accident_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX person_vehicle_id IF NOT EXISTS FOR (p:Person) ON (p.vehicle_id)",
        ":Person(vehicle_id) index",
    )
    run_ddl(
        session,
        "CREATE INDEX event_accident_id IF NOT EXISTS FOR (e:Event) ON (e.accident_id)",
        ":Event(accident_id) index",
    )


def print_dry_run(session: Any) -> None:
    print("Expected relationships from current node keys:")
    for edge in EDGES:
        start = time.time()
        record = session.run(edge["count_query"]).single()
        elapsed = time.time() - start
        expected = int(record["expected"]) if record else 0
        print(f"  {edge['label']:<42} {expected:>12,} in {seconds_text(elapsed)}")

    print("\nExisting relationship counts:")
    for label, query in POST_COUNT_QUERIES:
        record = session.run(query).single()
        count = int(record["count"]) if record else 0
        print(f"  {label:<18} {count:>12,}")


def run_edge(session: Any, edge: dict[str, str], batch_size: int) -> dict[str, Any]:
    result = session.run(
        APOC_ITERATE_QUERY,
        source=edge["source"],
        action=edge["action"],
        batch_size=batch_size,
    )
    record = result.single()
    return dict(record) if record else {}


def import_edges(session: Any, batch_size: int) -> None:
    for index, edge in enumerate(EDGES, 1):
        start = time.time()
        print(f"[{index}/{len(EDGES)}] Creating {edge['label']}...")
        try:
            stats = run_edge(session, edge, batch_size)
            elapsed = time.time() - start
            print(f"  Done in {seconds_text(elapsed)} ({format_duration(elapsed)}) | {stats}")
        except Exception as exc:
            elapsed = time.time() - start
            print(f"  FAILED after {seconds_text(elapsed)} ({format_duration(elapsed)}): {exc}")
            raise


def print_post_counts(session: Any) -> None:
    print("\nRelationship counts:")
    for label, query in POST_COUNT_QUERIES:
        record = session.run(query).single()
        count = int(record["count"]) if record else 0
        print(f"  {label:<18} {count:>12,}")


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        ensure_database(driver, args.database)

        with driver.session(database=args.database) as session:
            if args.dry_run:
                print_dry_run(session)
                return

            ensure_indexes(session)
            if args.ensure_indexes_only:
                print(f"Indexes/constraints ensured on database {args.database!r}")
                return

            print(f"Neo4j URI           : {args.uri}")
            print(f"Neo4j database      : {args.database}")
            print(f"Batch size          : {args.batch_size}")
            print(f"Script started UTC  : {utc_now()}")
            print("")

            edge_start = time.time()
            print(f"Edge load started UTC: {utc_now()}")
            import_edges(session, args.batch_size)
            edge_elapsed = time.time() - edge_start

            print(f"Edge load finished UTC: {utc_now()}")
            print(f"Edge load seconds     : {edge_elapsed:.3f}")
            print_post_counts(session)

        total_elapsed = time.time() - total_start
        print(f"\nTotal script seconds: {total_elapsed:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Weather Nodes (50ms)

cypher
UNWIND [
  {id: 1,    name: 'Clear'},
  {id: 2,    name: 'Rain (Mist)'},
  {id: 3,    name: 'Sleet (Hail)'},
  {id: 4,    name: 'Snow'},
  {id: 6,    name: 'Severe Crosswinds'},
  {id: 7,    name: 'Blowing Sand, Soil, Dirt'},
  {id: 8,    name: 'Other'},
  {id: 9,    name: 'Cloudy'},
  {id: 11,   name: 'Blowing Snow'},
  {id: 12,   name: 'Freezing Rain or Drizzle'},
  {id: 98,   name: 'Not Reported'},
  {id: 99,   name: 'Unknown'},
  {id: 1001, name: 'Fog'},
  {id: 1002, name: 'Smog, Smoke'}
] AS row
MERGE (:Weather {id: row.id, name: row.name});

CrashRF Nodes (218ms)

cypher
UNWIND [
  {id: 1,  name: 'Inadequate Warning of Exits, Lanes Narrowing, Traffic Controls, etc.'},
  {id: 2,  name: 'Shoulder Design or Condition'},
  {id: 3,  name: 'Other Maintenance or Construction-Created Condition'},
  {id: 4,  name: 'No or Obscured Pavement Marking'},
  {id: 5,  name: 'Surface Under Water'},
  {id: 6,  name: 'Inadequate Construction or Poor Design of Roadway, Bridge, etc.'},
  {id: 7,  name: 'Surface Washed Out (Caved in, Road Slippage)'},
  {id: 10, name: 'Emergency Vehicle Related'},
  {id: 12, name: 'Distracted Driver of a Non-Contact Vehicle'},
  {id: 13, name: 'Aggressive Driving/Road Rage by Non-Contact Vehicle Driver'},
  {id: 14, name: 'Motor Vehicle Struck by Falling Cargo or Something That Came Loose From or Something That Was Set in Motion by a Vehicle'},
  {id: 15, name: 'Non-Occupant Struck by Falling Cargo, or Something Came Loose From or Something That Was Set in Motion by a Vehicle'},
  {id: 16, name: 'Non-Occupant Struck Vehicle'},
  {id: 17, name: 'Stopped Vehicle Set in Motion by Non-Driver'},
  {id: 18, name: 'Date of Crash and Date of EMS Notification Were Not Same Day'},
  {id: 19, name: 'Recent Previous Crash Scene Nearby'},
  {id: 20, name: 'Police-Pursuit-Involved'},
  {id: 21, name: 'Within Designated School Zone'},
  {id: 22, name: 'Speed Limit Is a Statutory Limit as Recorded or Was Determined as This State Basic Rule'},
  {id: 23, name: 'Indication of a Stalled/Disabled Vehicle'},
  {id: 24, name: 'Unstabilized Situation Began and All Harmful Events Occurred off of the Roadway'},
  {id: 25, name: 'Toll Booth/Plaza Related'},
  {id: 26, name: 'Prior Non-Recurring Incident'},
  {id: 27, name: 'Backup Due to Prior Crash'},
  {id: 28, name: 'Regular Congestion'},
  {id: 30, name: 'Obstructed Crosswalks'},
  {id: 31, name: 'Related to a Bus Stop'},
  {id: 40, name: 'Traffic Controls Not Functioning Properly'},
  {id: 42, name: 'Uncontrolled Intersection or Railroad Crossing'},
  {id: 99, name: 'Unknown'}
] AS row
MERGE (:CrashRF {id: row.id, name: row.name});

Damage Nodes (145ms)

cypher
UNWIND [
  {id: 0,  name: 'Non-Collision'},
  {id: 1,  name: 'Clock Point 1'},
  {id: 2,  name: 'Clock Point 2'},
  {id: 3,  name: 'Clock Point 3'},
  {id: 4,  name: 'Clock Point 4'},
  {id: 5,  name: 'Clock Point 5'},
  {id: 6,  name: 'Clock Point 6'},
  {id: 7,  name: 'Clock Point 7'},
  {id: 8,  name: 'Clock Point 8'},
  {id: 9,  name: 'Clock Point 9'},
  {id: 10, name: 'Clock Point 10'},
  {id: 11, name: 'Clock Point 11'},
  {id: 12, name: 'Clock Point 12'},
  {id: 13, name: 'Top'},
  {id: 14, name: 'Undercarriage'},
  {id: 18, name: 'Set-in-Motion (Not a Clock Point)'},
  {id: 61, name: 'Left'},
  {id: 62, name: 'Left-Front Half'},
  {id: 63, name: 'Left-Back Half'},
  {id: 81, name: 'Right'},
  {id: 82, name: 'Right-Front Half'},
  {id: 83, name: 'Right-Back Half'},
  {id: 98, name: 'Not Reported'},
  {id: 99, name: 'Unknown'}
] AS row
MERGE (:Damage {id: row.id, name: row.name});

Distract Nodes (89ms)

cypher
UNWIND [
  {id: 1,  name: 'Looked but Did Not See'},
  {id: 3,  name: 'By Other Occupant(s)'},
  {id: 4,  name: 'By a Moving Object in Vehicle'},
  {id: 5,  name: 'While Talking or Listening to Mobile Phone'},
  {id: 6,  name: 'While Manipulating Mobile Phone'},
  {id: 7,  name: 'While Adjusting Audio or Climate Controls'},
  {id: 9,  name: 'While Using Other Component/Controls Integral to Vehicle'},
  {id: 10, name: 'While Using or Reaching for Device/Object Brought Into Vehicle'},
  {id: 12, name: 'Distracted by Outside Person, Object or Event'},
  {id: 13, name: 'Eating or Drinking'},
  {id: 14, name: 'Smoking Related'},
  {id: 15, name: 'Other Mobile Phone Related'},
  {id: 16, name: 'No Driver Present/Unknown if Driver Present'},
  {id: 17, name: 'Distraction/Inattention'},
  {id: 18, name: 'Distraction/Careless'},
  {id: 19, name: 'Careless/Inattentive'},
  {id: 92, name: 'Distraction (Distracted), Details Unknown'},
  {id: 93, name: 'Inattention (Inattentive), Details Unknown'},
  {id: 96, name: 'Not Reported'},
  {id: 97, name: 'Lost in Thought/Daydreaming'},
  {id: 98, name: 'Other Distraction'},
  {id: 99, name: 'Reported as Unknown if Distracted'}
] AS row
MERGE (d:Distract {id: row.id})
SET d.name = row.name;

Factor Nodes (9304ms)

cypher
UNWIND [
  {id: 1,    name: 'Tires'},
  {id: 2,    name: 'Brake System'},
  {id: 3,    name: 'Steering'},
  {id: 4,    name: 'Suspension'},
  {id: 5,    name: 'Power Train'},
  {id: 6,    name: 'Exhaust System'},
  {id: 7,    name: 'Head Lights'},
  {id: 8,    name: 'Signal Lights'},
  {id: 9,    name: 'Other Lights'},
  {id: 10,   name: 'Wipers'},
  {id: 11,   name: 'Wheels'},
  {id: 12,   name: 'Mirrors'},
  {id: 13,   name: 'Windows/Windshield'},
  {id: 14,   name: 'Body, Doors'},
  {id: 15,   name: 'Truck Coupling/Trailer Hitch/Safety Chains'},
  {id: 16,   name: 'Safety Systems'},
  {id: 17,   name: 'Vehicle Contributing Factors - No Details'},
  {id: 97,   name: 'Other'},
  {id: 98,   name: 'Not Reported'},
  {id: 99,   name: 'Unknown'},
  {id: 1001, name: 'Horn'},
  {id: 1002, name: 'Driver Seating and Control'},
  {id: 1003, name: 'Air Bags'},
  {id: 1004, name: 'Safety Belts'}
] AS row
MERGE (f:Factor {id: row.id})
SET f.name = row.name;

DrImpair Nodes (100ms)

cypher
UNWIND [
  {id: 1,  name: 'Ill, Blackout'},
  {id: 2,  name: 'Asleep or Fatigued'},
  {id: 3,  name: 'Walking With a Cane or Crutches, etc.'},
  {id: 4,  name: 'Paraplegic or Restricted to Wheelchair'},
  {id: 5,  name: 'Impaired Due to Previous Injury'},
  {id: 6,  name: 'Deaf/Hard of Hearing'},
  {id: 7,  name: 'Blind/Low Vision'},
  {id: 8,  name: 'Emotional (Depressed, Angry, Disturbed, etc.)'},
  {id: 9,  name: 'Under the Influence of Alcohol, Drugs, or Medication'},
  {id: 10, name: 'Physical Impairment - No Details'},
  {id: 95, name: 'No Driver Present/Unknown if Driver Present'},
  {id: 96, name: 'Other Physical Impairment'},
  {id: 98, name: 'Not Reported'},
  {id: 99, name: 'Unknown if Impaired'}
] AS row
MERGE (d:DrImpair {id: row.id})
SET d.name = row.name;

Maneuver Nodes (55ms)

cypher
UNWIND [
  {id: 0,  name: 'Driver Did Not Maneuver to Avoid'},
  {id: 1,  name: 'Object'},
  {id: 2,  name: 'Poor Road Conditions (Puddle, Ice, Pothole, etc.)'},
  {id: 3,  name: 'Live Animal'},
  {id: 4,  name: 'Contact Motor Vehicle (in This Crash)'},
  {id: 5,  name: 'Pedestrian, Pedalcyclist or Other Non-Motorist'},
  {id: 92, name: 'Phantom/Non-Contact Motor Vehicle'},
  {id: 95, name: 'No Driver Present/Unknown if Driver Present'},
  {id: 98, name: 'Not Reported'},
  {id: 99, name: 'Reported as Unknown'}
] AS row
MERGE (m:Maneuver {id: row.id})
SET m.name = row.name;

Violatn Nodes (553ms)

cypher
CREATE CONSTRAINT violatn_id IF NOT EXISTS
FOR (v:Violatn) REQUIRE v.id IS UNIQUE;

UNWIND [
  {id: 1,    name: 'Manslaughter or Homicide'},
  {id: 2,    name: 'Willful Reckless Driving; Driving to Endanger; Negligent Driving'},
  {id: 3,    name: 'Unsafe Reckless (Not Willful, Wanton Reckless) Driving'},
  {id: 4,    name: 'Inattentive, Careless, Improper Driving, Driving Without Due Care'},
  {id: 5,    name: 'Fleeing or Eluding Police'},
  {id: 6,    name: 'Fail to Obey Police, Fireman, Authorized Person Directing Traffic'},
  {id: 7,    name: 'Hit-And-Run, Fail to Stop After Crash'},
  {id: 8,    name: 'Fail to Give Aid, Information, Wait for Police After Crash'},
  {id: 9,    name: 'Serious Violation Resulting in Death'},
  {id: 10,   name: 'Use of Telecommunications Device'},
  {id: 11,   name: 'Driving While Intoxicated (Alcohol or Drugs) or BAC Above Limit'},
  {id: 12,   name: 'Driving While Impaired'},
  {id: 13,   name: 'Driving Under Influence of Substance Not Intended to Intoxicate'},
  {id: 14,   name: 'Drinking While Operating'},
  {id: 15,   name: 'Illegal Possession of Alcohol or Drugs'},
  {id: 16,   name: 'Driving With Detectable Alcohol'},
  {id: 18,   name: 'Refusal to Submit to Chemical Test'},
  {id: 19,   name: 'Alcohol, Drug or Impairment Violations Generally'},
  {id: 21,   name: 'Racing'},
  {id: 22,   name: 'Speeding (Above the Speed Limit)'},
  {id: 23,   name: 'Speed Greater Than Reasonable and Prudent'},
  {id: 24,   name: 'Exceeding Special Speed Limit'},
  {id: 25,   name: 'Energy Speed (Exceeding 55 mph, Non-Pointable)'},
  {id: 26,   name: 'Driving Too Slowly'},
  {id: 29,   name: 'Speed-Related Violations Generally'},
  {id: 31,   name: 'Fail to Stop for Red Signal'},
  {id: 32,   name: 'Fail to Stop for Flashing Red'},
  {id: 33,   name: 'Violation of Turn on Red'},
  {id: 34,   name: 'Fail to Obey Flashing Signal (Yellow or Red)'},
  {id: 35,   name: 'Fail to Obey Signal Generally'},
  {id: 36,   name: 'Violate RR Grade Crossing Device/Regulations'},
  {id: 37,   name: 'Fail to Obey Stop Sign'},
  {id: 38,   name: 'Fail to Obey Yield Sign'},
  {id: 39,   name: 'Fail to Obey Traffic Control Device Generally'},
  {id: 41,   name: 'Turn in Violation of Traffic Control'},
  {id: 42,   name: 'Improper Method and Position of Turn (Too Wide, Wrong Lane)'},
  {id: 43,   name: 'Fail to Signal for Turn or Stop'},
  {id: 45,   name: 'Fail to Yield to Emergency Vehicle'},
  {id: 46,   name: 'Fail to Yield Generally'},
  {id: 48,   name: 'Enter Intersection When Space Insufficient'},
  {id: 49,   name: 'Turn, Yield, Signaling Violations Generally'},
  {id: 51,   name: 'Driving Wrong Way on One-Way Road'},
  {id: 52,   name: 'Driving on Left, Wrong Side of Road Generally'},
  {id: 53,   name: 'Improper, Unsafe Passing'},
  {id: 54,   name: 'Pass on Right (Drive off Pavement to Pass)'},
  {id: 55,   name: 'Pass Stopped School Bus'},
  {id: 56,   name: 'Fail to Give Way When Overtaken'},
  {id: 58,   name: 'Following Too Closely'},
  {id: 59,   name: 'Wrong Side, Passing, Following Violations Generally'},
  {id: 61,   name: 'Unsafe or Prohibited Lane Change'},
  {id: 62,   name: 'Improper Use of Lane'},
  {id: 63,   name: 'Certain Traffic to Use Right Lane'},
  {id: 66,   name: 'Motorcycle Lane Violations'},
  {id: 67,   name: 'Motorcyclist Attached to Another Vehicle'},
  {id: 69,   name: 'Lane Violations Generally'},
  {id: 71,   name: 'Driving While License Withdrawn'},
  {id: 72,   name: 'Other Driver License Violations'},
  {id: 73,   name: 'Commercial Driver Violations'},
  {id: 74,   name: 'Vehicle Registration Violations'},
  {id: 75,   name: 'Fail to Carry Insurance Card'},
  {id: 76,   name: 'Driving Uninsured Vehicle'},
  {id: 79,   name: 'Non-Moving Violations Generally'},
  {id: 81,   name: 'Lamp Violations'},
  {id: 82,   name: 'Brake Violations'},
  {id: 83,   name: 'Failure to Require Restraint Use'},
  {id: 84,   name: 'Motorcycle Equipment Violations'},
  {id: 85,   name: 'Violation of Hazardous Cargo Regulations'},
  {id: 86,   name: 'Size, Weight, Load Violations'},
  {id: 89,   name: 'Equipment Violations Generally'},
  {id: 91,   name: 'Parking'},
  {id: 92,   name: 'Theft, Unauthorized Use of Motor Vehicle'},
  {id: 93,   name: 'Driving Where Prohibited'},
  {id: 95,   name: 'No Driver Present/Unknown if Driver Present'},
  {id: 97,   name: 'Not Reported'},
  {id: 98,   name: 'Other Moving Violation'},
  {id: 99,   name: 'Unknown Violations'},
  {id: 1001, name: 'Yes (1975-1981)'}
] AS row
MERGE (v:Violatn {id: row.id})
SET v.name = row.name;

Vision Nodes (204ms)

cypher
UNWIND [
  {id: 1,    name: 'Rain, Snow, Fog, Smoke, Sand, Dust'},
  {id: 2,    name: 'Reflected Glare, Bright Sunlight, Headlights'},
  {id: 3,    name: 'Curve, Hill, or Other Roadway Design Features'},
  {id: 4,    name: 'Building, Billboard, or Other Structure'},
  {id: 5,    name: 'Trees, Crops, Vegetation'},
  {id: 6,    name: 'In-Transport Motor Vehicle (Including Load)'},
  {id: 7,    name: 'Not In-Transport Motor Vehicle (Parked, Working)'},
  {id: 8,    name: 'Splash or Spray of Passing Vehicle'},
  {id: 9,    name: 'Inadequate Defrost or Defog System'},
  {id: 10,   name: 'Inadequate Vehicle Lighting System'},
  {id: 11,   name: 'Obstructing Interior to the Vehicle'},
  {id: 12,   name: 'External Mirrors'},
  {id: 13,   name: 'Broken or Improperly Cleaned Windshield'},
  {id: 14,   name: 'Obstructing Angles on Vehicle'},
  {id: 95,   name: 'No Driver Present/Unknown if Driver Present'},
  {id: 97,   name: 'Vision Obscured - No Details'},
  {id: 98,   name: 'Other Visual Obstruction'},
  {id: 99,   name: 'Reported as Unknown'},
  {id: 1001, name: 'Head Restraints'}
] AS row
MERGE (v:Vision {id: row.id})
SET v.name = row.name;

VehicleSF Nodes (271ms)

cypher
UNWIND [
  {id: 1,   name: 'Tires (Does Not Include Wheels, See Value 16)'},
  {id: 2,   name: 'Brake System'},
  {id: 3,   name: 'Steering System-Tie Rod, Kingpin, Ball Joint, etc.'},
  {id: 4,   name: 'Suspension-Springs, Shock Absorbers, MacPherson Struts, Axle Bearing, Control Arms, etc.'},
  {id: 5,   name: 'Power Train (Power Train/Engine, 2001-2009)-Universal Joint, Drive Shaft, Transmission, etc.'},
  {id: 6,   name: 'Exhaust System'},
  {id: 7,   name: 'Headlights'},
  {id: 8,   name: 'Signal Lights'},
  {id: 9,   name: 'Other Lights'},
  {id: 10,  name: 'Horn'},
  {id: 11,  name: 'Mirrors'},
  {id: 12,  name: 'Wipers'},
  {id: 13,  name: 'Driver Seating and Control'},
  {id: 14,  name: 'Body, Doors, Hood, Other'},
  {id: 15,  name: 'Trailer Hitch'},
  {id: 16,  name: 'Wheels'},
  {id: 17,  name: 'Air Bags'},
  {id: 18,  name: 'Other Vehicle Defects'},
  {id: 19,  name: 'Safety Belts'},
  {id: 29,  name: 'Default Code Used for Vehicle Numbering'},
  {id: 30,  name: 'Multi-Wheeled Motorcycle Conversion'},
  {id: 31,  name: 'Hit-and-Run Vehicle'},
  {id: 32,  name: 'Vehicle Registration for a Person with a Disability'},
  {id: 33,  name: 'Vehicle Being Pushed by Non-Motorist'},
  {id: 34,  name: 'Vehicle Impact Point-the Result of Something Set in Motion'},
  {id: 35,  name: 'Reconstructed/Altered Vehicle'},
  {id: 37,  name: 'Transporting Children to/From Head Start/Day Care'},
  {id: 38,  name: 'Vehicle Went Airborne During Crash'},
  {id: 39,  name: 'Highway Construction, Maintenance, or Utility Vehicle, In-Transport (Inside or Outside Work Zone)'},
  {id: 40,  name: 'Highway Incident Response Vehicle'},
  {id: 41,  name: 'Police, Fire, or EMS Vehicle Working at the Scene of an Emergency or Performing Other Traffic Control Activities'},
  {id: 42,  name: 'Other Working Vehicle (Not Construction, Maintenance, Utility, Police, Fire, or EMS Vehicle)'},
  {id: 43,  name: 'Hazardous Materials/Cargo Released From This Vehicle'},
  {id: 44,  name: 'Adaptive Equipment'},
  {id: 45,  name: 'Slide-in Camper'},
  {id: 99,  name: 'Unknown'},
  {id: 999, name: 'Unknown'}
] AS row
MERGE (sf:VehicleSF {id: row.id})
SET sf.name = row.name;

DriverRF Nodes (289ms)

cypher
UNWIND [
  {id: 4,   name: 'Reaction to or Failure to Take Drugs/Medication'},
  {id: 5,   name: 'Other Drugs (Marijuana, Cocaine, etc.)'},
  {id: 6,   name: 'Careless Driving, Inattentive Operation, Improper Driving, Driving Without Due Care'},
  {id: 8,   name: 'Road Rage/Aggressive Driving'},
  {id: 9,   name: 'Emergency Services Personnel'},
  {id: 10,  name: 'Looked but Did Not See'},
  {id: 12,  name: 'Mother of Dead Fetus/Mother of Infant Born Post Crash'},
  {id: 13,  name: 'Mentally Challenged'},
  {id: 14,  name: 'Failure to Take Drugs/Medication'},
  {id: 15,  name: 'Seat Back Not in Normal Position, Seat Back Reclined'},
  {id: 16,  name: 'Police or Law Enforcement Officer'},
  {id: 17,  name: 'Running off Road'},
  {id: 18,  name: 'Traveling on Prohibited Trafficways'},
  {id: 19,  name: 'Legally Driving on Suspended or Revoked License'},
  {id: 20,  name: 'Leaving Vehicle Unattended With Engine Running; Leaving Vehicle Unattended in Roadway'},
  {id: 21,  name: 'Overloading or Improper Loading of Vehicle With Passenger or Cargo'},
  {id: 22,  name: 'Towing or Pushing Vehicle Improperly'},
  {id: 23,  name: 'Failing to Dim Lights or to Have Lights on When Required'},
  {id: 24,  name: 'Operating Without Required Equipment'},
  {id: 25,  name: 'Creating Unlawful Noise or Using Equipment Prohibited by Law'},
  {id: 26,  name: 'Following Improperly'},
  {id: 27,  name: 'Improper or Erratic Lane Changing'},
  {id: 28,  name: 'Improper Lane Usage'},
  {id: 29,  name: 'Intentional Illegal Driving off the Roadway'},
  {id: 30,  name: 'Making Improper Entry to or Exit From Trafficway'},
  {id: 31,  name: 'Starting or Backing Improperly'},
  {id: 32,  name: 'Opening Vehicle Closure Into Moving Traffic or Vehicle Is in Motion'},
  {id: 33,  name: 'Passing Where Prohibited by Posted Signs, Pavement Markings, or School Bus Displaying Warning Not to Pass'},
  {id: 34,  name: 'Improper Passing Location'},
  {id: 35,  name: 'Passing With Insufficient Distance or Inadequate Visibility or Failing to Yield to Overtaking Vehicle'},
  {id: 36,  name: 'Operating the Vehicle in an Erratic, Reckless, Careless or Negligent Manner'},
  {id: 37,  name: 'Police Pursuing This Driver or Police Officer in Pursuit'},
  {id: 38,  name: 'Failure to Yield Right-of-Way'},
  {id: 39,  name: 'Failure to Obey Actual Traffic Signs, Traffic Control Devices or Traffic Officers, Failure to Observe Safety Zone Traffic Laws'},
  {id: 40,  name: 'Passing Through or Around Barrier'},
  {id: 41,  name: 'Failure to Observe Warnings or Instructions on Vehicle Displaying Them'},
  {id: 42,  name: 'Failure to Signal Intentions'},
  {id: 43,  name: 'Giving Wrong Signal'},
  {id: 44,  name: 'Driving Too Fast for Conditions or in Excess of Posted Speed Limit'},
  {id: 45,  name: 'Driving Less Than Posted Maximum'},
  {id: 46,  name: 'Operating at Erratic or Suddenly Changing Speeds'},
  {id: 47,  name: 'Making Right Turn From Left-Turn Lane or Making Left Turn From Right-Turn Lane'},
  {id: 48,  name: 'Making Improper Turn'},
  {id: 49,  name: 'Failure to Comply With Physical Restrictions of License'},
  {id: 50,  name: 'Driving Wrong Way on One-Way Trafficway'},
  {id: 51,  name: 'Driving on Wrong Side of Two-way Trafficway (Intentionally or Unintentionally)'},
  {id: 52,  name: 'Operator Inexperience'},
  {id: 53,  name: 'Unfamiliar With Roadway'},
  {id: 54,  name: 'Stopping in Roadway (Vehicle Not Abandoned)'},
  {id: 55,  name: 'Improper Management of Vehicle Controls'},
  {id: 56,  name: 'Object Interference With Vehicle Controls'},
  {id: 57,  name: 'Driving With Tire-Related Problems'},
  {id: 58,  name: 'Over Correcting'},
  {id: 59,  name: 'Getting off/out of a Vehicle'},
  {id: 60,  name: 'Alcohol and/or Drug Test Refused'},
  {id: 73,  name: 'Driver Has Not Complied With Learners Permit or Intermediate Driver License Restrictions (GDL Restrictions)'},
  {id: 74,  name: 'Driver Has Not Complied With Physical or Other Imposed Restrictions'},
  {id: 77,  name: 'Severe Crosswind'},
  {id: 78,  name: 'Wind From Passing Truck'},
  {id: 79,  name: 'Slippery or Loose Surface'},
  {id: 80,  name: 'Tire Blow-Out or Flat'},
  {id: 81,  name: 'Debris or Objects in Road'},
  {id: 82,  name: 'Ruts, Holes, Bumps in Road'},
  {id: 83,  name: 'Live Animals in Road'},
  {id: 84,  name: 'Vehicle in Road'},
  {id: 85,  name: 'Phantom Vehicle'},
  {id: 86,  name: 'Pedestrian, Pedalcyclist, or Other Non-Motorist in Road'},
  {id: 87,  name: 'Ice, Water, Snow, Slush, Sand, Dirt, Oil, Wet Leaves on Road'},
  {id: 88,  name: 'Trailer Fishtailing or Swaying'},
  {id: 89,  name: "Driver has a Driving Record or Driver's License From More Than One State"},
  {id: 90,  name: 'Hit-and-Run Vehicle Driver'},
  {id: 91,  name: 'Non-Traffic Violation Charged (Manslaughter, Homicide or Other Assault Offense Committed Without Malice)'},
  {id: 92,  name: 'Other Non-Moving Traffic Violation'},
  {id: 93,  name: 'Cellular Telephone'},
  {id: 94,  name: 'Emergency Medical Service Personnel'},
  {id: 95,  name: 'Fire Personnel'},
  {id: 96,  name: 'Tow Operator'},
  {id: 97,  name: 'Transportation (i.e., Maintenance Workers, Safety Service Patrol Operators, etc.)'},
  {id: 98,  name: 'Head-Up Display (1991-2009)'},
  {id: 999, name: 'Reported as Unknown'}
] AS row
MERGE (rf:DriverRF {id: row.id})
SET rf.name = row.name;

Race Nodes (185ms)

cypher
UNWIND [
  {id: 0,  name: 'Not a Fatality (Not Applicable)'},
  {id: 1,  name: 'White'},
  {id: 2,  name: 'Black or African American'},
  {id: 3,  name: 'North American Indian or Alaska Native'},
  {id: 4,  name: 'Chinese'},
  {id: 5,  name: 'Japanese'},
  {id: 6,  name: 'Native Hawaiian'},
  {id: 7,  name: 'Filipino'},
  {id: 18, name: 'Asian Indian'},
  {id: 19, name: 'Other Indian'},
  {id: 28, name: 'Korean'},
  {id: 38, name: 'Samoan'},
  {id: 48, name: 'Vietnamese'},
  {id: 58, name: 'Guamanian or Chamorro'},
  {id: 68, name: 'Other Asian or Pacific Islander'},
  {id: 78, name: 'Asian or Pacific Islander, No Specific (Individual) Race'},
  {id: 96, name: 'Redacted'},
  {id: 97, name: 'Multiple Races, Unspecified'},
  {id: 98, name: 'All Other Races'},
  {id: 99, name: 'Unknown'}
] AS row
MERGE (:Race {id: row.id, name: row.name});

PersonRF Nodes (326ms)

cypher
UNWIND [
  {id: 4, name: 'Walking/Riding With or Against Traffic, Playing, Working, Sitting, Lying, Standing, etc., in Roadway'},
  {id: 5, name: 'Interfering With Driver*'},
  {id: 8, name: 'Person with an Intellectual, Cognitive, or Developmental Disability'},
  {id: 9, name: 'Construction/Maintenance/Utility Worker'},
  {id: 10, name: 'Alcohol and/or Drug Test Refused (Since 2017)'},
  {id: 13, name: 'Motorized Wheelchair Rider**'},
  {id: 18, name: 'Mother of Dead Fetus/Mother of Infant Born Post Crash'},
  {id: 19, name: 'Pedestrian'},
  {id: 20, name: 'Leaving Vehicle Unattended in Roadway'},
  {id: 21, name: 'Overloading or Improper Loading of Vehicle With Passengers or Cargo'},
  {id: 22, name: 'Towing or Pushing Vehicle Improperly'},
  {id: 24, name: 'Operating Without Required Equipment'},
  {id: 25, name: 'Creating Unlawful Noise or Using Equipment Prohibited by Law'},
  {id: 26, name: 'Following Improperly'},
  {id: 29, name: 'Illegal Driving on Road Shoulder, in Ditch, on Sidewalk, on Median'},
  {id: 31, name: 'Default Code Used for Vehicle Numbering**'},
  {id: 32, name: 'Opening Vehicle Closure Into Moving Traffic or While Vehicle is in Motion (Since 2001)*'},
  {id: 33, name: 'Passing Where Prohibited by Posted Signs, Pavement Markings, Hill, or Curve, or School Bus Displaying Warning Not to Pass'},
  {id: 34, name: 'Passing on Wrong Side'},
  {id: 36, name: 'Operating the Vehicle in Other Erratic, Reckless, Careless or Negligent Manner'},
  {id: 37, name: 'Traveling on Prohibited Trafficway'},
  {id: 39, name: 'Failure to Obey Actual Traffic Signs, Traffic Control Devices or Traffic Officers; Failure to Obey Safety Zone Traffic Laws'},
  {id: 40, name: 'Passing Through or Around Barrier Positioned to Prohibit or Channel Traffic'},
  {id: 41, name: 'Failure to Observe Warnings or Instructions on Vehicles Displaying Them'},
  {id: 42, name: 'Failure to Signal Intentions'},
  {id: 43, name: 'Giving Wrong Signal'},
  {id: 44, name: 'Driving Too Fast for Conditions or in Excess of Posted Speed Limit'},
  {id: 45, name: 'Driving Less Than Posted Maximum'},
  {id: 46, name: 'Operating at Erratic or Suddenly Changing Speeds'},
  {id: 47, name: 'Making Right Turn From Left Turn Lane or Making Left Turn From Right Turn Lane'},
  {id: 51, name: 'Operator Inexperience'},
  {id: 52, name: 'Unfamiliar With Roadway'},
  {id: 53, name: 'Non-Motorist Previously Used a Motor Vehicle for Motion**'},
  {id: 54, name: 'Non-Motorist Attempting to Use a Motor Vehicle for Motion**'},
  {id: 55, name: 'Non-Motorist Attempting to Use or Previously Used a Motor Vehicle for Motion, Details Not Reported**'},
  {id: 56, name: 'Non-Driver Flees Scene (Since 2005)'},
  {id: 57, name: 'Improper Tire Pressure (Since 1995)'},
  {id: 58, name: 'Locked Wheel (1995-2014)'},
  {id: 59, name: 'Overcorrecting (Since 1995)*'},
  {id: 60, name: 'Rain, Snow, Fog, Smoke, Sand, Dust'},
  {id: 61, name: 'Reflected Glare, Bright Sunlight, Headlights'},
  {id: 62, name: 'Curve, Hill, or Other Design Features (Including Traffic Signs, Embankment)'},
  {id: 63, name: 'Building, Billboard, Other Structures'},
  {id: 64, name: 'Trees, Crops, Vegetation'},
  {id: 65, name: 'Motor Vehicle (Including Load)'},
  {id: 66, name: 'Parked Vehicle'},
  {id: 67, name: 'Splash or Spray or Passing Vehicle'},
  {id: 68, name: 'Inadequate Lighting System'},
  {id: 69, name: 'Obstructing Angles on Vehicle'},
  {id: 70, name: 'Mirrors'},
  {id: 71, name: 'Mirrors-Other'},
  {id: 72, name: 'Other Visual Obstruction'},
  {id: 73, name: 'Severe Crosswind'},
  {id: 74, name: 'Wind From Passing Truck'},
  {id: 75, name: 'Slippery or Loose Surface'},
  {id: 76, name: 'Tire Blow-Out or Flat'},
  {id: 77, name: 'Debris or Objects in Road'},
  {id: 78, name: 'Ruts, Holes, Bumps in Road'},
  {id: 79, name: 'Live Animals in Road'},
  {id: 80, name: 'Vehicle in Road'},
  {id: 81, name: 'Phantom Vehicle'},
  {id: 82, name: 'Pedestrian, Pedalcyclist, or Other Non-Motorist'},
  {id: 83, name: 'Ice, Snow, Slush, Water, Sand, Dirt, Oil, Wet Leaves on Road (Since 1995)'},
  {id: 86, name: 'Emergency Services Personnel (Since 2007)'},
  {id: 87, name: 'Police or Law Enforcement Officer'},
  {id: 88, name: 'Seat Back Not in Normal Upright Position, Seat Back Reclined*'},
  {id: 89, name: 'Parked Motor Vehicle With Equipment Extending Into the Travel Lane'},
  {id: 90, name: 'Non-Motorist Pushing a Vehicle**'},
  {id: 91, name: 'Portable Electronic Devices'},
  {id: 92, name: 'Person in Ambulance Treatment Compartment*'},
  {id: 93, name: 'Non-Motorist Wearing Motorcycle Helmet**'},
  {id: 94, name: 'Emergency Medical Services Personnel'},
  {id: 95, name: 'Fire Personnel'},
  {id: 96, name: 'Tow Operator'},
  {id: 97, name: 'Transportation (Maintenance Workers, Safety Service Patrol Operators, etc.)'},
  {id: 100, name: 'Using a Shared Micromobility Device**'},
  {id: 101, name: 'Obstructed Sidewalk (for this Person)**'},
  {id: 999, name: 'Reported as Unknown'},
  {id: 1001, name: 'Stopping in Roadway (Vehicle Not Abandoned)'},
  {id: 1002, name: 'Underriding a Parked Truck (1982-1996)'},
  {id: 1003, name: 'Getting off/out of or on/into Moving Transport Vehicle'}
] AS row
MERGE (n:PersonRF {id: row.id})
SET n.name = row.name;

NMDistract Nodes (72ms)

cypher
UNWIND [
  {id: 2,  name: 'By Other Non-Motorist(s)'},
  {id: 3,  name: 'By a Driver or Occupant of a Motor Vehicle'},
  {id: 5,  name: 'While Talking or Listening to Mobile Phone'},
  {id: 6,  name: 'While Manipulating Mobile Phone'},
  {id: 7,  name: 'Adjusting or Listening to Portable Audio Device (Other Than on a Mobile Phone)'},
  {id: 8,  name: 'Adjusting, Talking to, or Manipulating Other Portable Electronic Device'},
  {id: 12, name: 'Distracted by Animal, Other Object, Event, or Activity'},
  {id: 13, name: 'Eating or Drinking'},
  {id: 14, name: 'Smoking Related'},
  {id: 15, name: 'Other Mobile Phone Related'},
  {id: 17, name: 'Distraction/Inattention'},
  {id: 18, name: 'Distraction/Careless'},
  {id: 19, name: 'Careless/Inattentive'},
  {id: 92, name: 'Distraction (Distracted), Details Unknown'},
  {id: 93, name: 'Inattention (Inattentive), Details Unknown'},
  {id: 96, name: 'Not Reported'},
  {id: 97, name: 'Lost in Thought/Daydreaming'},
  {id: 98, name: 'Other Distraction'},
  {id: 99, name: 'Reported as Unknown if Distracted'}
] AS row
MERGE (n:NMDistract {id: row.id})
SET n.name = row.name;

NMCrash Nodes (524ms)

cypher
UNWIND [
  {id: 1,  name: 'Dart-out - Visual Obstruction Noted'},
  {id: 2,  name: 'Failure to Yield Right-Of-Way'},
  {id: 3,  name: 'Failure to Obey Traffic Signs, Signals or Officer'},
  {id: 4,  name: 'In Roadway Improperly (Standing, Lying, Working, Playing)'},
  {id: 5,  name: 'Entering/Exiting Parked or Stopped Vehicle'},
  {id: 6,  name: 'Inattentive (Talking, Eating, etc.)'},
  {id: 7,  name: 'Improper Turn/Merge'},
  {id: 8,  name: 'Improper Passing'},
  {id: 9,  name: 'Wrong-Way Riding or Walking'},
  {id: 10, name: 'Riding on Wrong Side of Road'},
  {id: 11, name: 'Dash - Run, No Visual Obstruction Noted'},
  {id: 12, name: 'Improper Crossing of Roadway or Intersection (Jaywalking)'},
  {id: 13, name: 'Failing to Have Lights on When Required'},
  {id: 14, name: 'Operating Without Required Equipment'},
  {id: 15, name: 'Improper or Erratic Lane Changing'},
  {id: 16, name: 'Failure to Keep in Proper Lane or Running off Road'},
  {id: 17, name: 'Making Improper Entry to or Exit From Trafficway'},
  {id: 18, name: 'Operating in Other Erratic, Reckless, Careless or Negligent Manner'},
  {id: 19, name: 'Not Visible (Dark Clothing, No Lighting, etc.)'},
  {id: 20, name: 'Passing With Insufficient Distance or Inadequate Visibility or Failing to Yield to Overtaking Vehicle'},
  {id: 21, name: 'Other'},
  {id: 92, name: 'Contributing Circumstance -No Details'},
  {id: 98, name: 'Not Reported'},
  {id: 99, name: 'Reported as Unknown'}
] AS row
MERGE (n:NMCrash {id: row.id})
SET n.name = row.name;

NMPrior Nodes (112ms)

cypher
UNWIND [
  {id: 1,  name: 'Going to or From School [Pre-K-12] (Since 2023)'},
  {id: 2,  name: 'Waiting to Cross Roadway'},
  {id: 3,  name: 'Crossing Roadway'},
  {id: 4,  name: 'Jogging/Running'},
  {id: 5,  name: 'Movement Along Roadway With Traffic (in or Adjacent to Travel Lane)'},
  {id: 6,  name: 'Movement Along Roadway Against Traffic (in or Adjacent to Travel Lane)'},
  {id: 7,  name: 'Movement on Sidewalk'},
  {id: 8,  name: 'In Roadway-Other [Working, Playing, etc.]'},
  {id: 9,  name: 'Adjacent to Roadway (e.g., Shoulder, Median)'},
  {id: 10, name: 'Working in Trafficway [Incident Response]'},
  {id: 11, name: 'Entering/Exiting a Vehicle'},
  {id: 12, name: 'Disabled Vehicle Related (Working on, Pushing, Leaving/Approaching)'},
  {id: 14, name: 'Other'},
  {id: 15, name: 'None'},
  {id: 16, name: 'Movement Along Roadway - Direction Unknown (Since 2012)'},
  {id: 98, name: 'Not Reported'},
  {id: 99, name: 'Unknown'}
] AS row
MERGE (n:NMPrior {id: row.id})
SET n.name = row.name;

NMImpair Nodes (107ms)

cypher
UNWIND [
  {id: 1,  name: 'Ill, Blackout'},
  {id: 2,  name: 'Asleep or Fatigued'},
  {id: 3,  name: 'Walking With a Cane or Crutches'},
  {id: 4,  name: 'Paraplegic or in a Wheelchair'},
  {id: 5,  name: 'Impaired Due to Previous Injury'},
  {id: 6,  name: 'Deaf/Hard of Hearing'},
  {id: 7,  name: 'Blind/Low Vision'},
  {id: 8,  name: 'Emotional (Depressed, Angry, Disturbed, etc.)'},
  {id: 9,  name: 'Under the Influence of Alcohol, Drugs, or Medication'},
  {id: 10, name: 'Physical Impairment - No Details'},
  {id: 96, name: 'Other Physical Impairment'},
  {id: 98, name: 'Not Reported'},
  {id: 99, name: 'Reported as Unknown if Impaired'}
] AS row
MERGE (n:NMImpair {id: row.id})
SET n.name = row.name;

DrugTest Nodes (178ms)

cypher
UNWIND [
  {id: 1,  name: 'Whole Blood'},
  {id: 2,  name: 'Urine'},
  {id: 11, name: 'Blood Plasma/Serum'},
  {id: 12, name: 'Blood Clot'},
  {id: 13, name: 'Oral Fluids'},
  {id: 14, name: 'Vitreous'},
  {id: 15, name: 'Liver'},
  {id: 96, name: 'Not Reported'},
  {id: 97, name: 'Unknown Specimen'},
  {id: 98, name: 'Other Specimen'},
  {id: 99, name: 'Reported as Unknown if Tested'}
] AS row
MERGE (n:DrugTest {id: row.id})
SET n.name = row.name;

CrashRF to Accident Relationship (20.233s)

Average edge import time per year: 0.446s across 1975-1981.

Average edge import time per year: 0.359s across 1982-2012.

Average edge import time per year: 0.687s across 2013-2017.

Average edge import time per year: 0.511s across 2018-2022.

python
#!/usr/bin/env python3
"""
Create FARS CrashRF-to-Accident relationships in Neo4j.

Sources:
  * 1975-2019: Accident.CF1, Accident.CF2, Accident.CF3
  * 2020-2022: crashrf.csv, one row per crash-related factor

The graph join uses accident_id = <year>_<ST_CASE>. Raw ST_CASE is not globally
unique across years, so it must not be used for Neo4j joins.

Relationship:
  (:CrashRF)-[:CRASH_FACTOR_OF]->(:Accident)

For legacy 1975-1981 CF codes, only values that can be mapped into the modern
CrashRF category nodes are imported. Weather-related legacy code 20 is excluded
because it is handled by the Weather importer.
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

CRASHRF_NODES: dict[int, str] = {
    1: "Inadequate Warning of Exits, Lanes Narrowing, Traffic Controls, etc.",
    2: "Shoulder Design or Condition",
    3: "Other Maintenance or Construction-Created Condition",
    4: "No or Obscured Pavement Marking",
    5: "Surface Under Water",
    6: "Inadequate Construction or Poor Design of Roadway, Bridge, etc.",
    7: "Surface Washed Out (Caved in, Road Slippage)",
    10: "Emergency Vehicle Related",
    12: "Distracted Driver of a Non-Contact Vehicle",
    13: "Aggressive Driving/Road Rage by Non-Contact Vehicle Driver",
    14: "Motor Vehicle Struck by Falling Cargo or Something That Came Loose From or Something That Was Set in Motion by a Vehicle",
    15: "Non-Occupant Struck by Falling Cargo, or Something Came Loose From or Something That Was Set in Motion by a Vehicle",
    16: "Non-Occupant Struck Vehicle",
    17: "Stopped Vehicle Set in Motion by Non-Driver",
    18: "Date of Crash and Date of EMS Notification Were Not Same Day",
    19: "Recent Previous Crash Scene Nearby",
    20: "Police-Pursuit-Involved",
    21: "Within Designated School Zone",
    22: "Speed Limit Is a Statutory Limit as Recorded or Was Determined as This State Basic Rule",
    23: "Indication of a Stalled/Disabled Vehicle",
    24: "Unstabilized Situation Began and All Harmful Events Occurred off of the Roadway",
    25: "Toll Booth/Plaza Related",
    26: "Prior Non-Recurring Incident",
    27: "Backup Due to Prior Crash",
    28: "Regular Congestion",
    30: "Obstructed Crosswalks",
    31: "Related to a Bus Stop",
    40: "Traffic Controls Not Functioning Properly",
    42: "Uncontrolled Intersection or Railroad Crossing",
    99: "Unknown",
}

CRASHRF_NODE_ROWS = [
    {"id": crashrf_id, "name": name}
    for crashrf_id, name in CRASHRF_NODES.items()
]

LEGACY_1975_1981_MAP: dict[int, int] = {
    24: 7,
    40: 40,
    41: 1,
    42: 42,
    43: 2,
    44: 2,
    47: 3,
    48: 4,
    49: 5,
    50: 6,
    51: 7,
    99: 99,
}

DIRECT_1982_PLUS_MAP: dict[int, int] = {
    1: 1,
    2: 2,
    3: 3,
    4: 4,
    5: 5,
    6: 6,
    7: 7,
    10: 10,
    12: 12,
    13: 13,
    14: 14,
    15: 15,
    16: 16,
    17: 17,
    18: 18,
    19: 19,
    20: 20,
    21: 21,
    22: 22,
    23: 23,
    24: 24,
    25: 25,
    26: 26,
    27: 27,
    28: 28,
    30: 30,
    31: 31,
    40: 40,
    42: 42,
    99: 99,
    999: 99,
}

MERGE_CRASHRF_NODES_QUERY = """
UNWIND $rows AS row
MERGE (c:CrashRF {id: row.id})
SET c.name = row.name
RETURN count(c) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (a:Accident {accident_id: row.accident_id})
MATCH (c:CrashRF {id: row.crashrf_id})
MERGE (c)-[r:CRASH_FACTOR_OF]->(a)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "crashrf_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS CrashRF-to-Accident relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure CrashRF constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def map_crashrf_code(year: int, code: int) -> int | None:
    if code == 0:
        return None
    if 1975 <= year <= 1981:
        return LEGACY_1975_1981_MAP.get(code)
    return DIRECT_1982_PLUS_MAP.get(code)


def add_factor(
    factors: dict[tuple[str, int], dict[str, Any]],
    accident_id: str,
    year: int,
    crashrf_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (accident_id, crashrf_id)
    factor = factors.setdefault(
        key,
        {
            "accident_id": accident_id,
            "crashrf_id": crashrf_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in factor["source_columns"]:
        factor["source_columns"].append(source_column)
    if source_code not in factor["source_codes"]:
        factor["source_codes"].append(source_code)


def find_accident_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if 1975 <= year <= 2019:
            csv_path = year_dir / "National" / "accident.csv"
            if csv_path.exists():
                files.append((year, csv_path))
    return files


def find_crashrf_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if 2020 <= year <= 2022:
            csv_path = year_dir / "National" / "crashrf.csv"
            if csv_path.exists():
                files.append((year, csv_path))
    return files


def iter_accident_batches(year: int, csv_path: Path, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in {"ST_CASE", "CF1", "CF2", "CF3"},
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        factors: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            accident_id = f"{year}_{identifier_part(record['ST_CASE'])}"
            for column in ("CF1", "CF2", "CF3"):
                code = int_code(record.get(column))
                if code is None:
                    continue
                crashrf_id = map_crashrf_code(year, code)
                if crashrf_id is None:
                    continue
                add_factor(factors, accident_id, year, crashrf_id, "ACCIDENT", column, code)
        yield list(factors.values())


def iter_crashrf_batches(year: int, csv_path: Path, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in {"ST_CASE", "CRASHRF"},
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        factors: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            code = int_code(record.get("CRASHRF"))
            if code is None:
                continue
            crashrf_id = map_crashrf_code(year, code)
            if crashrf_id is None:
                continue
            accident_id = f"{year}_{identifier_part(record['ST_CASE'])}"
            add_factor(factors, accident_id, year, crashrf_id, "CRASHRF", "CRASHRF", code)
        yield list(factors.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, csv_path in find_accident_files(base):
        for rows in iter_accident_batches(year, csv_path, chunk_size):
            yield year, "ACCIDENT", rows
    for year, csv_path in find_crashrf_files(base):
        for rows in iter_crashrf_batches(year, csv_path, chunk_size):
            yield year, "CRASHRF", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_crashrf: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    total = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total += len(rows)
        for row in rows:
            rows_by_crashrf[int(row["crashrf_id"])] += 1

    print(f"Expected CRASH_FACTOR_OF relationships: {total:,}")
    print(f"Source edge counts                    : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by CrashRF id                    : {dict(sorted(rows_by_crashrf.items()))}")
    print(f"Dry run seconds                       : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT crashrf_id IF NOT EXISTS FOR (c:CrashRF) REQUIRE c.id IS UNIQUE",
        ":CrashRF(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT accident_id IF NOT EXISTS FOR (a:Accident) REQUIRE a.accident_id IS UNIQUE",
        ":Accident(accident_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} rows in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if not rows:
            continue

        record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
        matched = int(record["matched"]) if record else 0
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} rows in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_CRASHRF_NODES_QUERY, rows=CRASHRF_NODE_ROWS).consume()
            print("Ensured CrashRF category nodes")

            if args.ensure_constraints_only:
                print(f"CrashRF constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI              : {args.uri}")
            print(f"Neo4j database         : {args.database}")
            print(f"Batch size             : {args.batch_size}")
            print(f"Script started UTC     : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"CrashRF edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:CRASH_FACTOR_OF]->() RETURN count(r) AS count").single()["count"]

        print(f"CrashRF edge load finished UTC: {utc_now()}")
        print(f"CrashRF edge load seconds     : {load_elapsed:.3f}")
        print(f"Total source rows processed   : {total_rows:,}")
        print(f"Total relationships matched   : {total_matched:,}")
        print(f"CRASH_FACTOR_OF rel count     : {rel_count:,}")
        print(f"Total script seconds          : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Damage to Vehicle Relationship (426.056s)

Average edge import time per year: 6.281s across 1975-2011.

Average edge import time per year: 17.604s across 2012-2022.

python
#!/usr/bin/env python3
"""
Create FARS Damage-to-Vehicle relationships in Neo4j.

Sources:
  * 1975-2011: vehicle-family IMPACT1/IMPACT2 columns
      vehicle.csv, vehnit.csv, and parkwork.csv where present
  * 2012-2022: damage.csv, one row per damaged area

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:Damage)-[:POSITION_OF]->(:Vehicle)

For 1980-1993, legacy IMPACT codes 15 and 16 indicate underride/override. They
are imported as Damage category nodes 15 and 16 and linked with POSITION_OF.
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

DAMAGE_NODES: dict[int, str] = {
    0: "Non-Collision",
    1: "Clock Point 1",
    2: "Clock Point 2",
    3: "Clock Point 3",
    4: "Clock Point 4",
    5: "Clock Point 5",
    6: "Clock Point 6",
    7: "Clock Point 7",
    8: "Clock Point 8",
    9: "Clock Point 9",
    10: "Clock Point 10",
    11: "Clock Point 11",
    12: "Clock Point 12",
    13: "Top",
    14: "Undercarriage",
    15: "Underride",
    16: "Override",
    18: "Set-in-Motion (Not a Clock Point)",
    61: "Left",
    62: "Left-Front Half",
    63: "Left-Back Half",
    81: "Right",
    82: "Right-Front Half",
    83: "Right-Back Half",
    98: "Not Reported",
    99: "Unknown",
}

DAMAGE_NODE_ROWS = [
    {"id": damage_id, "name": name}
    for damage_id, name in DAMAGE_NODES.items()
]

VALID_DAMAGE_IDS = set(DAMAGE_NODES)

MERGE_DAMAGE_NODES_QUERY = """
UNWIND $rows AS row
MERGE (d:Damage {id: row.id})
SET d.name = row.name
RETURN count(d) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (v:Vehicle {vehicle_id: row.vehicle_id})
MATCH (d:Damage {id: row.damage_id})
MERGE (d)-[r:POSITION_OF]->(v)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "damage_edges"
RETURN count(r) AS matched
"""

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Damage-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Damage constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def canonical_column(name: Any, table: str) -> str:
    column = normalize_column(name)
    if table == "PARKWORK" and column.startswith("P"):
        return column[1:]
    return column


def normalize_columns(columns: Iterable[Any], table: str) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = canonical_column(column, table)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    return f"{year}_{identifier_part(st_case)}_{identifier_part(veh_no)}"


def valid_damage_code(code: int) -> bool:
    return code in VALID_DAMAGE_IDS


def map_damage_code(year: int, code: int) -> int | None:
    if 1980 <= year <= 1993 and code in {15, 16}:
        return code
    if code in {15, 16}:
        return None
    if valid_damage_code(code):
        return code
    return None


def add_damage(
    damage_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    damage_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, damage_id)
    row = damage_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "damage_id": damage_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_vehicle_family_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if not (1975 <= year <= 2011):
            continue
        national = year_dir / "National"
        for table in ("vehicle", "vehnit", "parkwork"):
            csv_path = national / f"{table}.csv"
            if csv_path.exists():
                files.append((year, table.upper(), csv_path))
    return files


def find_damage_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if 2012 <= year <= 2022:
            csv_path = year_dir / "National" / "damage.csv"
            if csv_path.exists():
                files.append((year, csv_path))
    return files


def iter_vehicle_family_batches(
    year: int,
    table: str,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "IMPACT1", "IMPACT2"}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: canonical_column(c, table) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns, table)
        damage_rows: dict[tuple[str, int], dict[str, Any]] = {}

        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record["ST_CASE"], record["VEH_NO"])
            for source_column in ("IMPACT1", "IMPACT2"):
                code = int_code(record.get(source_column))
                if code is None:
                    continue

                damage_id = map_damage_code(year, code)
                if damage_id is None:
                    continue

                add_damage(damage_rows, vehicle_id, year, damage_id, table, source_column, code)

        yield list(damage_rows.values())


def iter_damage_table_batches(
    year: int,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    header = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    columns = {normalize_column(column) for column in header.columns}
    damage_column = "DAMAGE" if "DAMAGE" in columns else "MDAREAS"
    wanted = {"ST_CASE", "VEH_NO", damage_column}

    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns, "DAMAGE")
        damage_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            code = int_code(record.get(damage_column))
            if code is None:
                continue
            damage_id = map_damage_code(year, code)
            if damage_id is None:
                continue
            vehicle_id = vehicle_id_for(year, record["ST_CASE"], record["VEH_NO"])
            add_damage(damage_rows, vehicle_id, year, damage_id, "DAMAGE", damage_column, code)
        yield list(damage_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, table, csv_path in find_vehicle_family_files(base):
        for damage_rows in iter_vehicle_family_batches(year, table, csv_path, chunk_size):
            yield year, table, damage_rows
    for year, csv_path in find_damage_files(base):
        for damage_rows in iter_damage_table_batches(year, csv_path, chunk_size):
            yield year, "DAMAGE", damage_rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_damage: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    total_damage_rows = 0

    for year, source, damage_rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(damage_rows)
        rows_by_source[source] += len(damage_rows)
        total_damage_rows += len(damage_rows)
        for row in damage_rows:
            rows_by_damage[int(row["damage_id"])] += 1

    print(f"Expected POSITION_OF relationships : {total_damage_rows:,}")
    print(f"Source edge counts                 : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by Damage id                  : {dict(sorted(rows_by_damage.items()))}")
    print(f"Dry run seconds                    : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT damage_id IF NOT EXISTS FOR (d:Damage) REQUIRE d.id IS UNIQUE",
        ":Damage(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_damage_rows = 0
    total_damage_matched = 0
    current_year: int | None = None
    year_damage_rows = 0
    year_start = time.time()

    for year, _, damage_rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(
                f"  Done {current_year}: {year_damage_rows:,} edges "
                f"in {seconds_text(elapsed)} ({format_duration(elapsed)})"
            )
            current_year = year
            year_damage_rows = 0
            year_start = time.time()

        if damage_rows:
            record = session.run(MERGE_EDGE_QUERY, rows=damage_rows).single()
            matched = int(record["matched"]) if record else 0
            total_damage_rows += len(damage_rows)
            total_damage_matched += matched
            year_damage_rows += len(damage_rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(
            f"  Done {current_year}: {year_damage_rows:,} edges "
            f"in {seconds_text(elapsed)} ({format_duration(elapsed)})"
        )

    return total_damage_rows, total_damage_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_DAMAGE_NODES_QUERY, rows=DAMAGE_NODE_ROWS).consume()
            print("Ensured Damage category nodes")

            if args.ensure_constraints_only:
                print(f"Damage constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI             : {args.uri}")
            print(f"Neo4j database        : {args.database}")
            print(f"Batch size            : {args.batch_size}")
            print(f"Script started UTC    : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Damage edge load started UTC: {utc_now()}")
            total_damage_rows, total_damage_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:POSITION_OF]->() RETURN count(r) AS count").single()["count"]

        print(f"Damage edge load finished UTC: {utc_now()}")
        print(f"Damage edge load seconds     : {load_elapsed:.3f}")
        print(f"Total source edge rows       : {total_damage_rows:,}")
        print(f"Total relationships matched  : {total_damage_matched:,}")
        print(f"POSITION_OF relationship count: {rel_count:,}")
        print(f"Total script seconds          : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Distraction to Vehicle Relationship (28.560s)

Average edge import time per year: 1.527s across 2010-2017.

Average edge import time per year: 3.269s across 2018-2022.

python
#!/usr/bin/env python3
"""
Create FARS Distract-to-Vehicle relationships in Neo4j.

Sources:
  * 2010-2019: distract.csv MDRDSTRD
  * 2020-2022: distract.csv DRDISTRACT

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:Distract)-[:DISTRACTION_OF]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

DISTRACT_NODES: dict[int, str] = {
    1: "Looked but Did Not See",
    3: "By Other Occupant(s)",
    4: "By a Moving Object in Vehicle",
    5: "While Talking or Listening to Mobile Phone",
    6: "While Manipulating Mobile Phone",
    7: "While Adjusting Audio or Climate Controls",
    9: "While Using Other Component/Controls Integral to Vehicle",
    10: "While Using or Reaching for Device/Object Brought Into Vehicle",
    12: "Distracted by Outside Person, Object or Event",
    13: "Eating or Drinking",
    14: "Smoking Related",
    15: "Other Mobile Phone Related",
    16: "No Driver Present/Unknown if Driver Present",
    17: "Distraction/Inattention",
    18: "Distraction/Careless",
    19: "Careless/Inattentive",
    92: "Distraction (Distracted), Details Unknown",
    93: "Inattention (Inattentive), Details Unknown",
    96: "Not Reported",
    97: "Lost in Thought/Daydreaming",
    98: "Other Distraction",
    99: "Reported as Unknown if Distracted",
}

DISTRACT_NODE_ROWS = [
    {"id": distract_id, "name": name}
    for distract_id, name in DISTRACT_NODES.items()
]

VALID_DISTRACT_IDS = set(DISTRACT_NODES)

MERGE_DISTRACT_NODES_QUERY = """
UNWIND $rows AS row
MERGE (d:Distract {id: row.id})
SET d.name = row.name
RETURN count(d) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (v:Vehicle {vehicle_id: row.vehicle_id})
MATCH (d:Distract {id: row.distract_id})
MERGE (d)-[r:DISTRACTION_OF]->(v)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_column = row.source_column,
    r.source_code = row.source_code,
    r.created_from = "distract_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Distract-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Distract constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def find_source_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["distract.csv", f"DISTRACT_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "DRDISTRACT" in columns:
            files.append((year, path, "DRDISTRACT"))
        elif "MDRDSTRD" in columns:
            files.append((year, path, "MDRDSTRD"))
    return files


def add_distract_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    distract_id: int,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, distract_id)
    edge_rows[key] = {
        "vehicle_id": vehicle_id,
        "distract_id": distract_id,
        "DATA_YEAR": year,
        "source_table": "DISTRACT",
        "source_column": source_column,
        "source_code": source_code,
    }


def iter_source_batches(
    year: int,
    csv_path: Path,
    source_column: str,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", source_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            distract_id = int_code(record.get(source_column))
            if distract_id is None or distract_id not in VALID_DISTRACT_IDS:
                continue
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            add_distract_edge(edge_rows, vehicle_id, year, distract_id, source_column, distract_id)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, list[dict[str, Any]]]]:
    for year, csv_path, source_column in find_source_files(base):
        for rows in iter_source_batches(year, csv_path, source_column, chunk_size):
            yield year, rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_distract: Counter[int] = Counter()
    total_rows = 0

    for year, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_distract[int(row["distract_id"])] += 1

    print(f"Expected DISTRACTION_OF relationships : {total_rows:,}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by Distract id                   : {dict(sorted(rows_by_distract.items()))}")
    print(f"Dry run seconds                       : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT distract_id IF NOT EXISTS FOR (d:Distract) REQUIRE d.id IS UNIQUE",
        ":Distract(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if rows:
            record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
            matched = int(record["matched"]) if record else 0
            total_rows += len(rows)
            total_matched += matched
            year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_DISTRACT_NODES_QUERY, rows=DISTRACT_NODE_ROWS).consume()
            print("Ensured Distract category nodes")

            if args.ensure_constraints_only:
                print(f"Distract constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI              : {args.uri}")
            print(f"Neo4j database         : {args.database}")
            print(f"Batch size             : {args.batch_size}")
            print(f"Script started UTC     : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Distract edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:DISTRACTION_OF]->() RETURN count(r) AS count").single()["count"]

        print(f"Distract edge load finished UTC : {utc_now()}")
        print(f"Distract edge load seconds      : {load_elapsed:.3f}")
        print(f"Total source edge rows          : {total_rows:,}")
        print(f"Total relationships matched     : {total_matched:,}")
        print(f"DISTRACTION_OF relationship count: {rel_count:,}")
        print(f"Total script seconds            : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Driver Impairment to Vehicle Relationship (78.102s)

Average edge import time per year: 1.244s across 1975-2009.

Average edge import time per year: 2.657s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS DrImpair-to-Vehicle relationships in Neo4j.

Sources:
  * 1975-2009: vehicle-family DR_CF1/DR_CF2/DR_CF3/DR_CF4 columns
      vehicle.csv, and vehnit.csv where present
  * 2010-2022: drimpair.csv DRIMPAIR

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:DrImpair)-[:IMPAIRMENT_OF]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

DRIMPAIR_NODES: dict[int, str] = {
    1: "Ill, Blackout",
    2: "Asleep or Fatigued",
    3: "Walking With a Cane or Crutches, etc.",
    4: "Paraplegic or Restricted to Wheelchair",
    5: "Impaired Due to Previous Injury",
    6: "Deaf/Hard of Hearing",
    7: "Blind/Low Vision",
    8: "Emotional (Depressed, Angry, Disturbed, etc.)",
    9: "Under the Influence of Alcohol, Drugs, or Medication",
    10: "Physical Impairment - No Details",
    95: "No Driver Present/Unknown if Driver Present",
    96: "Other Physical Impairment",
    98: "Not Reported",
    99: "Unknown if Impaired",
}

DRIMPAIR_NODE_ROWS = [
    {"id": drimpair_id, "name": name}
    for drimpair_id, name in DRIMPAIR_NODES.items()
]

VALID_DRIMPAIR_IDS = set(DRIMPAIR_NODES)

MERGE_DRIMPAIR_NODES_QUERY = """
UNWIND $rows AS row
MERGE (d:DrImpair {id: row.id})
SET d.name = row.name
RETURN count(d) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (v:Vehicle {vehicle_id: row.vehicle_id})
MATCH (d:DrImpair {id: row.drimpair_id})
MERGE (d)-[r:IMPAIRMENT_OF]->(v)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "drimpair_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS DrImpair-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure DrImpair constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def map_legacy_driver_factor(year: int, code: int) -> int | None:
    if 1975 <= year <= 1981:
        return {
            2: 1,   # Ill, blackout.
            1: 2,   # Drowsy/sleepy/asleep/fatigued.
            3: 8,   # Depression/emotional.
            7: 10,  # Physical impairments, no details.
        }.get(code)

    if 1982 <= year <= 2009:
        mapping = {
            2: 1,   # Ill, passed out/blackout.
            1: 2,   # Drowsy/sleepy/asleep/fatigued.
            3: 8,   # Emotional.
            7: 4,   # Restricted to wheelchair.
            9: 5,   # Impaired due to previous injury.
            11: 96, # Other physical impairment.
        }
        if 1982 <= year <= 1994:
            mapping[8] = 4   # Paraplegic.
            mapping[10] = 6  # Deaf.
        if 2003 <= year <= 2009:
            mapping[5] = 9   # Alcohol/drugs/medication.
        return mapping.get(code)

    return None


def add_drimpair_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    drimpair_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, drimpair_id)
    row = edge_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "drimpair_id": drimpair_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_source_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year in range(1975, 2010):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle))
        vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
        if vehnit is not None:
            files.append((year, "VEHNIT", vehnit))
    return files


def find_drimpair_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["drimpair.csv", f"DRIMPAIR_{year}.CSV"])
        if path is not None:
            files.append((year, path))
    return files


def iter_legacy_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    header = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    columns = {normalize_column(column) for column in header.columns}
    dr_columns = [column for column in ("DR_CF1", "DR_CF2", "DR_CF3", "DR_CF4") if column in columns]
    wanted = {"ST_CASE", "VEH_NO", *dr_columns}

    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in dr_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                drimpair_id = map_legacy_driver_factor(year, code)
                if drimpair_id is None:
                    continue
                add_drimpair_edge(edge_rows, vehicle_id, year, drimpair_id, source_table, source_column, code)
        yield list(edge_rows.values())


def iter_drimpair_batches(
    year: int,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "DRIMPAIR"}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            code = int_code(record.get("DRIMPAIR"))
            if code is None or code not in VALID_DRIMPAIR_IDS:
                continue
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            add_drimpair_edge(edge_rows, vehicle_id, year, code, "DRIMPAIR", "DRIMPAIR", code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, source_table, csv_path in find_legacy_source_files(base):
        for rows in iter_legacy_batches(year, source_table, csv_path, chunk_size):
            yield year, source_table, rows
    for year, csv_path in find_drimpair_files(base):
        for rows in iter_drimpair_batches(year, csv_path, chunk_size):
            yield year, "DRIMPAIR", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_drimpair: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_drimpair[int(row["drimpair_id"])] += 1

    print(f"Expected IMPAIRMENT_OF relationships : {total_rows:,}")
    print(f"Source edge counts                   : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by DrImpair id                  : {dict(sorted(rows_by_drimpair.items()))}")
    print(f"Dry run seconds                      : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT drimpair_id IF NOT EXISTS FOR (d:DrImpair) REQUIRE d.id IS UNIQUE",
        ":DrImpair(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if rows:
            record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
            matched = int(record["matched"]) if record else 0
            total_rows += len(rows)
            total_matched += matched
            year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_DRIMPAIR_NODES_QUERY, rows=DRIMPAIR_NODE_ROWS).consume()
            print("Ensured DrImpair category nodes")

            if args.ensure_constraints_only:
                print(f"DrImpair constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI              : {args.uri}")
            print(f"Neo4j database         : {args.database}")
            print(f"Batch size             : {args.batch_size}")
            print(f"Script started UTC     : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"DrImpair edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:IMPAIRMENT_OF]->() RETURN count(r) AS count").single()["count"]

        print(f"DrImpair edge load finished UTC : {utc_now()}")
        print(f"DrImpair edge load seconds      : {load_elapsed:.3f}")
        print(f"Total source edge rows          : {total_rows:,}")
        print(f"Total relationships matched     : {total_matched:,}")
        print(f"IMPAIRMENT_OF relationship count: {rel_count:,}")
        print(f"Total script seconds            : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Average edge import time per year: 6.345s across 1975-1981.

Average edge import time per year: 5.539s across 1982-2009.

Average edge import time per year: 2.989s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS DriverRF-to-Vehicle relationships in Neo4j.

Sources:
  * 1975-2009: vehicle-family DR_CF1..DR_CF4 columns from vehicle.csv and
      vehnit.csv where present
  * 2010-2019: vehicle.csv DR_SF1..DR_SF4
  * 2020-2022: driverrf.csv DRIVERRF

Some raw numeric codes changed meaning across years. This importer only maps a
code to the requested DriverRF node for years where the workbook shows the same
or compatible meaning.

Relationship:
  (:DriverRF)-[:DRIVER_RF_OF]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

DRIVERRF_NODES: dict[int, str] = {
    4: "Reaction to or Failure to Take Drugs/Medication",
    5: "Other Drugs (Marijuana, Cocaine, etc.)",
    6: "Careless Driving, Inattentive Operation, Improper Driving, Driving Without Due Care",
    8: "Road Rage/Aggressive Driving",
    9: "Emergency Services Personnel",
    10: "Looked but Did Not See",
    12: "Mother of Dead Fetus/Mother of Infant Born Post Crash",
    13: "Mentally Challenged",
    14: "Failure to Take Drugs/Medication",
    15: "Seat Back Not in Normal Position, Seat Back Reclined",
    16: "Police or Law Enforcement Officer",
    17: "Running off Road",
    18: "Traveling on Prohibited Trafficways",
    19: "Legally Driving on Suspended or Revoked License",
    20: "Leaving Vehicle Unattended With Engine Running; Leaving Vehicle Unattended in Roadway",
    21: "Overloading or Improper Loading of Vehicle With Passenger or Cargo",
    22: "Towing or Pushing Vehicle Improperly",
    23: "Failing to Dim Lights or to Have Lights on When Required",
    24: "Operating Without Required Equipment",
    25: "Creating Unlawful Noise or Using Equipment Prohibited by Law",
    26: "Following Improperly",
    27: "Improper or Erratic Lane Changing",
    28: "Improper Lane Usage",
    29: "Intentional Illegal Driving off the Roadway",
    30: "Making Improper Entry to or Exit From Trafficway",
    31: "Starting or Backing Improperly",
    32: "Opening Vehicle Closure Into Moving Traffic or Vehicle Is in Motion",
    33: "Passing Where Prohibited by Posted Signs, Pavement Markings, or School Bus Displaying Warning Not to Pass",
    34: "Improper Passing Location",
    35: "Passing With Insufficient Distance or Inadequate Visibility or Failing to Yield to Overtaking Vehicle",
    36: "Operating the Vehicle in an Erratic, Reckless, Careless or Negligent Manner",
    37: "Police Pursuing This Driver or Police Officer in Pursuit",
    38: "Failure to Yield Right-of-Way",
    39: "Failure to Obey Actual Traffic Signs, Traffic Control Devices or Traffic Officers, Failure to Observe Safety Zone Traffic Laws",
    40: "Passing Through or Around Barrier",
    41: "Failure to Observe Warnings or Instructions on Vehicle Displaying Them",
    42: "Failure to Signal Intentions",
    43: "Giving Wrong Signal",
    44: "Driving Too Fast for Conditions or in Excess of Posted Speed Limit",
    45: "Driving Less Than Posted Maximum",
    46: "Operating at Erratic or Suddenly Changing Speeds",
    47: "Making Right Turn From Left-Turn Lane or Making Left Turn From Right-Turn Lane",
    48: "Making Improper Turn",
    49: "Failure to Comply With Physical Restrictions of License",
    50: "Driving Wrong Way on One-Way Trafficway",
    51: "Driving on Wrong Side of Two-way Trafficway (Intentionally or Unintentionally)",
    52: "Operator Inexperience",
    53: "Unfamiliar With Roadway",
    54: "Stopping in Roadway (Vehicle Not Abandoned)",
    55: "Improper Management of Vehicle Controls",
    56: "Object Interference With Vehicle Controls",
    57: "Driving With Tire-Related Problems",
    58: "Over Correcting",
    59: "Getting off/out of a Vehicle",
    60: "Alcohol and/or Drug Test Refused",
    73: "Driver Has Not Complied With Learners Permit or Intermediate Driver License Restrictions (GDL Restrictions)",
    74: "Driver Has Not Complied With Physical or Other Imposed Restrictions",
    77: "Severe Crosswind",
    78: "Wind From Passing Truck",
    79: "Slippery or Loose Surface",
    80: "Tire Blow-Out or Flat",
    81: "Debris or Objects in Road",
    82: "Ruts, Holes, Bumps in Road",
    83: "Live Animals in Road",
    84: "Vehicle in Road",
    85: "Phantom Vehicle",
    86: "Pedestrian, Pedalcyclist, or Other Non-Motorist in Road",
    87: "Ice, Water, Snow, Slush, Sand, Dirt, Oil, Wet Leaves on Road",
    88: "Trailer Fishtailing or Swaying",
    89: "Driver has a Driving Record or Driver's License From More Than One State",
    90: "Hit-and-Run Vehicle Driver",
    91: "Non-Traffic Violation Charged (Manslaughter, Homicide or Other Assault Offense Committed Without Malice)",
    92: "Other Non-Moving Traffic Violation",
    93: "Cellular Telephone",
    94: "Emergency Medical Service Personnel",
    95: "Fire Personnel",
    96: "Tow Operator",
    97: "Transportation (i.e., Maintenance Workers, Safety Service Patrol Operators, etc.)",
    98: "Head-Up Display (1991-2009)",
    999: "Reported as Unknown",
}

DRIVERRF_NODE_ROWS = [{"id": code, "name": name} for code, name in DRIVERRF_NODES.items()]

MERGE_DRIVERRF_NODES_QUERY = """
UNWIND $rows AS row
MERGE (rf:DriverRF {id: row.id})
SET rf.name = row.name
RETURN count(rf) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (veh:Vehicle {vehicle_id: row.vehicle_id})
MATCH (rf:DriverRF {id: row.driverrf_id})
MERGE (rf)-[r:DRIVER_RF_OF]->(veh)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "driverrf_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS DriverRF-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure DriverRF constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def is_valid_driverrf_for_year(year: int, code: int) -> bool:
    if code not in DRIVERRF_NODES:
        return False
    if code == 5:
        return 1975 <= year <= 1994
    if code == 8:
        return year >= 2004
    if code == 9:
        return year == 2018
    if code == 10:
        return year >= 2018
    if code == 12:
        return year >= 1982
    if code == 13:
        return year >= 1995
    if code == 14:
        return 1995 <= year <= 2004
    if code in {15, 16}:
        return year >= 2002
    if code == 17:
        return 2000 <= year <= 2003
    if code == 18:
        return year >= 1995
    if code == 19:
        return year >= 1982
    if code == 25:
        return year <= 2009
    if code == 37:
        return year >= 1978
    if code == 43:
        return year <= 1981
    if code == 44:
        return year <= 2008
    if code == 45:
        return year >= 1982
    if code == 46:
        return year <= 1994
    if code == 49:
        return year <= 2004
    if code in {55, 56, 57, 60}:
        return year >= 2017
    if code == 59:
        return year >= 1982
    if code in {73, 74}:
        return year >= 2004
    if code in {77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88}:
        return year >= 1982
    if code == 89:
        return year >= 2010
    if code == 90:
        return 1982 <= year <= 2009
    if code == 91:
        return 1986 <= year <= 2020
    if code == 92:
        return 1986 <= year <= 2011
    if code in {93, 98}:
        return 1991 <= year <= 2009
    if code in {94, 95, 96, 97}:
        return year >= 2019
    if code == 999:
        return year >= 2020
    return True


def add_driverrf_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    driverrf_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, driverrf_id)
    row = edge_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "driverrf_id": driverrf_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_source_files(base: Path) -> list[tuple[int, str, Path, tuple[str, ...]]]:
    files: list[tuple[int, str, Path, tuple[str, ...]]] = []
    for year in range(1975, 2010):
        national = base / str(year) / "National"
        columns = ("DR_CF1", "DR_CF2", "DR_CF3", "DR_CF4")
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle, columns))
        vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
        if vehnit is not None:
            files.append((year, "VEHNIT", vehnit, columns))
    return files


def find_driversf_source_files(base: Path) -> list[tuple[int, str, Path, tuple[str, ...]]]:
    files: list[tuple[int, str, Path, tuple[str, ...]]] = []
    for year in range(2010, 2020):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle, ("DR_SF1", "DR_SF2", "DR_SF3", "DR_SF4")))
    return files


def find_driverrf_source_files(base: Path) -> list[tuple[int, str, Path, tuple[str, ...]]]:
    files: list[tuple[int, str, Path, tuple[str, ...]]] = []
    for year in range(2020, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["driverrf.csv", f"DRIVERRF_{year}.CSV"])
        if path is not None:
            files.append((year, "DRIVERRF", path, ("DRIVERRF",)))
    return files


def iter_source_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    source_columns: tuple[str, ...],
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    header = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    available = {normalize_column(column) for column in header.columns}
    active_columns = tuple(column for column in source_columns if column in available)
    if not active_columns:
        return
    wanted = {"ST_CASE", "VEH_NO", *active_columns}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in active_columns:
                code = int_code(record.get(source_column))
                if code is None or not is_valid_driverrf_for_year(year, code):
                    continue
                add_driverrf_edge(edge_rows, vehicle_id, year, code, source_table, source_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, source_table, csv_path, source_columns in find_legacy_source_files(base):
        for rows in iter_source_batches(year, source_table, csv_path, source_columns, chunk_size):
            yield year, source_table, rows
    for year, source_table, csv_path, source_columns in find_driversf_source_files(base):
        for rows in iter_source_batches(year, source_table, csv_path, source_columns, chunk_size):
            yield year, source_table, rows
    for year, source_table, csv_path, source_columns in find_driverrf_source_files(base):
        for rows in iter_source_batches(year, source_table, csv_path, source_columns, chunk_size):
            yield year, source_table, rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_driverrf: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_driverrf[int(row["driverrf_id"])] += 1

    print(f"Expected DRIVER_RF_OF relationships : {total_rows:,}")
    print(f"Source edge counts                  : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by DriverRF id                 : {dict(sorted(rows_by_driverrf.items()))}")
    print(f"Dry run seconds                     : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT driverrf_id IF NOT EXISTS FOR (rf:DriverRF) REQUIRE rf.id IS UNIQUE",
        ":DriverRF(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()
        if not rows:
            continue
        result = session.run(MERGE_EDGE_QUERY, rows=rows)
        matched = result.single()["matched"]
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started UTC: {utc_now()}")
    print(f"Base: {args.base}")

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        print(f"Finished UTC: {utc_now()}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            node_result = session.run(MERGE_DRIVERRF_NODES_QUERY, rows=DRIVERRF_NODE_ROWS).single()
            print(f"Ensured DriverRF nodes: {node_result['matched']}")
            if args.ensure_constraints_only:
                return
            rows_sent, rows_matched = import_relationships(session, args.base, args.batch_size)
            total_elapsed = time.time() - total_start
            print(f"\nTotal DRIVER_RF_OF rows sent: {rows_sent:,}")
            print(f"Total DRIVER_RF_OF rows matched: {rows_matched:,}")
            print(f"Total seconds: {total_elapsed:.3f}")
            print(f"Total duration: {format_duration(total_elapsed)}")
    finally:
        driver.close()
    print(f"Finished UTC: {utc_now()}")


if __name__ == "__main__":
    main()

Drug Event to Person Relationship (228.852s)

This import is performed as one total batch rather than by year, so no yearly average time is available.

python
#!/usr/bin/env python3
"""
Create FARS DrugEvent nodes from historical person drug columns and drugs.csv.

Sources:
  * 1991-1992: PERSON DRUGTEST / DRUG_RES, one event slot per person row.
  * 1993-2017: PERSON DRUGTST1..3 / DRUGRES1..3, up to three event slots.
  * 2018-2022: drugs.csv DRUGSPEC / DRUGRES, one event per drugs.csv row.

The graph join uses person_id = <year>_<ST_CASE>_<VEH_NO>_<PER_NO>. DrugEvent
nodes also keep person_id as a property and are linked with:

  (:DrugEvent)-[:DRUG_EVENT_OF]->(:Person)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Iterator

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

MERGE_DRUG_EVENT_QUERY = """
UNWIND $rows AS row
MERGE (d:DrugEvent {drug_event_id: row.drug_event_id})
SET d += row
WITH d, row
OPTIONAL MATCH (p:Person {person_id: row.person_id})
FOREACH (_ IN CASE WHEN p IS NULL THEN [] ELSE [1] END |
  MERGE (d)-[:DRUG_EVENT_OF]->(p)
)
RETURN count(d) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS DrugEvent nodes in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure constraints, then exit without importing nodes.",
    )
    return parser.parse_args()


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def clean_text(value: Any) -> str | None:
    if value is None or pd.isna(value):
        return None
    text = str(value).replace("\x1a", "").strip()
    if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
        text = text[1:-1].strip()
    text = " ".join(text.split())
    return text or None


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def has_drug_event(drugtest_code: int | None, drugres_code: int | None) -> bool:
    if drugtest_code not in (None, 0):
        return True
    return drugres_code not in (None, 0, 1)


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def read_csv(path: Path) -> pd.DataFrame:
    for encoding in ("utf-8", "latin-1"):
        try:
            df = pd.read_csv(path, encoding=encoding, low_memory=False)
            df.columns = normalize_columns(df.columns)
            return df
        except UnicodeDecodeError:
            continue
    raise RuntimeError(f"Could not decode {path}")


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def find_person_file(base: Path, year: int) -> Path | None:
    national = base / str(year) / "National"
    return find_file(national, [f"PERSON_{year}.CSV", "person.csv", "PERSON.CSV", "Person.csv"])


def find_drugs_file(base: Path, year: int) -> Path | None:
    national = base / str(year) / "National"
    return find_file(national, [f"DRUGS_{year}.CSV", "drugs.csv", "DRUGS.CSV", "Drugs.csv"])


def base_row(
    year: int,
    person_id: str,
    st_case: Any,
    veh_no: Any,
    per_no: Any,
    source_table: str,
    source_slot: str,
    source_row_number: int,
    drugtest_code: int | None,
    drugres_code: int | None,
    drugtest_name: str | None = None,
    drugres_name: str | None = None,
) -> dict[str, Any]:
    drug_event_id = f"{person_id}_DRUG_{source_slot}"
    return {
        "drug_event_id": drug_event_id,
        "person_id": person_id,
        "DATA_YEAR": year,
        "ST_CASE": person_id.rsplit("_", 2)[0],
        "VEH_NO": identifier_part(veh_no),
        "PER_NO": identifier_part(per_no),
        "source_table": source_table,
        "source_slot": source_slot,
        "source_row_number": source_row_number,
        "drugtest_code": drugtest_code,
        "drugres_code": drugres_code,
        "drugtest_name": drugtest_name,
        "drugres_name": drugres_name,
        "created_from": "drug_event_import",
    }


def iter_person_drug_events(base: Path, year: int) -> Iterator[dict[str, Any]]:
    path = find_person_file(base, year)
    if path is None:
        print(f"  WARNING {year}: no PERSON file found")
        return

    df = read_csv(path)
    required = {"ST_CASE", "VEH_NO", "PER_NO"}
    missing = sorted(required - set(df.columns))
    if missing:
        print(f"  WARNING {year}: {path.name} missing {missing}; skipping")
        return

    if year <= 1992:
        pairs = [("1", "DRUGTEST", "DRUG_RES", None, None)]
    else:
        pairs = [
            ("1", "DRUGTST1", "DRUGRES1", "DRUGTST1NAME", "DRUGRES1NAME"),
            ("2", "DRUGTST2", "DRUGRES2", "DRUGTST2NAME", "DRUGRES2NAME"),
            ("3", "DRUGTST3", "DRUGRES3", "DRUGTST3NAME", "DRUGRES3NAME"),
        ]

    for row_number, rec in enumerate(df.itertuples(index=False), start=1):
        values = rec._asdict()
        person_id = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
        for slot, test_col, res_col, test_name_col, res_name_col in pairs:
            if test_col not in df.columns or res_col not in df.columns:
                continue
            drugtest_code = int_code(values[test_col])
            drugres_code = int_code(values[res_col])

            if not has_drug_event(drugtest_code, drugres_code):
                continue

            yield base_row(
                year=year,
                person_id=person_id,
                st_case=values["ST_CASE"],
                veh_no=values["VEH_NO"],
                per_no=values["PER_NO"],
                source_table="PERSON",
                source_slot=slot,
                source_row_number=row_number,
                drugtest_code=drugtest_code,
                drugres_code=drugres_code,
                drugtest_name=clean_text(values.get(test_name_col)) if test_name_col else None,
                drugres_name=clean_text(values.get(res_name_col)) if res_name_col else None,
            )


def iter_drugs_table_events(base: Path, year: int) -> Iterator[dict[str, Any]]:
    path = find_drugs_file(base, year)
    if path is None:
        print(f"  WARNING {year}: no drugs.csv file found")
        return

    df = read_csv(path)
    required = {"ST_CASE", "VEH_NO", "PER_NO", "DRUGSPEC", "DRUGRES"}
    missing = sorted(required - set(df.columns))
    if missing:
        print(f"  WARNING {year}: {path.name} missing {missing}; skipping")
        return

    person_sequence: Counter[str] = Counter()
    for row_number, rec in enumerate(df.itertuples(index=False), start=1):
        values = rec._asdict()
        drugtest_code = int_code(values["DRUGSPEC"])
        drugres_code = int_code(values["DRUGRES"])
        if not has_drug_event(drugtest_code, drugres_code):
            continue
        person_id = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
        person_sequence[person_id] += 1
        slot = str(person_sequence[person_id])
        yield base_row(
            year=year,
            person_id=person_id,
            st_case=values["ST_CASE"],
            veh_no=values["VEH_NO"],
            per_no=values["PER_NO"],
            source_table="DRUGS",
            source_slot=slot,
            source_row_number=row_number,
            drugtest_code=drugtest_code,
            drugres_code=drugres_code,
            drugtest_name=clean_text(values.get("DRUGSPECNAME")),
            drugres_name=clean_text(values.get("DRUGRESNAME")),
        )


def iter_all_events(base: Path) -> Iterator[dict[str, Any]]:
    for year in range(1991, 2018):
        yield from iter_person_drug_events(base, year)
    for year in range(2018, 2023):
        yield from iter_drugs_table_events(base, year)


def dry_run(base: Path) -> None:
    total = 0
    counts_by_year: Counter[int] = Counter()
    counts_by_source: Counter[str] = Counter()
    combo_counts: Counter[tuple[int | None, int | None]] = Counter()
    start = time.time()

    for row in iter_all_events(base):
        total += 1
        counts_by_year[row["DATA_YEAR"]] += 1
        counts_by_source[row["source_table"]] += 1
        combo_counts[(row["drugtest_code"], row["drugres_code"])] += 1

    print(f"Dry run DrugEvent nodes prepared: {total}")
    print(f"Counts by source: {dict(sorted(counts_by_source.items()))}")
    print(f"Counts by year: {dict(sorted(counts_by_year.items()))}")
    print(f"Distinct drugtest/drugres combinations: {len(combo_counts)}")
    print("Top 20 combinations:")
    for (test_code, res_code), count in combo_counts.most_common(20):
        print(f"  drugtest={test_code}, drugres={res_code}: {count}")
    print(f"Dry-run elapsed seconds: {time.time() - start:.3f}")


def ensure_constraints(session: Any) -> None:
    session.run(
        "CREATE CONSTRAINT drugevent_id_unique IF NOT EXISTS "
        "FOR (d:DrugEvent) REQUIRE d.drug_event_id IS UNIQUE"
    ).consume()
    session.run(
        "CREATE CONSTRAINT person_person_id_unique IF NOT EXISTS "
        "FOR (p:Person) REQUIRE p.person_id IS UNIQUE"
    ).consume()


def import_events(session: Any, base: Path, batch_size: int) -> int:
    total = 0
    batch: list[dict[str, Any]] = []
    year_counts: Counter[int] = Counter()
    start = time.time()

    for row in iter_all_events(base):
        batch.append(row)
        year_counts[row["DATA_YEAR"]] += 1
        if len(batch) >= batch_size:
            result = session.run(MERGE_DRUG_EVENT_QUERY, rows=batch).single()
            total += result["matched"] if result else 0
            print(f"  Imported batch: total={total}")
            batch = []

    if batch:
        result = session.run(MERGE_DRUG_EVENT_QUERY, rows=batch).single()
        total += result["matched"] if result else 0
        print(f"  Imported final batch: total={total}")

    print(f"Counts by year: {dict(sorted(year_counts.items()))}")
    print(f"Import loop elapsed seconds: {time.time() - start:.3f}")
    return total


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started at {utc_now()}")
    print(f"Base: {args.base}")
    print(f"Database: {args.database}")

    if args.dry_run:
        dry_run(args.base)
        print(f"Finished at {utc_now()}")
        print(f"Total elapsed seconds: {time.time() - total_start:.3f}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            if args.ensure_constraints_only:
                print("Constraints ensured; no DrugEvent nodes imported.")
                return
            imported = import_events(session, args.base, args.batch_size)
            print(f"Total DrugEvent nodes matched/created: {imported}")
    finally:
        driver.close()

    total_elapsed = time.time() - total_start
    print(f"Finished at {utc_now()}")
    print(f"Total elapsed: {format_duration(total_elapsed)}")
    print(f"Total elapsed seconds: {total_elapsed:.3f}")


if __name__ == "__main__":
    main()

Drug Test Result to Drug Event Relationship (192.319s)

This import is performed as one total batch rather than by year, so no yearly average time is available.

python
#!/usr/bin/env python3
"""
Create Drug nodes and Drug-to-DrugEvent result relationships.

Rules:
  * 2018-2022: create Drug nodes from drugres_code_name_mapping_2018_2022.csv
    and link by DRUGRES code.
  * 2015-2017: lowercase DrugEvent.drugres_name, then link by name to the
    existing 2018-2022 Drug nodes. If no name match exists, link to/create a
    historical Drug node with id = 19932017<drugres_code>.
  * 1993-2014: no name exists in PERSON, so link by historical Drug node id
    19932017<drugres_code>. The node name comes from the 2015-2017 mapping
    when available; otherwise it is "N/A".

Relationship:
  (:Drug)-[:RESULT_OF]->(:DrugEvent)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Iterator

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

MODERN_MAPPING = "drugres_code_name_mapping_2018_2022.csv"
LEGACY_MAPPING = "drugres_code_name_mapping_2015_2017.csv"
LEGACY_PREFIX = "19932017"

MERGE_DRUG_NODES_QUERY = """
UNWIND $rows AS row
MERGE (d:Drug {id: row.id, name: row.name})
SET d.code = row.code,
    d.name_key = row.name_key,
    d.source_mapping = row.source_mapping,
    d.created_from = "drug_result_edges_import"
RETURN count(d) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (d:Drug {id: row.drug_id, name: row.drug_name})
MATCH (e:DrugEvent {drug_event_id: row.drug_event_id})
MERGE (d)-[r:RESULT_OF]->(e)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_slot = row.source_slot,
    r.source_drugres_code = row.source_drugres_code,
    r.match_method = row.match_method,
    r.created_from = "drug_result_edges_import"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create Drug result nodes and RESULT_OF relationships.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument("--ensure-nodes-only", action="store_true", help="Only create/update Drug nodes.")
    return parser.parse_args()


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def clean_name(value: Any) -> str | None:
    if value is None or pd.isna(value):
        return None
    text = str(value).replace("\x1a", "").replace('"', "").replace("“", "").replace("”", "").strip()
    text = " ".join(text.split()).lower()
    return text or None


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def has_drug_event(drugtest_code: int | None, drugres_code: int | None) -> bool:
    if drugtest_code not in (None, 0):
        return True
    return drugres_code not in (None, 0, 1)


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def read_csv(path: Path) -> pd.DataFrame:
    for encoding in ("utf-8", "latin-1"):
        try:
            df = pd.read_csv(path, encoding=encoding, low_memory=False)
            df.columns = normalize_columns(df.columns)
            return df
        except UnicodeDecodeError:
            continue
    raise RuntimeError(f"Could not decode {path}")


def read_mapping(path: Path) -> pd.DataFrame:
    df = pd.read_csv(path, escapechar="\\")
    df.columns = [str(c).strip().lower() for c in df.columns]
    if not {"code", "name"}.issubset(df.columns):
        raise ValueError(f"{path} must contain code,name columns")
    df["code"] = pd.to_numeric(df["code"], errors="coerce").astype("Int64")
    df["name"] = df["name"].map(clean_name)
    df = df.dropna(subset=["code", "name"])
    df["code"] = df["code"].astype(int)
    return df[["code", "name"]]


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def find_person_file(base: Path, year: int) -> Path | None:
    national = base / str(year) / "National"
    return find_file(national, [f"PERSON_{year}.CSV", "person.csv", "PERSON.CSV", "Person.csv"])


def find_drugs_file(base: Path, year: int) -> Path | None:
    national = base / str(year) / "National"
    return find_file(national, [f"DRUGS_{year}.CSV", "drugs.csv", "DRUGS.CSV", "Drugs.csv"])


def legacy_id(code: int) -> int:
    return int(f"{LEGACY_PREFIX}{code}")


def drug_node(drug_id: int | str, code: int | None, name: str, source_mapping: str) -> dict[str, Any]:
    return {
        "id": drug_id,
        "code": code,
        "name": name,
        "name_key": clean_name(name) if name != "N/A" else "n/a",
        "source_mapping": source_mapping,
    }


def load_modern_nodes(base: Path) -> tuple[
    list[dict[str, Any]],
    dict[int, dict[str, Any]],
    dict[str, list[dict[str, Any]]],
]:
    mapping = read_mapping(base / MODERN_MAPPING)
    seen: Counter[tuple[int, str]] = Counter()
    rows: list[dict[str, Any]] = []
    by_code: dict[int, dict[str, Any]] = {}
    by_name: dict[str, list[dict[str, Any]]] = defaultdict(list)

    for record in mapping.itertuples(index=False):
        code = int(record.code)
        name = str(record.name)
        seen[(code, name)] += 1
        drug_id: int | str = code if seen[(code, name)] == 1 else f"{code}_{seen[(code, name)]}"
        row = drug_node(drug_id, code, name, "drugres_code_name_mapping_2018_2022")
        rows.append(row)
        by_code.setdefault(code, row)
        by_name[name].append(row)
    return rows, by_code, by_name


def load_legacy_name_by_code(base: Path) -> dict[int, str]:
    mapping = read_mapping(base / LEGACY_MAPPING)
    return {int(row.code): str(row.name) for row in mapping.itertuples(index=False)}


def base_edge_row(
    drug: dict[str, Any],
    year: int,
    person_id: str,
    source_table: str,
    source_slot: str,
    source_drugres_code: int | None,
    match_method: str,
) -> dict[str, Any]:
    return {
        "drug_id": drug["id"],
        "drug_name": drug["name"],
        "drug_event_id": f"{person_id}_DRUG_{source_slot}",
        "DATA_YEAR": year,
        "source_table": source_table,
        "source_slot": source_slot,
        "source_drugres_code": source_drugres_code,
        "match_method": match_method,
    }


def iter_2018_2022_edges(
    base: Path,
    by_code: dict[int, dict[str, Any]],
) -> Iterator[tuple[dict[str, Any] | None, str]]:
    for year in range(2018, 2023):
        path = find_drugs_file(base, year)
        if path is None:
            print(f"  WARNING {year}: no drugs.csv found")
            continue
        df = read_csv(path)
        person_sequence: Counter[str] = Counter()
        for rec in df.itertuples(index=False):
            values = rec._asdict()
            drugtest_code = int_code(values["DRUGSPEC"])
            drugres_code = int_code(values["DRUGRES"])
            if not has_drug_event(drugtest_code, drugres_code):
                continue
            person_id = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
            person_sequence[person_id] += 1
            source_slot = str(person_sequence[person_id])
            drug = by_code.get(drugres_code) if drugres_code is not None else None
            if drug is None:
                yield None, "unmatched_code_2018_2022"
                continue
            yield base_edge_row(drug, year, person_id, "DRUGS", source_slot, drugres_code, "code_2018_2022"), "code_2018_2022"


def iter_2015_2017_edges(
    base: Path,
    by_name: dict[str, list[dict[str, Any]]],
    legacy_nodes: dict[int, dict[str, Any]],
) -> Iterator[tuple[dict[str, Any] | None, str]]:
    for year in range(2015, 2018):
        path = find_person_file(base, year)
        if path is None:
            print(f"  WARNING {year}: no PERSON file found")
            continue
        df = read_csv(path)
        for rec in df.itertuples(index=False):
            values = rec._asdict()
            person_id = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
            for slot in ("1", "2", "3"):
                test_col = f"DRUGTST{slot}"
                res_col = f"DRUGRES{slot}"
                name_col = f"DRUGRES{slot}NAME"
                drugtest_code = int_code(values[test_col])
                drugres_code = int_code(values[res_col])
                if not has_drug_event(drugtest_code, drugres_code):
                    continue
                name = clean_name(values.get(name_col))
                if name and name in by_name:
                    for drug in by_name[name]:
                        yield base_edge_row(drug, year, person_id, "PERSON", slot, drugres_code, "name_2015_2017"), "name_2015_2017"
                    continue
                if drugres_code is None:
                    yield None, "unmatched_no_code_2015_2017"
                    continue
                lname = name or "N/A"
                drug = legacy_nodes.setdefault(
                    drugres_code,
                    drug_node(legacy_id(drugres_code), drugres_code, lname, "legacy_1993_2017_from_2015_2017_unmatched"),
                )
                yield base_edge_row(drug, year, person_id, "PERSON", slot, drugres_code, "legacy_created_2015_2017"), "legacy_created_2015_2017"


def iter_1993_2014_edges(
    base: Path,
    legacy_name_by_code: dict[int, str],
    legacy_nodes: dict[int, dict[str, Any]],
) -> Iterator[tuple[dict[str, Any] | None, str]]:
    for year in range(1993, 2015):
        path = find_person_file(base, year)
        if path is None:
            print(f"  WARNING {year}: no PERSON file found")
            continue
        df = read_csv(path)
        for rec in df.itertuples(index=False):
            values = rec._asdict()
            person_id = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
            for slot in ("1", "2", "3"):
                test_col = f"DRUGTST{slot}"
                res_col = f"DRUGRES{slot}"
                drugtest_code = int_code(values[test_col])
                drugres_code = int_code(values[res_col])
                if not has_drug_event(drugtest_code, drugres_code):
                    continue
                if drugres_code is None:
                    yield None, "unmatched_no_code_1993_2014"
                    continue
                if drugres_code in legacy_nodes:
                    drug = legacy_nodes[drugres_code]
                    method = "legacy_id_1993_2014"
                else:
                    name = legacy_name_by_code.get(drugres_code, "N/A")
                    source = "legacy_1993_2017_mapping" if drugres_code in legacy_name_by_code else "legacy_1993_2017_na"
                    drug = legacy_nodes.setdefault(drugres_code, drug_node(legacy_id(drugres_code), drugres_code, name, source))
                    method = "legacy_id_1993_2014" if name != "N/A" else "legacy_id_created_na_1993_2014"
                yield base_edge_row(drug, year, person_id, "PERSON", slot, drugres_code, method), method


def collect_legacy_nodes(
    base: Path,
    by_name: dict[str, list[dict[str, Any]]],
    legacy_name_by_code: dict[int, str],
) -> dict[int, dict[str, Any]]:
    legacy_nodes: dict[int, dict[str, Any]] = {}
    for _row, _method in iter_2015_2017_edges(base, by_name, legacy_nodes):
        pass
    for _row, _method in iter_1993_2014_edges(base, legacy_name_by_code, legacy_nodes):
        pass
    return legacy_nodes


def iter_all_edges(
    base: Path,
    by_code: dict[int, dict[str, Any]],
    by_name: dict[str, list[dict[str, Any]]],
    legacy_name_by_code: dict[int, str],
    legacy_nodes: dict[int, dict[str, Any]],
) -> Iterator[tuple[dict[str, Any] | None, str]]:
    yield from iter_1993_2014_edges(base, legacy_name_by_code, legacy_nodes)
    yield from iter_2015_2017_edges(base, by_name, legacy_nodes)
    yield from iter_2018_2022_edges(base, by_code)


def dry_run(base: Path) -> None:
    modern_nodes, by_code, by_name = load_modern_nodes(base)
    legacy_name_by_code = load_legacy_name_by_code(base)
    legacy_nodes = collect_legacy_nodes(base, by_name, legacy_name_by_code)

    total_edges = 0
    method_counts: Counter[str] = Counter()
    year_counts: Counter[int] = Counter()
    unmatched_counts: Counter[str] = Counter()
    start = time.time()
    for row, method in iter_all_edges(base, by_code, by_name, legacy_name_by_code, legacy_nodes):
        method_counts[method] += 1
        if row is None:
            unmatched_counts[method] += 1
            continue
        total_edges += 1
        year_counts[row["DATA_YEAR"]] += 1

    print(f"Modern Drug nodes from 2018-2022 mapping: {len(modern_nodes)}")
    print(f"Historical Drug nodes with id {LEGACY_PREFIX}<code>: {len(legacy_nodes)}")
    print(f"Dry run RESULT_OF edges prepared: {total_edges}")
    print(f"Counts by year: {dict(sorted(year_counts.items()))}")
    print(f"Counts by match method: {dict(sorted(method_counts.items()))}")
    print(f"Unmatched counts: {dict(sorted(unmatched_counts.items()))}")
    print(f"Dry-run elapsed seconds: {time.time() - start:.3f}")


def normalize_drug_event_names(session: Any) -> None:
    result = session.run(
        """
        MATCH (e:DrugEvent)
        WHERE e.drugres_name IS NOT NULL
        SET e.drugres_name = toLower(e.drugres_name)
        RETURN count(e) AS updated
        """
    ).single()
    print(f"Lowercased DrugEvent.drugres_name values: {result['updated'] if result else 0}")


def ensure_indexes_and_nodes(session: Any, drug_nodes: list[dict[str, Any]]) -> None:
    session.run("CREATE INDEX drug_id_name IF NOT EXISTS FOR (d:Drug) ON (d.id, d.name)").consume()
    session.run("CREATE INDEX drug_code IF NOT EXISTS FOR (d:Drug) ON (d.code)").consume()
    session.run("CREATE INDEX drug_name_key IF NOT EXISTS FOR (d:Drug) ON (d.name_key)").consume()
    session.run(
        "CREATE CONSTRAINT drugevent_id_unique IF NOT EXISTS "
        "FOR (e:DrugEvent) REQUIRE e.drug_event_id IS UNIQUE"
    ).consume()
    total = 0
    for i in range(0, len(drug_nodes), DEFAULT_BATCH_SIZE):
        batch = drug_nodes[i : i + DEFAULT_BATCH_SIZE]
        result = session.run(MERGE_DRUG_NODES_QUERY, rows=batch).single()
        total += result["matched"] if result else 0
    print(f"Ensured Drug nodes: {total}")


def import_edges(
    session: Any,
    base: Path,
    by_code: dict[int, dict[str, Any]],
    by_name: dict[str, list[dict[str, Any]]],
    legacy_name_by_code: dict[int, str],
    legacy_nodes: dict[int, dict[str, Any]],
    batch_size: int,
) -> int:
    total = 0
    batch: list[dict[str, Any]] = []
    method_counts: Counter[str] = Counter()
    unmatched_counts: Counter[str] = Counter()
    start = time.time()
    for row, method in iter_all_edges(base, by_code, by_name, legacy_name_by_code, legacy_nodes):
        method_counts[method] += 1
        if row is None:
            unmatched_counts[method] += 1
            continue
        batch.append(row)
        if len(batch) >= batch_size:
            result = session.run(MERGE_EDGE_QUERY, rows=batch).single()
            total += result["matched"] if result else 0
            print(f"  Imported batch: total={total}")
            batch = []
    if batch:
        result = session.run(MERGE_EDGE_QUERY, rows=batch).single()
        total += result["matched"] if result else 0
        print(f"  Imported final batch: total={total}")
    print(f"Counts by match method: {dict(sorted(method_counts.items()))}")
    print(f"Unmatched counts: {dict(sorted(unmatched_counts.items()))}")
    print(f"Import loop elapsed seconds: {time.time() - start:.3f}")
    return total


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started at {utc_now()}")
    print(f"Base: {args.base}")
    print(f"Database: {args.database}")

    if args.dry_run:
        dry_run(args.base)
        print(f"Finished at {utc_now()}")
        print(f"Total elapsed seconds: {time.time() - total_start:.3f}")
        return

    from neo4j import GraphDatabase

    modern_nodes, by_code, by_name = load_modern_nodes(args.base)
    legacy_name_by_code = load_legacy_name_by_code(args.base)
    legacy_nodes = collect_legacy_nodes(args.base, by_name, legacy_name_by_code)
    all_drug_nodes = modern_nodes + list(legacy_nodes.values())

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            normalize_drug_event_names(session)
            ensure_indexes_and_nodes(session, all_drug_nodes)
            if args.ensure_nodes_only:
                print("Drug nodes ensured; no RESULT_OF edges imported.")
                return
            imported = import_edges(session, args.base, by_code, by_name, legacy_name_by_code, legacy_nodes, args.batch_size)
            print(f"Total RESULT_OF edges matched/created: {imported}")
    finally:
        driver.close()

    total_elapsed = time.time() - total_start
    print(f"Finished at {utc_now()}")
    print(f"Total elapsed: {format_duration(total_elapsed)}")
    print(f"Total elapsed seconds: {total_elapsed:.3f}")


if __name__ == "__main__":
    main()

Drug Test to Drug Event Relationship (227.472s)

This import is performed as one total batch rather than by year, so no yearly average time is available.

python
#!/usr/bin/env python3
"""
Create FARS DrugTest-to-DrugEvent relationships.

Sources:
  * 1991-1992: PERSON DRUGTEST / DRUG_RES
  * 1993-2017: PERSON DRUGTST1..3 / DRUGRES1..3
  * 2018-2022: drugs.csv DRUGSPEC / DRUGRES

Relationship:
  (:DrugTest)-[:DRUG_TEST_OF]->(:DrugEvent)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Iterator

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

DRUGTEST_NODES: dict[int, str] = {
    0: "Test Not Given",
    1: "Whole Blood",
    2: "Urine",
    11: "Blood Plasma/Serum",
    12: "Blood Clot",
    13: "Oral Fluids",
    14: "Vitreous",
    15: "Liver",
    96: "Not Reported",
    97: "Unknown Specimen",
    98: "Other Specimen",
    99: "Reported as Unknown if Tested",
}

DRUGTEST_NODE_ROWS = [{"id": code, "name": name} for code, name in DRUGTEST_NODES.items()]

MERGE_DRUGTEST_NODES_QUERY = """
UNWIND $rows AS row
MERGE (t:DrugTest {id: row.id})
SET t.name = row.name
RETURN count(t) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (t:DrugTest {id: row.drugtest_id})
MATCH (d:DrugEvent {drug_event_id: row.drug_event_id})
MERGE (t)-[r:DRUG_TEST_OF]->(d)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_slot = row.source_slot,
    r.source_drugtest_code = row.source_drugtest_code,
    r.AND_FLAG = row.AND_FLAG,
    r.OR_FLAG = row.OR_FLAG,
    r.created_from = "drugtest_edges_import"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS DrugTest-to-DrugEvent relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure constraints and DrugTest nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def has_drug_event(drugtest_code: int | None, drugres_code: int | None) -> bool:
    if drugtest_code not in (None, 0):
        return True
    return drugres_code not in (None, 0, 1)


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def read_csv(path: Path) -> pd.DataFrame:
    for encoding in ("utf-8", "latin-1"):
        try:
            df = pd.read_csv(path, encoding=encoding, low_memory=False)
            df.columns = normalize_columns(df.columns)
            return df
        except UnicodeDecodeError:
            continue
    raise RuntimeError(f"Could not decode {path}")


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def find_person_file(base: Path, year: int) -> Path | None:
    national = base / str(year) / "National"
    return find_file(national, [f"PERSON_{year}.CSV", "person.csv", "PERSON.CSV", "Person.csv"])


def find_drugs_file(base: Path, year: int) -> Path | None:
    national = base / str(year) / "National"
    return find_file(national, [f"DRUGS_{year}.CSV", "drugs.csv", "DRUGS.CSV", "Drugs.csv"])


def drugtest_edges_for(year: int, code: int | None) -> list[dict[str, int | None]]:
    if code is None:
        return []

    if 1991 <= year <= 2017:
        if code in {0, 1, 2}:
            return [{"drugtest_id": code, "AND_FLAG": None, "OR_FLAG": None}]
        if code == 3:
            return [
                {"drugtest_id": 1, "AND_FLAG": 1, "OR_FLAG": None},
                {"drugtest_id": 2, "AND_FLAG": 1, "OR_FLAG": None},
            ]
        if code == 6:
            return [{"drugtest_id": 96, "AND_FLAG": None, "OR_FLAG": None}]
        if code == 7:
            return [{"drugtest_id": 97, "AND_FLAG": None, "OR_FLAG": None}]
        if code == 8:
            return [{"drugtest_id": 98, "AND_FLAG": None, "OR_FLAG": None}]
        if code == 9:
            if 1993 <= year <= 2009:
                return [
                    {"drugtest_id": 96, "AND_FLAG": None, "OR_FLAG": 1},
                    {"drugtest_id": 99, "AND_FLAG": None, "OR_FLAG": 1},
                ]
            return [{"drugtest_id": 99, "AND_FLAG": None, "OR_FLAG": None}]
        return []

    if 2018 <= year <= 2022 and code in DRUGTEST_NODES:
        return [{"drugtest_id": code, "AND_FLAG": None, "OR_FLAG": None}]

    return []


def edge_row(
    year: int,
    person_id: str,
    source_table: str,
    source_slot: str,
    source_drugtest_code: int | None,
    mapped: dict[str, int | None],
) -> dict[str, Any]:
    return {
        "drug_event_id": f"{person_id}_DRUG_{source_slot}",
        "drugtest_id": mapped["drugtest_id"],
        "DATA_YEAR": year,
        "source_table": source_table,
        "source_slot": source_slot,
        "source_drugtest_code": source_drugtest_code,
        "AND_FLAG": mapped["AND_FLAG"],
        "OR_FLAG": mapped["OR_FLAG"],
    }


def iter_person_edges(base: Path, year: int) -> Iterator[dict[str, Any]]:
    path = find_person_file(base, year)
    if path is None:
        print(f"  WARNING {year}: no PERSON file found")
        return

    df = read_csv(path)
    if year <= 1992:
        pairs = [("1", "DRUGTEST", "DRUG_RES")]
    else:
        pairs = [
            ("1", "DRUGTST1", "DRUGRES1"),
            ("2", "DRUGTST2", "DRUGRES2"),
            ("3", "DRUGTST3", "DRUGRES3"),
        ]

    required = {"ST_CASE", "VEH_NO", "PER_NO"}
    missing = sorted(required - set(df.columns))
    if missing:
        print(f"  WARNING {year}: {path.name} missing {missing}; skipping")
        return

    for rec in df.itertuples(index=False):
        values = rec._asdict()
        person_id = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
        for slot, test_col, res_col in pairs:
            if test_col not in df.columns or res_col not in df.columns:
                continue
            drugtest_code = int_code(values[test_col])
            drugres_code = int_code(values[res_col])
            if not has_drug_event(drugtest_code, drugres_code):
                continue
            for mapped in drugtest_edges_for(year, drugtest_code):
                yield edge_row(year, person_id, "PERSON", slot, drugtest_code, mapped)


def iter_drugs_edges(base: Path, year: int) -> Iterator[dict[str, Any]]:
    path = find_drugs_file(base, year)
    if path is None:
        print(f"  WARNING {year}: no drugs.csv file found")
        return

    df = read_csv(path)
    required = {"ST_CASE", "VEH_NO", "PER_NO", "DRUGSPEC", "DRUGRES"}
    missing = sorted(required - set(df.columns))
    if missing:
        print(f"  WARNING {year}: {path.name} missing {missing}; skipping")
        return

    person_sequence: Counter[str] = Counter()
    for rec in df.itertuples(index=False):
        values = rec._asdict()
        drugtest_code = int_code(values["DRUGSPEC"])
        drugres_code = int_code(values["DRUGRES"])
        if not has_drug_event(drugtest_code, drugres_code):
            continue
        person_id = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
        person_sequence[person_id] += 1
        slot = str(person_sequence[person_id])
        for mapped in drugtest_edges_for(year, drugtest_code):
            yield edge_row(year, person_id, "DRUGS", slot, drugtest_code, mapped)


def iter_all_edges(base: Path) -> Iterator[dict[str, Any]]:
    for year in range(1991, 2018):
        yield from iter_person_edges(base, year)
    for year in range(2018, 2023):
        yield from iter_drugs_edges(base, year)


def dry_run(base: Path) -> None:
    total = 0
    counts_by_year: Counter[int] = Counter()
    counts_by_drugtest: Counter[int] = Counter()
    flags: Counter[str] = Counter()
    start = time.time()

    for row in iter_all_edges(base):
        total += 1
        counts_by_year[row["DATA_YEAR"]] += 1
        counts_by_drugtest[row["drugtest_id"]] += 1
        if row["AND_FLAG"] == 1:
            flags["AND_FLAG"] += 1
        if row["OR_FLAG"] == 1:
            flags["OR_FLAG"] += 1

    print(f"Dry run DRUG_TEST_OF edges prepared: {total}")
    print(f"Counts by year: {dict(sorted(counts_by_year.items()))}")
    print(f"Counts by DrugTest id: {dict(sorted(counts_by_drugtest.items()))}")
    print(f"Flagged edge counts: {dict(sorted(flags.items()))}")
    print(f"Dry-run elapsed seconds: {time.time() - start:.3f}")


def ensure_constraints_and_nodes(session: Any) -> None:
    session.run(
        "CREATE CONSTRAINT drugtest_id_unique IF NOT EXISTS "
        "FOR (t:DrugTest) REQUIRE t.id IS UNIQUE"
    ).consume()
    session.run(
        "CREATE CONSTRAINT drugevent_id_unique IF NOT EXISTS "
        "FOR (d:DrugEvent) REQUIRE d.drug_event_id IS UNIQUE"
    ).consume()
    result = session.run(MERGE_DRUGTEST_NODES_QUERY, rows=DRUGTEST_NODE_ROWS).single()
    print(f"Ensured DrugTest nodes: {result['matched'] if result else 0}")


def import_edges(session: Any, base: Path, batch_size: int) -> int:
    total = 0
    batch: list[dict[str, Any]] = []
    start = time.time()

    for row in iter_all_edges(base):
        batch.append(row)
        if len(batch) >= batch_size:
            result = session.run(MERGE_EDGE_QUERY, rows=batch).single()
            total += result["matched"] if result else 0
            print(f"  Imported batch: total={total}")
            batch = []

    if batch:
        result = session.run(MERGE_EDGE_QUERY, rows=batch).single()
        total += result["matched"] if result else 0
        print(f"  Imported final batch: total={total}")

    print(f"Import loop elapsed seconds: {time.time() - start:.3f}")
    return total


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started at {utc_now()}")
    print(f"Base: {args.base}")
    print(f"Database: {args.database}")

    if args.dry_run:
        dry_run(args.base)
        print(f"Finished at {utc_now()}")
        print(f"Total elapsed seconds: {time.time() - total_start:.3f}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints_and_nodes(session)
            if args.ensure_constraints_only:
                print("Constraints/nodes ensured; no DRUG_TEST_OF edges imported.")
                return
            imported = import_edges(session, args.base, args.batch_size)
            print(f"Total DRUG_TEST_OF edges matched/created: {imported}")
    finally:
        driver.close()

    total_elapsed = time.time() - total_start
    print(f"Finished at {utc_now()}")
    print(f"Total elapsed: {format_duration(total_elapsed)}")
    print(f"Total elapsed seconds: {total_elapsed:.3f}")


if __name__ == "__main__":
    main()

Other Factor to Vehicle Relationship (36.767s)

Average edge import time per year: 1.230s across 1975-1981.

Average edge import time per year: 0.715s across 1982-2009.

Average edge import time per year: 0.601s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS Factor-to-Vehicle relationships in Neo4j.

Sources:
  * 1975-2009: vehicle-family VEH_CF1/VEH_CF2 columns from vehicle.csv and
      vehnit.csv where present
  * 2010-2019: factor.csv MFACTOR
  * 2020-2022: factor.csv VEHICLECC

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:Factor)-[:FACTOR_OF]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

FACTOR_NODES: dict[int, str] = {
    1: "Tires",
    2: "Brake System",
    3: "Steering",
    4: "Suspension",
    5: "Power Train",
    6: "Exhaust System",
    7: "Head Lights",
    8: "Signal Lights",
    9: "Other Lights",
    10: "Wipers",
    11: "Wheels",
    12: "Mirrors",
    13: "Windows/Windshield",
    14: "Body, Doors",
    15: "Truck Coupling/Trailer Hitch/Safety Chains",
    16: "Safety Systems",
    17: "Vehicle Contributing Factors - No Details",
    97: "Other",
    98: "Not Reported",
    99: "Unknown",
    1001: "Horn",
    1002: "Driver Seating and Control",
    1003: "Air Bags",
    1004: "Safety Belts",
}

FACTOR_NODE_ROWS = [{"id": factor_id, "name": name} for factor_id, name in FACTOR_NODES.items()]
VALID_FACTOR_IDS = set(FACTOR_NODES)

MERGE_FACTOR_NODES_QUERY = """
UNWIND $rows AS row
MERGE (f:Factor {id: row.id})
SET f.name = row.name
RETURN count(f) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (veh:Vehicle {vehicle_id: row.vehicle_id})
MATCH (factor:Factor {id: row.factor_id})
MERGE (factor)-[r:FACTOR_OF]->(veh)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "factor_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Factor-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Factor constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def legacy_factor_ids(year: int, code: int) -> list[int]:
    if code == 0:
        return []
    if 1975 <= year <= 1981:
        mapping = {
            1: [1, 11],
            2: [2],
            3: [3],
            4: [4],
            5: [5],
            6: [6],
            7: [7],
            8: [8],
            9: [9],
            10: [1001],
            11: [12],
            12: [10],
            13: [1002],
            14: [14],
            15: [15],
            99: [99],
        }
        return mapping.get(code, [])

    if 1982 <= year <= 2009:
        mapping = {
            1: [1],
            2: [2],
            3: [3],
            4: [4],
            5: [5],
            6: [6],
            7: [7],
            8: [8],
            9: [9],
            10: [1001],
            11: [12],
            12: [10],
            13: [1002],
            14: [14],
            15: [15],
            16: [11],
            18: [97],
            98: [98],
            99: [99],
        }
        if 1995 <= year <= 2009 and code == 17:
            return [1003]
        if 2002 <= year <= 2009 and code == 19:
            return [1004]
        return mapping.get(code, [])

    return []


def direct_factor_ids(code: int) -> list[int]:
    if code == 0:
        return []
    if code in VALID_FACTOR_IDS:
        return [code]
    return []


def add_factor_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    factor_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, factor_id)
    row = edge_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "factor_id": factor_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_source_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year in range(1975, 2010):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle))
        vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
        if vehnit is not None:
            files.append((year, "VEHNIT", vehnit))
    return files


def find_factor_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["factor.csv", f"FACTOR_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "MFACTOR" in columns:
            files.append((year, path, "MFACTOR"))
        elif "VEHICLECC" in columns:
            files.append((year, path, "VEHICLECC"))
    return files


def iter_legacy_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "VEH_CF1", "VEH_CF2"}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in ("VEH_CF1", "VEH_CF2"):
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                for factor_id in legacy_factor_ids(year, code):
                    add_factor_edge(edge_rows, vehicle_id, year, factor_id, source_table, source_column, code)
        yield list(edge_rows.values())


def iter_factor_batches(year: int, csv_path: Path, source_column: str, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", source_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            code = int_code(record.get(source_column))
            if code is None:
                continue
            for factor_id in direct_factor_ids(code):
                add_factor_edge(edge_rows, vehicle_id, year, factor_id, "FACTOR", source_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, source_table, csv_path in find_legacy_source_files(base):
        for rows in iter_legacy_batches(year, source_table, csv_path, chunk_size):
            yield year, source_table, rows
    for year, csv_path, source_column in find_factor_files(base):
        for rows in iter_factor_batches(year, csv_path, source_column, chunk_size):
            yield year, "FACTOR", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_factor: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_factor[int(row["factor_id"])] += 1

    print(f"Expected FACTOR_OF relationships : {total_rows:,}")
    print(f"Source edge counts               : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by Factor id                : {dict(sorted(rows_by_factor.items()))}")
    print(f"Dry run seconds                  : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT factor_id IF NOT EXISTS FOR (f:Factor) REQUIRE f.id IS UNIQUE",
        ":Factor(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()
        if not rows:
            continue
        result = session.run(MERGE_EDGE_QUERY, rows=rows)
        matched = result.single()["matched"]
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started UTC: {utc_now()}")
    print(f"Base: {args.base}")

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        print(f"Finished UTC: {utc_now()}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            node_result = session.run(MERGE_FACTOR_NODES_QUERY, rows=FACTOR_NODE_ROWS).single()
            print(f"Ensured Factor nodes: {node_result['matched']}")
            if args.ensure_constraints_only:
                return
            rows_sent, rows_matched = import_relationships(session, args.base, args.batch_size)
            total_elapsed = time.time() - total_start
            print(f"\nTotal FACTOR_OF rows sent: {rows_sent:,}")
            print(f"Total FACTOR_OF rows matched: {rows_matched:,}")
            print(f"Total seconds: {total_elapsed:.3f}")
            print(f"Total duration: {format_duration(total_elapsed)}")
    finally:
        driver.close()
    print(f"Finished UTC: {utc_now()}")


if __name__ == "__main__":
    main()

Maneuver to Vehicle Relationship (78.426s)

Average edge import time per year: 0.588s across 1982-2009.

Average edge import time per year: 4.766s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS Maneuver-to-Vehicle relationships in Neo4j.

Sources:
  * 1982-2009: vehicle-family DR_CF1/DR_CF2/DR_CF3/DR_CF4 legacy columns
      vehicle.csv, and vehnit.csv where present
  * 2010-2019: maneuver.csv MDRMANAV
  * 2020-2022: maneuver.csv MANEUVER

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:Maneuver)-[:MANEUVER_OF]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

MANEUVER_NODES: dict[int, str] = {
    0: "Driver Did Not Maneuver to Avoid",
    1: "Object",
    2: "Poor Road Conditions (Puddle, Ice, Pothole, etc.)",
    3: "Live Animal",
    4: "Contact Motor Vehicle (in This Crash)",
    5: "Pedestrian, Pedalcyclist or Other Non-Motorist",
    92: "Phantom/Non-Contact Motor Vehicle",
    95: "No Driver Present/Unknown if Driver Present",
    98: "Not Reported",
    99: "Reported as Unknown",
}

MANEUVER_NODE_ROWS = [
    {"id": maneuver_id, "name": name}
    for maneuver_id, name in MANEUVER_NODES.items()
]

VALID_MANEUVER_IDS = set(MANEUVER_NODES)

MERGE_MANEUVER_NODES_QUERY = """
UNWIND $rows AS row
MERGE (m:Maneuver {id: row.id})
SET m.name = row.name
RETURN count(m) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (v:Vehicle {vehicle_id: row.vehicle_id})
MATCH (m:Maneuver {id: row.maneuver_id})
MERGE (m)-[r:MANEUVER_OF]->(v)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "maneuver_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Maneuver-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Maneuver constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def map_legacy_driver_factor(code: int) -> int | None:
    return {
        81: 1,   # Debris or objects in road.
        82: 2,   # Ruts, holes, bumps in road.
        83: 3,   # Live animals in road.
        84: 4,   # Vehicle in road.
        85: 92,  # Phantom vehicle.
        86: 5,   # Pedestrian, pedalcyclist, or other non-motorist in road.
    }.get(code)


def add_maneuver_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    maneuver_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, maneuver_id)
    row = edge_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "maneuver_id": maneuver_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_source_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year in range(1982, 2010):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle))
        vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
        if vehnit is not None:
            files.append((year, "VEHNIT", vehnit))
    return files


def find_maneuver_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["maneuver.csv", f"MANEUVER_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "MANEUVER" in columns:
            files.append((year, path, "MANEUVER"))
        elif "MDRMANAV" in columns:
            files.append((year, path, "MDRMANAV"))
    return files


def iter_legacy_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    header = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    columns = {normalize_column(column) for column in header.columns}
    dr_columns = [column for column in ("DR_CF1", "DR_CF2", "DR_CF3", "DR_CF4") if column in columns]
    wanted = {"ST_CASE", "VEH_NO", *dr_columns}

    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in dr_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                maneuver_id = map_legacy_driver_factor(code)
                if maneuver_id is None:
                    continue
                add_maneuver_edge(edge_rows, vehicle_id, year, maneuver_id, source_table, source_column, code)
        yield list(edge_rows.values())


def iter_maneuver_batches(
    year: int,
    csv_path: Path,
    source_column: str,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", source_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            code = int_code(record.get(source_column))
            if code is None or code not in VALID_MANEUVER_IDS:
                continue
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            add_maneuver_edge(edge_rows, vehicle_id, year, code, "MANEUVER", source_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, source_table, csv_path in find_legacy_source_files(base):
        for rows in iter_legacy_batches(year, source_table, csv_path, chunk_size):
            yield year, source_table, rows
    for year, csv_path, source_column in find_maneuver_files(base):
        for rows in iter_maneuver_batches(year, csv_path, source_column, chunk_size):
            yield year, "MANEUVER", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_maneuver: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_maneuver[int(row["maneuver_id"])] += 1

    print(f"Expected MANEUVER_OF relationships : {total_rows:,}")
    print(f"Source edge counts                 : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by Maneuver id                : {dict(sorted(rows_by_maneuver.items()))}")
    print(f"Dry run seconds                    : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT maneuver_id IF NOT EXISTS FOR (m:Maneuver) REQUIRE m.id IS UNIQUE",
        ":Maneuver(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if rows:
            record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
            matched = int(record["matched"]) if record else 0
            total_rows += len(rows)
            total_matched += matched
            year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_MANEUVER_NODES_QUERY, rows=MANEUVER_NODE_ROWS).consume()
            print("Ensured Maneuver category nodes")

            if args.ensure_constraints_only:
                print(f"Maneuver constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI             : {args.uri}")
            print(f"Neo4j database        : {args.database}")
            print(f"Batch size            : {args.batch_size}")
            print(f"Script started UTC    : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Maneuver edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:MANEUVER_OF]->() RETURN count(r) AS count").single()["count"]

        print(f"Maneuver edge load finished UTC : {utc_now()}")
        print(f"Maneuver edge load seconds      : {load_elapsed:.3f}")
        print(f"Total source edge rows          : {total_rows:,}")
        print(f"Total relationships matched     : {total_matched:,}")
        print(f"MANEUVER_OF relationship count  : {rel_count:,}")
        print(f"Total script seconds            : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Non-Motorist Crash Factor to Person Relationship (51.928s)

Average edge import time per year: 1.200s across 1975-2009.

Average edge import time per year: 0.722s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS NMCrash-to-Person relationships in Neo4j.

Sources:
  * 1975-2009: person.csv P_CF1/P_CF2/P_CF3 recoded according to nmcrash.xlsx
  * 2010-2019: nmcrash.csv MTM_CRSH
  * 2020-2022: nmcrash.csv NMCC

The graph join uses person_id = <year>_<ST_CASE>_<VEH_NO>_<PER_NO>. Raw FARS
identifiers are not globally unique across years.

Relationship:
  (:NMCrash)-[:NMCRASH_OF]->(:Person)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

NMCRASH_NODES: dict[int, str] = {
    1: "Dart-out - Visual Obstruction Noted",
    2: "Failure to Yield Right-Of-Way",
    3: "Failure to Obey Traffic Signs, Signals or Officer",
    4: "In Roadway Improperly (Standing, Lying, Working, Playing)",
    5: "Entering/Exiting Parked or Stopped Vehicle",
    6: "Inattentive (Talking, Eating, etc.)",
    7: "Improper Turn/Merge",
    8: "Improper Passing",
    9: "Wrong-Way Riding or Walking",
    10: "Riding on Wrong Side of Road",
    11: "Dash - Run, No Visual Obstruction Noted",
    12: "Improper Crossing of Roadway or Intersection (Jaywalking)",
    13: "Failing to Have Lights on When Required",
    14: "Operating Without Required Equipment",
    15: "Improper or Erratic Lane Changing",
    16: "Failure to Keep in Proper Lane or Running off Road",
    17: "Making Improper Entry to or Exit From Trafficway",
    18: "Operating in Other Erratic, Reckless, Careless or Negligent Manner",
    19: "Not Visible (Dark Clothing, No Lighting, etc.)",
    20: "Passing With Insufficient Distance or Inadequate Visibility or Failing to Yield to Overtaking Vehicle",
    21: "Other",
    92: "Contributing Circumstance -No Details",
    98: "Not Reported",
    99: "Reported as Unknown",
}

NMCRASH_NODE_ROWS = [{"id": code, "name": name} for code, name in NMCRASH_NODES.items()]
VALID_NMCRASH_IDS = set(NMCRASH_NODES)

MERGE_NMCRASH_NODES_QUERY = """
UNWIND $rows AS row
MERGE (n:NMCrash {id: row.id})
SET n.name = row.name
RETURN count(n) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
MATCH (n:NMCrash {id: row.nmcrash_id})
MERGE (n)-[r:NMCRASH_OF]->(p)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "nmcrash_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS NMCrash-to-Person relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure NMCrash constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def legacy_nmcrash_ids(year: int, code: int) -> list[int]:
    if code == 0:
        return []
    if 1975 <= year <= 1981:
        mapping = {
            2: [19],
            3: [1],
            4: [12],
            23: [13],
            27: [15],
            28: [16],
            30: [17],
            35: [20],
            48: [7],
            49: [9],
            50: [10],
            99: [99],
        }
        return mapping.get(code, [])
    if 1982 <= year <= 2009:
        mapping = {
            1: [19],
            3: [12],
            10: [6],
            23: [13],
            27: [15],
            28: [16],
            30: [17],
            35: [20],
            38: [2],
            48: [7],
            49: [9],
            99: [99],
        }
        if year >= 1995 and code == 2:
            return [1]
        if year >= 1995 and code == 50:
            return [10]
        if 1982 <= year <= 1994 and code == 84:
            return [12]
        return mapping.get(code, [])
    return []


def direct_nmcrash_ids(code: int) -> list[int]:
    if code == 0:
        return []
    if code in VALID_NMCRASH_IDS:
        return [code]
    return []


def add_nmcrash_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    person_id: str,
    year: int,
    nmcrash_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (person_id, nmcrash_id)
    row = edge_rows.setdefault(
        key,
        {
            "person_id": person_id,
            "nmcrash_id": nmcrash_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_person_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year in range(1975, 2010):
        national = base / str(year) / "National"
        path = find_file(national, ["person.csv", f"PERSON_{year}.CSV"])
        if path is not None:
            files.append((year, path))
    return files


def find_nmcrash_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["nmcrash.csv", f"NMCRASH_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "NMCC" in columns:
            files.append((year, path, "NMCC"))
        elif "MTM_CRSH" in columns:
            files.append((year, path, "MTM_CRSH"))
    return files


def iter_legacy_batches(year: int, csv_path: Path, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    source_columns = ("P_CF1", "P_CF2", "P_CF3")
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", *source_columns}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            for source_column in source_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                for nmcrash_id in legacy_nmcrash_ids(year, code):
                    add_nmcrash_edge(edge_rows, person_id, year, nmcrash_id, "PERSON", source_column, code)
        yield list(edge_rows.values())


def iter_nmcrash_batches(year: int, csv_path: Path, source_column: str, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", source_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            code = int_code(record.get(source_column))
            if code is None:
                continue
            for nmcrash_id in direct_nmcrash_ids(code):
                add_nmcrash_edge(edge_rows, person_id, year, nmcrash_id, "NMCRASH", source_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, csv_path in find_legacy_person_files(base):
        for rows in iter_legacy_batches(year, csv_path, chunk_size):
            yield year, "PERSON", rows
    for year, csv_path, source_column in find_nmcrash_files(base):
        for rows in iter_nmcrash_batches(year, csv_path, source_column, chunk_size):
            yield year, "NMCRASH", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_nmcrash: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_nmcrash[int(row["nmcrash_id"])] += 1

    print(f"Expected NMCRASH_OF relationships : {total_rows:,}")
    print(f"Source edge counts                : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by NMCrash id                : {dict(sorted(rows_by_nmcrash.items()))}")
    print(f"Dry run seconds                   : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT nmcrash_id IF NOT EXISTS FOR (n:NMCrash) REQUIRE n.id IS UNIQUE",
        ":NMCrash(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT person_person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE",
        ":Person(person_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()
        if not rows:
            continue
        result = session.run(MERGE_EDGE_QUERY, rows=rows)
        matched = result.single()["matched"]
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started UTC: {utc_now()}")
    print(f"Base: {args.base}")

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        print(f"Finished UTC: {utc_now()}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            node_result = session.run(MERGE_NMCRASH_NODES_QUERY, rows=NMCRASH_NODE_ROWS).single()
            print(f"Ensured NMCrash nodes: {node_result['matched']}")
            if args.ensure_constraints_only:
                return
            rows_sent, rows_matched = import_relationships(session, args.base, args.batch_size)
            total_elapsed = time.time() - total_start
            print(f"\nTotal NMCRASH_OF rows sent: {rows_sent:,}")
            print(f"Total NMCRASH_OF rows matched: {rows_matched:,}")
            print(f"Total seconds: {total_elapsed:.3f}")
            print(f"Total duration: {format_duration(total_elapsed)}")
    finally:
        driver.close()
    print(f"Finished UTC: {utc_now()}")


if __name__ == "__main__":
    main()

Non-Motorist Distraction to Person Relationship (4.129s)

Average edge import time per year: 0.936s across 2019-2022.

python
#!/usr/bin/env python3
"""
Create FARS NMDistract-to-Person relationships in Neo4j.

Sources:
  * 2019: nmdistract.csv MNMDSTRD
  * 2020-2022: nmdistract.csv NMDISTRACT

The graph join uses person_id = <year>_<ST_CASE>_<VEH_NO>_<PER_NO>. Raw FARS
identifiers are not globally unique across years.

Relationship:
  (:NMDistract)-[:NMDISTRACT_OF]->(:Person)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

NMDISTRACT_NODES: dict[int, str] = {
    2: "By Other Non-Motorist(s)",
    3: "By a Driver or Occupant of a Motor Vehicle",
    5: "While Talking or Listening to Mobile Phone",
    6: "While Manipulating Mobile Phone",
    7: "Adjusting or Listening to Portable Audio Device (Other Than on a Mobile Phone)",
    8: "Adjusting, Talking to, or Manipulating Other Portable Electronic Device",
    12: "Distracted by Animal, Other Object, Event, or Activity",
    13: "Eating or Drinking",
    14: "Smoking Related",
    15: "Other Mobile Phone Related",
    17: "Distraction/Inattention",
    18: "Distraction/Careless",
    19: "Careless/Inattentive",
    92: "Distraction (Distracted), Details Unknown",
    93: "Inattention (Inattentive), Details Unknown",
    96: "Not Reported",
    97: "Lost in Thought/Daydreaming",
    98: "Other Distraction",
    99: "Reported as Unknown if Distracted",
}

NMDISTRACT_NODE_ROWS = [{"id": code, "name": name} for code, name in NMDISTRACT_NODES.items()]
VALID_NMDISTRACT_IDS = set(NMDISTRACT_NODES)

MERGE_NMDISTRACT_NODES_QUERY = """
UNWIND $rows AS row
MERGE (n:NMDistract {id: row.id})
SET n.name = row.name
RETURN count(n) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
MATCH (n:NMDistract {id: row.nmdistract_id})
MERGE (n)-[r:NMDISTRACT_OF]->(p)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "nmdistract_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS NMDistract-to-Person relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure NMDistract constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def direct_nmdistract_ids(code: int) -> list[int]:
    if code == 0:
        return []
    if code in VALID_NMDISTRACT_IDS:
        return [code]
    return []


def add_nmdistract_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    person_id: str,
    year: int,
    nmdistract_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (person_id, nmdistract_id)
    row = edge_rows.setdefault(
        key,
        {
            "person_id": person_id,
            "nmdistract_id": nmdistract_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_nmdistract_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2019, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["nmdistract.csv", f"NMDISTRACT_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "NMDISTRACT" in columns:
            files.append((year, path, "NMDISTRACT"))
        elif "MNMDSTRD" in columns:
            files.append((year, path, "MNMDSTRD"))
    return files


def iter_nmdistract_batches(year: int, csv_path: Path, source_column: str, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", source_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            code = int_code(record.get(source_column))
            if code is None:
                continue
            for nmdistract_id in direct_nmdistract_ids(code):
                add_nmdistract_edge(edge_rows, person_id, year, nmdistract_id, "NMDISTRACT", source_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, csv_path, source_column in find_nmdistract_files(base):
        for rows in iter_nmdistract_batches(year, csv_path, source_column, chunk_size):
            yield year, "NMDISTRACT", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_nmdistract: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_nmdistract[int(row["nmdistract_id"])] += 1

    print(f"Expected NMDISTRACT_OF relationships : {total_rows:,}")
    print(f"Source edge counts                   : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by NMDistract id                : {dict(sorted(rows_by_nmdistract.items()))}")
    print(f"Dry run seconds                      : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT nmdistract_id IF NOT EXISTS FOR (n:NMDistract) REQUIRE n.id IS UNIQUE",
        ":NMDistract(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT person_person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE",
        ":Person(person_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()
        if not rows:
            continue
        result = session.run(MERGE_EDGE_QUERY, rows=rows)
        matched = result.single()["matched"]
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started UTC: {utc_now()}")
    print(f"Base: {args.base}")

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        print(f"Finished UTC: {utc_now()}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            node_result = session.run(MERGE_NMDISTRACT_NODES_QUERY, rows=NMDISTRACT_NODE_ROWS).single()
            print(f"Ensured NMDistract nodes: {node_result['matched']}")
            if args.ensure_constraints_only:
                return
            rows_sent, rows_matched = import_relationships(session, args.base, args.batch_size)
            total_elapsed = time.time() - total_start
            print(f"\nTotal NMDISTRACT_OF rows sent: {rows_sent:,}")
            print(f"Total NMDISTRACT_OF rows matched: {rows_matched:,}")
            print(f"Total seconds: {total_elapsed:.3f}")
            print(f"Total duration: {format_duration(total_elapsed)}")
    finally:
        driver.close()
    print(f"Finished UTC: {utc_now()}")


if __name__ == "__main__":
    main()

Non-Motorist Impairment to Person Relationship (35.800s)

Average edge import time per year: 0.865s across 1975-1981.

Average edge import time per year: 0.793s across 1982-2009.

Average edge import time per year: 0.783s across 2019-2022.

python
#!/usr/bin/env python3
"""
Create FARS NMImpair-to-Person relationships in Neo4j.

Sources:
  * 1975-1981: person.csv P_CF1/P_CF2/P_CF3 code 1 recoded to NMImpair 10
  * 1982-2009: person.csv P_CF1/P_CF2/P_CF3 selected legacy impairment codes
  * 2010-2022: nmimpair.csv NMIMPAIR

The graph join uses person_id = <year>_<ST_CASE>_<VEH_NO>_<PER_NO>. Raw FARS
identifiers are not globally unique across years.

Relationship:
  (:NMImpair)-[:NMIMPAIR_OF]->(:Person)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

NMIMPAIR_NODES: dict[int, str] = {
    1: "Ill, Blackout",
    2: "Asleep or Fatigued",
    3: "Walking With a Cane or Crutches",
    4: "Paraplegic or in a Wheelchair",
    5: "Impaired Due to Previous Injury",
    6: "Deaf/Hard of Hearing",
    7: "Blind/Low Vision",
    8: "Emotional (Depressed, Angry, Disturbed, etc.)",
    9: "Under the Influence of Alcohol, Drugs, or Medication",
    10: "Physical Impairment - No Details",
    96: "Other Physical Impairment",
    98: "Not Reported",
    99: "Reported as Unknown if Impaired",
}

NMIMPAIR_NODE_ROWS = [{"id": code, "name": name} for code, name in NMIMPAIR_NODES.items()]
VALID_NMIMPAIR_IDS = set(NMIMPAIR_NODES)

MERGE_NMIMPAIR_NODES_QUERY = """
UNWIND $rows AS row
MERGE (n:NMImpair {id: row.id})
SET n.name = row.name
RETURN count(n) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
MATCH (n:NMImpair {id: row.nmimpair_id})
MERGE (n)-[r:NMIMPAIR_OF]->(p)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "nmimpair_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS NMImpair-to-Person relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure NMImpair constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def legacy_nmimpair_ids(year: int, code: int) -> list[int]:
    if code == 0:
        return []
    if 1975 <= year <= 1981:
        return [10] if code == 1 else []
    if 1982 <= year <= 2009:
        mapping = {
            7: [8],
            11: [3],
            12: [4],
            14: [5],
            16: [7],
            17: [96],
        }
        if year >= 1995 and code == 6:
            return [1]
        if year <= 1994 and code == 13:
            return [4]
        return mapping.get(code, [])
    return []


def direct_nmimpair_ids(code: int) -> list[int]:
    if code == 0:
        return []
    if code in VALID_NMIMPAIR_IDS:
        return [code]
    return []


def add_nmimpair_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    person_id: str,
    year: int,
    nmimpair_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (person_id, nmimpair_id)
    row = edge_rows.setdefault(
        key,
        {
            "person_id": person_id,
            "nmimpair_id": nmimpair_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_person_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year in range(1975, 2010):
        national = base / str(year) / "National"
        path = find_file(national, ["person.csv", f"PERSON_{year}.CSV"])
        if path is not None:
            files.append((year, path))
    return files


def find_nmimpair_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["nmimpair.csv", f"NMIMPAIR_{year}.CSV"])
        if path is not None:
            files.append((year, path))
    return files


def iter_legacy_batches(year: int, csv_path: Path, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    source_columns = ("P_CF1", "P_CF2", "P_CF3")
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", *source_columns}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            for source_column in source_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                for nmimpair_id in legacy_nmimpair_ids(year, code):
                    add_nmimpair_edge(edge_rows, person_id, year, nmimpair_id, "PERSON", source_column, code)
        yield list(edge_rows.values())


def iter_nmimpair_batches(year: int, csv_path: Path, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", "NMIMPAIR"}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            code = int_code(record.get("NMIMPAIR"))
            if code is None:
                continue
            for nmimpair_id in direct_nmimpair_ids(code):
                add_nmimpair_edge(edge_rows, person_id, year, nmimpair_id, "NMIMPAIR", "NMIMPAIR", code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, csv_path in find_legacy_person_files(base):
        for rows in iter_legacy_batches(year, csv_path, chunk_size):
            yield year, "PERSON", rows
    for year, csv_path in find_nmimpair_files(base):
        for rows in iter_nmimpair_batches(year, csv_path, chunk_size):
            yield year, "NMIMPAIR", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_nmimpair: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_nmimpair[int(row["nmimpair_id"])] += 1

    print(f"Expected NMIMPAIR_OF relationships : {total_rows:,}")
    print(f"Source edge counts                 : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by NMImpair id                : {dict(sorted(rows_by_nmimpair.items()))}")
    print(f"Dry run seconds                    : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT nmimpair_id IF NOT EXISTS FOR (n:NMImpair) REQUIRE n.id IS UNIQUE",
        ":NMImpair(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT person_person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE",
        ":Person(person_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()
        if not rows:
            continue
        result = session.run(MERGE_EDGE_QUERY, rows=rows)
        matched = result.single()["matched"]
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started UTC: {utc_now()}")
    print(f"Base: {args.base}")

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        print(f"Finished UTC: {utc_now()}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            node_result = session.run(MERGE_NMIMPAIR_NODES_QUERY, rows=NMIMPAIR_NODE_ROWS).single()
            print(f"Ensured NMImpair nodes: {node_result['matched']}")
            if args.ensure_constraints_only:
                return
            rows_sent, rows_matched = import_relationships(session, args.base, args.batch_size)
            total_elapsed = time.time() - total_start
            print(f"\nTotal NMIMPAIR_OF rows sent: {rows_sent:,}")
            print(f"Total NMIMPAIR_OF rows matched: {rows_matched:,}")
            print(f"Total seconds: {total_elapsed:.3f}")
            print(f"Total duration: {format_duration(total_elapsed)}")
    finally:
        driver.close()
    print(f"Finished UTC: {utc_now()}")


if __name__ == "__main__":
    main()

Non-Motorist Prior Action to Person Relationship (23.520s)

Average edge import time per year: 0.694s across 1982-2009.

Average edge import time per year: 1.092s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS NMPrior-to-Person relationships in Neo4j.

Sources:
  * 1982-1994: person.csv P_CF1/P_CF2/P_CF3 code 85 recoded to NMPrior 4
  * 2010-2019: nmprior.csv MPR_ACT
  * 2020-2022: nmprior.csv NMACTION

The graph join uses person_id = <year>_<ST_CASE>_<VEH_NO>_<PER_NO>. Raw FARS
identifiers are not globally unique across years.

Relationship:
  (:NMPrior)-[:NMPRIOR_OF]->(:Person)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

NMPRIOR_NODES: dict[int, str] = {
    1: "Going to or From School [Pre-K-12] (Since 2023)",
    2: "Waiting to Cross Roadway",
    3: "Crossing Roadway",
    4: "Jogging/Running",
    5: "Movement Along Roadway With Traffic (in or Adjacent to Travel Lane)",
    6: "Movement Along Roadway Against Traffic (in or Adjacent to Travel Lane)",
    7: "Movement on Sidewalk",
    8: "In Roadway-Other [Working, Playing, etc.]",
    9: "Adjacent to Roadway (e.g., Shoulder, Median)",
    10: "Working in Trafficway [Incident Response]",
    11: "Entering/Exiting a Vehicle",
    12: "Disabled Vehicle Related (Working on, Pushing, Leaving/Approaching)",
    14: "Other",
    15: "None",
    16: "Movement Along Roadway - Direction Unknown (Since 2012)",
    98: "Not Reported",
    99: "Unknown",
}

NMPRIOR_NODE_ROWS = [{"id": code, "name": name} for code, name in NMPRIOR_NODES.items()]
VALID_NMPRIOR_IDS = set(NMPRIOR_NODES)

MERGE_NMPRIOR_NODES_QUERY = """
UNWIND $rows AS row
MERGE (n:NMPrior {id: row.id})
SET n.name = row.name
RETURN count(n) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
MATCH (n:NMPrior {id: row.nmprior_id})
MERGE (n)-[r:NMPRIOR_OF]->(p)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "nmprior_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS NMPrior-to-Person relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure NMPrior constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def legacy_nmprior_ids(year: int, code: int) -> list[int]:
    if 1982 <= year <= 1994 and code == 85:
        return [4]
    return []


def direct_nmprior_ids(code: int) -> list[int]:
    if code == 0:
        return []
    if code in VALID_NMPRIOR_IDS:
        return [code]
    return []


def add_nmprior_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    person_id: str,
    year: int,
    nmprior_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (person_id, nmprior_id)
    row = edge_rows.setdefault(
        key,
        {
            "person_id": person_id,
            "nmprior_id": nmprior_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_person_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year in range(1982, 1995):
        national = base / str(year) / "National"
        path = find_file(national, ["person.csv", f"PERSON_{year}.CSV"])
        if path is not None:
            files.append((year, path))
    return files


def find_nmprior_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["nmprior.csv", f"NMPRIOR_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "NMACTION" in columns:
            files.append((year, path, "NMACTION"))
        elif "MPR_ACT" in columns:
            files.append((year, path, "MPR_ACT"))
    return files


def iter_legacy_batches(year: int, csv_path: Path, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    source_columns = ("P_CF1", "P_CF2", "P_CF3")
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", *source_columns}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            for source_column in source_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                for nmprior_id in legacy_nmprior_ids(year, code):
                    add_nmprior_edge(edge_rows, person_id, year, nmprior_id, "PERSON", source_column, code)
        yield list(edge_rows.values())


def iter_nmprior_batches(year: int, csv_path: Path, source_column: str, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", source_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            code = int_code(record.get(source_column))
            if code is None:
                continue
            for nmprior_id in direct_nmprior_ids(code):
                add_nmprior_edge(edge_rows, person_id, year, nmprior_id, "NMPRIOR", source_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, csv_path in find_legacy_person_files(base):
        for rows in iter_legacy_batches(year, csv_path, chunk_size):
            yield year, "PERSON", rows
    for year, csv_path, source_column in find_nmprior_files(base):
        for rows in iter_nmprior_batches(year, csv_path, source_column, chunk_size):
            yield year, "NMPRIOR", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_nmprior: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_nmprior[int(row["nmprior_id"])] += 1

    print(f"Expected NMPRIOR_OF relationships : {total_rows:,}")
    print(f"Source edge counts                : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by NMPrior id                : {dict(sorted(rows_by_nmprior.items()))}")
    print(f"Dry run seconds                   : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT nmprior_id IF NOT EXISTS FOR (n:NMPrior) REQUIRE n.id IS UNIQUE",
        ":NMPrior(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT person_person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE",
        ":Person(person_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()
        if not rows:
            continue
        result = session.run(MERGE_EDGE_QUERY, rows=rows)
        matched = result.single()["matched"]
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started UTC: {utc_now()}")
    print(f"Base: {args.base}")

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        print(f"Finished UTC: {utc_now()}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            node_result = session.run(MERGE_NMPRIOR_NODES_QUERY, rows=NMPRIOR_NODE_ROWS).single()
            print(f"Ensured NMPrior nodes: {node_result['matched']}")
            if args.ensure_constraints_only:
                return
            rows_sent, rows_matched = import_relationships(session, args.base, args.batch_size)
            total_elapsed = time.time() - total_start
            print(f"\nTotal NMPRIOR_OF rows sent: {rows_sent:,}")
            print(f"Total NMPRIOR_OF rows matched: {rows_matched:,}")
            print(f"Total seconds: {total_elapsed:.3f}")
            print(f"Total duration: {format_duration(total_elapsed)}")
    finally:
        driver.close()
    print(f"Finished UTC: {utc_now()}")


if __name__ == "__main__":
    main()

This import is performed as one total batch rather than by year, so no yearly average time is available.

python
#!/usr/bin/env python3
"""
Create FARS PersonRF-to-Person relationships in Neo4j.

Sources:
  * 1975-2009: person.csv P_CF1/P_CF2/P_CF3
  * 2010-2019: person.csv P_SF1/P_SF2/P_SF3
  * 2020-2022: personrf.csv PERSONRF

The graph join uses person_id = <year>_<ST_CASE>_<VEH_NO>_<PER_NO>. Raw FARS
identifiers are not globally unique across years.

Relationship:
  (:PersonRF)-[:PERSON_RF_OF]->(:Person)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

PERSONRF_NODES: dict[int, str] = {
    4: "Walking/Riding With or Against Traffic, Playing, Working, Sitting, Lying, Standing, etc., in Roadway",
    5: "Interfering With Driver*",
    8: "Person with an Intellectual, Cognitive, or Developmental Disability",
    9: "Construction/Maintenance/Utility Worker",
    10: "Alcohol and/or Drug Test Refused (Since 2017)",
    13: "Motorized Wheelchair Rider**",
    18: "Mother of Dead Fetus/Mother of Infant Born Post Crash",
    19: "Pedestrian",
    20: "Leaving Vehicle Unattended in Roadway",
    21: "Overloading or Improper Loading of Vehicle With Passengers or Cargo",
    22: "Towing or Pushing Vehicle Improperly",
    24: "Operating Without Required Equipment",
    25: "Creating Unlawful Noise or Using Equipment Prohibited by Law",
    26: "Following Improperly",
    29: "Illegal Driving on Road Shoulder, in Ditch, on Sidewalk, on Median",
    31: "Default Code Used for Vehicle Numbering**",
    32: "Opening Vehicle Closure Into Moving Traffic or While Vehicle is in Motion (Since 2001)*",
    33: "Passing Where Prohibited by Posted Signs, Pavement Markings, Hill, or Curve, or School Bus Displaying Warning Not to Pass",
    34: "Passing on Wrong Side",
    36: "Operating the Vehicle in Other Erratic, Reckless, Careless or Negligent Manner",
    37: "Traveling on Prohibited Trafficway",
    39: "Failure to Obey Actual Traffic Signs, Traffic Control Devices or Traffic Officers; Failure to Obey Safety Zone Traffic Laws",
    40: "Passing Through or Around Barrier Positioned to Prohibit or Channel Traffic",
    41: "Failure to Observe Warnings or Instructions on Vehicles Displaying Them",
    42: "Failure to Signal Intentions",
    43: "Giving Wrong Signal",
    44: "Driving Too Fast for Conditions or in Excess of Posted Speed Limit",
    45: "Driving Less Than Posted Maximum",
    46: "Operating at Erratic or Suddenly Changing Speeds",
    47: "Making Right Turn From Left Turn Lane or Making Left Turn From Right Turn Lane",
    51: "Operator Inexperience",
    52: "Unfamiliar With Roadway",
    53: "Non-Motorist Previously Used a Motor Vehicle for Motion**",
    54: "Non-Motorist Attempting to Use a Motor Vehicle for Motion**",
    55: "Non-Motorist Attempting to Use or Previously Used a Motor Vehicle for Motion, Details Not Reported**",
    56: "Non-Driver Flees Scene (Since 2005)",
    57: "Improper Tire Pressure (Since 1995)",
    58: "Locked Wheel (1995-2014)",
    59: "Overcorrecting (Since 1995)*",
    60: "Rain, Snow, Fog, Smoke, Sand, Dust",
    61: "Reflected Glare, Bright Sunlight, Headlights",
    62: "Curve, Hill, or Other Design Features (Including Traffic Signs, Embankment)",
    63: "Building, Billboard, Other Structures",
    64: "Trees, Crops, Vegetation",
    65: "Motor Vehicle (Including Load)",
    66: "Parked Vehicle",
    67: "Splash or Spray or Passing Vehicle",
    68: "Inadequate Lighting System",
    69: "Obstructing Angles on Vehicle",
    70: "Mirrors",
    71: "Mirrors-Other",
    72: "Other Visual Obstruction",
    73: "Severe Crosswind",
    74: "Wind From Passing Truck",
    75: "Slippery or Loose Surface",
    76: "Tire Blow-Out or Flat",
    77: "Debris or Objects in Road",
    78: "Ruts, Holes, Bumps in Road",
    79: "Live Animals in Road",
    80: "Vehicle in Road",
    81: "Phantom Vehicle",
    82: "Pedestrian, Pedalcyclist, or Other Non-Motorist",
    83: "Ice, Snow, Slush, Water, Sand, Dirt, Oil, Wet Leaves on Road (Since 1995)",
    86: "Emergency Services Personnel (Since 2007)",
    87: "Police or Law Enforcement Officer",
    88: "Seat Back Not in Normal Upright Position, Seat Back Reclined*",
    89: "Parked Motor Vehicle With Equipment Extending Into the Travel Lane",
    90: "Non-Motorist Pushing a Vehicle**",
    91: "Portable Electronic Devices",
    92: "Person in Ambulance Treatment Compartment*",
    93: "Non-Motorist Wearing Motorcycle Helmet**",
    94: "Emergency Medical Services Personnel",
    95: "Fire Personnel",
    96: "Tow Operator",
    97: "Transportation (Maintenance Workers, Safety Service Patrol Operators, etc.)",
    100: "Using a Shared Micromobility Device**",
    101: "Obstructed Sidewalk (for this Person)**",
    999: "Reported as Unknown",
    1001: "Stopping in Roadway (Vehicle Not Abandoned)",
    1002: "Underriding a Parked Truck (1982-1996)",
    1003: "Getting off/out of or on/into Moving Transport Vehicle",
}

PERSONRF_NODE_ROWS = [{"id": code, "name": name} for code, name in PERSONRF_NODES.items()]
VALID_PERSONRF_IDS = set(PERSONRF_NODES)

MERGE_PERSONRF_NODES_QUERY = """
UNWIND $rows AS row
MERGE (n:PersonRF {id: row.id})
SET n.name = row.name
RETURN count(n) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
MATCH (n:PersonRF {id: row.personrf_id})
MERGE (n)-[r:PERSON_RF_OF]->(p)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "personrf_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS PersonRF-to-Person relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure PersonRF constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def personrf_ids_for(year: int, code: int) -> list[int]:
    if code == 0:
        return []

    if 1975 <= year <= 1981:
        mapping = {
            5: [4],
            6: [5],
            20: [20],
            21: [21],
            22: [22],
            24: [24],
            25: [25],
            26: [26],
            29: [29],
            33: [33],
            34: [34],
            36: [36],
            40: [40],
            41: [41],
            42: [42],
            43: [43],
            44: [44],
            45: [45],
            46: [46],
            47: [47],
            51: [51],
            52: [52],
            99: [999],
        }
        return mapping.get(code, [])

    if 1982 <= year <= 2009:
        if code == 53:
            return [1001]
        if code == 54:
            return [1002]
        if code == 55:
            return [1003]
        if code == 99:
            return [999]

        valid = {
            4,
            5,
            18,
            19,
            21,
            24,
            26,
            29,
            33,
            34,
            36,
            39,
            40,
            41,
            42,
            44,
            45,
            47,
            51,
            52,
            60,
            61,
            62,
            63,
            64,
            65,
            66,
            67,
            68,
            69,
            70,
            71,
            72,
            73,
            74,
            75,
            76,
            77,
            78,
            79,
            80,
            81,
            82,
            87,
            88,
            90,
            91,
        }
        if 1982 <= year <= 1994:
            valid.add(20)
        if 1982 <= year <= 2003:
            valid.add(22)
        if 1982 <= year <= 2002:
            valid.add(25)
        if 1982 <= year <= 1996:
            valid.update({43, 46})
        if year >= 1995:
            valid.update({8, 9, 37, 57, 58, 59, 83})
        if year >= 2001:
            valid.add(32)
        if year >= 2005:
            valid.add(56)
        if year >= 2007:
            valid.add(86)
        if code in valid and code in VALID_PERSONRF_IDS:
            return [code]
        return []

    if 2010 <= year <= 2019:
        if code == 99:
            return [999]
        if code == 10 and year < 2017:
            return []
        if code == 31 and year < 2018:
            return []
        if code in {53, 54, 55} and year < 2019:
            return []
        if code == 58 and year > 2014:
            return []
        if code == 86 and year > 2018:
            return []
        if code == 93 and year < 2016:
            return []
        if code in {94, 95, 96, 97} and year < 2019:
            return []

        valid = {
            5,
            8,
            9,
            10,
            13,
            18,
            21,
            26,
            29,
            31,
            32,
            33,
            37,
            40,
            41,
            42,
            44,
            45,
            47,
            51,
            52,
            53,
            54,
            55,
            56,
            57,
            58,
            59,
            60,
            61,
            62,
            63,
            64,
            65,
            66,
            67,
            68,
            69,
            70,
            72,
            73,
            74,
            75,
            76,
            77,
            78,
            80,
            81,
            82,
            83,
            86,
            87,
            88,
            89,
            90,
            91,
            92,
            93,
            94,
            95,
            96,
            97,
        }
        if code in valid and code in VALID_PERSONRF_IDS:
            return [code]
        return []

    if 2020 <= year <= 2022:
        if code in VALID_PERSONRF_IDS:
            return [code]
        return []

    return []


def add_personrf_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    person_id: str,
    year: int,
    personrf_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (person_id, personrf_id)
    row = edge_rows.setdefault(
        key,
        {
            "person_id": person_id,
            "personrf_id": personrf_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_person_files(base: Path) -> list[tuple[int, Path, str, list[str]]]:
    files: list[tuple[int, Path, str, list[str]]] = []
    for year in range(1975, 2023):
        national = base / str(year) / "National"
        if year <= 2019:
            path = find_file(national, [f"PERSON_{year}.CSV", "person.csv", "PERSON.CSV", "Person.csv"])
            if path is not None:
                source_columns = ["P_CF1", "P_CF2", "P_CF3"] if year <= 2009 else ["P_SF1", "P_SF2", "P_SF3"]
                files.append((year, path, "PERSON", source_columns))
        else:
            path = find_file(
                national,
                [
                    f"PERSONRF_{year}.CSV",
                    "personrf.csv",
                    "PERSONRF.CSV",
                    "PersonRF.csv",
                    "Personrf.csv",
                ],
            )
            if path is not None:
                files.append((year, path, "PERSONRF", ["PERSONRF"]))
    return files


def read_csv(path: Path) -> pd.DataFrame:
    for encoding in ("utf-8", "latin-1"):
        try:
            df = pd.read_csv(path, encoding=encoding, low_memory=False)
            df.columns = normalize_columns(df.columns)
            return df
        except UnicodeDecodeError:
            continue
    raise UnicodeDecodeError("utf-8", b"", 0, 1, f"Could not decode {path}")


def collect_edges(base: Path) -> tuple[list[dict[str, Any]], Counter[int], Counter[int]]:
    edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
    raw_counts: Counter[int] = Counter()
    mapped_counts: Counter[int] = Counter()
    files = find_person_files(base)

    for year, path, source_table, source_columns in files:
        start = time.time()
        df = read_csv(path)
        required = {"ST_CASE", "VEH_NO", "PER_NO"}
        missing = sorted(required - set(df.columns))
        if missing:
            print(f"  WARNING {year}: {path.name} missing required columns {missing}; skipping")
            continue

        available_source_columns = [column for column in source_columns if column in df.columns]
        if not available_source_columns:
            print(f"  WARNING {year}: {path.name} has none of {source_columns}; skipping")
            continue

        year_edges_before = len(edge_rows)
        for rec in df[["ST_CASE", "VEH_NO", "PER_NO", *available_source_columns]].itertuples(index=False):
            values = rec._asdict()
            pid = person_id_for(year, values["ST_CASE"], values["VEH_NO"], values["PER_NO"])
            if not pid or pid.endswith("__"):
                continue
            for source_column in available_source_columns:
                code = int_code(values[source_column])
                if code is None:
                    continue
                raw_counts[code] += 1
                for personrf_id in personrf_ids_for(year, code):
                    add_personrf_edge(edge_rows, pid, year, personrf_id, source_table, source_column, code)
                    mapped_counts[personrf_id] += 1

        print(
            f"  Prepared {year}: {len(edge_rows) - year_edges_before} unique edges "
            f"from {path.name} in {format_duration(time.time() - start)}"
        )

    return list(edge_rows.values()), raw_counts, mapped_counts


def ensure_constraints_and_nodes(session: Any) -> None:
    session.run(
        "CREATE CONSTRAINT personrf_id_unique IF NOT EXISTS "
        "FOR (n:PersonRF) REQUIRE n.id IS UNIQUE"
    ).consume()
    session.run(
        "CREATE CONSTRAINT person_person_id_unique IF NOT EXISTS "
        "FOR (p:Person) REQUIRE p.person_id IS UNIQUE"
    ).consume()
    result = session.run(MERGE_PERSONRF_NODES_QUERY, rows=PERSONRF_NODE_ROWS).single()
    print(f"Ensured PersonRF nodes: {result['matched'] if result else 0}")


def import_edges(session: Any, rows: list[dict[str, Any]], batch_size: int) -> int:
    total = 0
    for i in range(0, len(rows), batch_size):
        batch = rows[i : i + batch_size]
        result = session.run(MERGE_EDGE_QUERY, rows=batch).single()
        total += result["matched"] if result else 0
        print(f"    Imported batch {i // batch_size + 1}: {len(batch)} rows")
    return total


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started at {utc_now()}")
    print(f"Base: {args.base}")
    print(f"Database: {args.database}")

    if args.dry_run:
        rows, raw_counts, mapped_counts = collect_edges(args.base)
        print(f"\nDry run only. Unique PERSON_RF_OF edges prepared: {len(rows)}")
        print(f"Mapped PersonRF ids: {dict(sorted(mapped_counts.items()))}")
        unmapped = {code: count for code, count in sorted(raw_counts.items()) if not any(personrf_ids_for(y, code) for y in range(1975, 2023))}
        if unmapped:
            print(f"Raw codes with no PersonRF mapping in any year: {unmapped}")
        print(f"Finished at {utc_now()}")
        print(f"Total elapsed seconds: {time.time() - total_start:.3f}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints_and_nodes(session)
            if args.ensure_constraints_only:
                print("Constraints/nodes ensured; no relationships imported.")
                return

            rows, raw_counts, mapped_counts = collect_edges(args.base)
            print(f"\nPrepared unique PERSON_RF_OF edges: {len(rows)}")
            print(f"Mapped PersonRF ids: {dict(sorted(mapped_counts.items()))}")
            start = time.time()
            imported = import_edges(session, rows, args.batch_size)
            print(f"\nTotal PERSON_RF_OF edges matched/created: {imported}")
            print(f"Import elapsed seconds: {time.time() - start:.3f}")
    finally:
        driver.close()

    total_elapsed = time.time() - total_start
    print(f"Finished at {utc_now()}")
    print(f"Total elapsed: {format_duration(total_elapsed)}")
    print(f"Total elapsed seconds: {total_elapsed:.3f}")


if __name__ == "__main__":
    main()

Race to Person Relationship (97.395s)

Average edge import time per year: 4.058s across 1999-2022.

python
#!/usr/bin/env python3
"""
Create FARS Race-to-Person relationships in Neo4j.

Sources:
  * 1999-2018: person.csv RACE
  * 2019-2022: race.csv RACE

The graph join uses person_id = <year>_<ST_CASE>_<VEH_NO>_<PER_NO>. Raw
ST_CASE/VEH_NO/PER_NO values are not globally unique across years.

Relationship:
  (:Race)-[:RACE_OF]->(:Person)
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

RACE_NODES: dict[int, str] = {
    0: "Not a Fatality (Not Applicable)",
    1: "White",
    2: "Black or African American",
    3: "North American Indian or Alaska Native",
    4: "Chinese",
    5: "Japanese",
    6: "Native Hawaiian",
    7: "Filipino",
    18: "Asian Indian",
    19: "Other Indian",
    28: "Korean",
    38: "Samoan",
    48: "Vietnamese",
    58: "Guamanian or Chamorro",
    68: "Other Asian or Pacific Islander",
    78: "Asian or Pacific Islander, No Specific (Individual) Race",
    96: "Redacted",
    97: "Multiple Races, Unspecified",
    98: "All Other Races",
    99: "Unknown",
}

RACE_NODE_ROWS = [
    {"id": race_id, "name": name}
    for race_id, name in RACE_NODES.items()
]

# Code 0 is a valid source value but means race is not applicable for nonfatal
# persons, so it is kept as a category node but not linked as a race edge.
VALID_RACE_EDGE_IDS = set(RACE_NODES) - {0}

MERGE_RACE_NODES_QUERY = """
UNWIND $rows AS row
MERGE (r:Race {id: row.id})
SET r.name = row.name
RETURN count(r) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (p:Person {person_id: row.person_id})
MATCH (r:Race {id: row.race_id})
MERGE (r)-[rel:RACE_OF]->(p)
SET rel.DATA_YEAR = row.DATA_YEAR,
    rel.source_table = row.source_table,
    rel.source_column = row.source_column,
    rel.source_code = row.source_code,
    rel.multrace = row.multrace,
    rel.created_from = "race_edges"
RETURN count(rel) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Race-to-Person relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Race constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def person_id_for(year: int, st_case: Any, veh_no: Any, per_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}_{identifier_part(per_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def find_source_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year in range(1999, 2019):
        national = base / str(year) / "National"
        path = find_file(national, ["person.csv", f"PERSON_{year}.CSV"])
        if path is not None:
            files.append((year, "PERSON", path))
    for year in range(2019, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["race.csv", f"RACE_{year}.CSV"])
        if path is not None:
            files.append((year, "RACE", path))
    return files


def add_race_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    person_id: str,
    year: int,
    race_id: int,
    source_table: str,
    source_code: int,
    multrace: int | None,
) -> None:
    key = (person_id, race_id)
    edge_rows[key] = {
        "person_id": person_id,
        "race_id": race_id,
        "DATA_YEAR": year,
        "source_table": source_table,
        "source_column": "RACE",
        "source_code": source_code,
        "multrace": multrace,
    }


def iter_source_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    header = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    columns = {normalize_column(column) for column in header.columns}
    wanted = {"ST_CASE", "VEH_NO", "PER_NO", "RACE"}
    if "MULTRACE" in columns:
        wanted.add("MULTRACE")

    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            race_id = int_code(record.get("RACE"))
            if race_id is None or race_id not in VALID_RACE_EDGE_IDS:
                continue
            person_id = person_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"), record.get("PER_NO"))
            if not person_id.rsplit("_", 1)[-1]:
                continue
            multrace = int_code(record.get("MULTRACE")) if "MULTRACE" in record else None
            add_race_edge(edge_rows, person_id, year, race_id, source_table, race_id, multrace)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, source_table, csv_path in find_source_files(base):
        for rows in iter_source_batches(year, source_table, csv_path, chunk_size):
            yield year, source_table, rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_race: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_race[int(row["race_id"])] += 1

    print(f"Expected RACE_OF relationships : {total_rows:,}")
    print(f"Source edge counts             : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by Race id                : {dict(sorted(rows_by_race.items()))}")
    print(f"Dry run seconds                : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT race_id IF NOT EXISTS FOR (r:Race) REQUIRE r.id IS UNIQUE",
        ":Race(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT person_person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.person_id IS UNIQUE",
        ":Person(person_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if rows:
            record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
            matched = int(record["matched"]) if record else 0
            total_rows += len(rows)
            total_matched += matched
            year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_RACE_NODES_QUERY, rows=RACE_NODE_ROWS).consume()
            print("Ensured Race category nodes")

            if args.ensure_constraints_only:
                print(f"Race constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI           : {args.uri}")
            print(f"Neo4j database      : {args.database}")
            print(f"Batch size          : {args.batch_size}")
            print(f"Script started UTC  : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Race edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:RACE_OF]->() RETURN count(r) AS count").single()["count"]

        print(f"Race edge load finished UTC: {utc_now()}")
        print(f"Race edge load seconds     : {load_elapsed:.3f}")
        print(f"Total source edge rows     : {total_rows:,}")
        print(f"Total relationships matched: {total_matched:,}")
        print(f"RACE_OF relationship count : {rel_count:,}")
        print(f"Total script seconds       : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Vehicle Special Factor to Vehicle Relationship (46.613s)

Average edge import time per year: 1.208s across 1975-1981.

Average edge import time per year: 1.032s across 1982-2009.

Average edge import time per year: 0.688s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS VehicleSF-to-Vehicle relationships in Neo4j.

Sources:
  * 1975-2009: vehicle-family VEH_CF1/VEH_CF2 columns from vehicle.csv and
      vehnit.csv where present
  * 2010-2019: vehicle.csv VEH_SC1/VEH_SC2 and parkwork.csv PVEH_SC1/PVEH_SC2
  * 2020-2022: vehiclesf.csv VEHICLESF and pvehiclesf.csv PVEHICLESF

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:VehicleSF)-[:VEHICLE_SF_OF]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

VEHICLESF_NODES: dict[int, str] = {
    1: "Tires (Does Not Include Wheels, See Value 16)",
    2: "Brake System",
    3: "Steering System-Tie Rod, Kingpin, Ball Joint, etc.",
    4: "Suspension-Springs, Shock Absorbers, MacPherson Struts, Axle Bearing, Control Arms, etc.",
    5: "Power Train (Power Train/Engine, 2001-2009)-Universal Joint, Drive Shaft, Transmission, etc.",
    6: "Exhaust System",
    7: "Headlights",
    8: "Signal Lights",
    9: "Other Lights",
    10: "Horn",
    11: "Mirrors",
    12: "Wipers",
    13: "Driver Seating and Control",
    14: "Body, Doors, Hood, Other",
    15: "Trailer Hitch",
    16: "Wheels",
    17: "Air Bags",
    18: "Other Vehicle Defects",
    19: "Safety Belts",
    29: "Default Code Used for Vehicle Numbering",
    30: "Multi-Wheeled Motorcycle Conversion",
    31: "Hit-and-Run Vehicle",
    32: "Vehicle Registration for a Person with a Disability",
    33: "Vehicle Being Pushed by Non-Motorist",
    34: "Vehicle Impact Point-the Result of Something Set in Motion",
    35: "Reconstructed/Altered Vehicle",
    37: "Transporting Children to/From Head Start/Day Care",
    38: "Vehicle Went Airborne During Crash",
    39: "Highway Construction, Maintenance, or Utility Vehicle, In-Transport (Inside or Outside Work Zone)",
    40: "Highway Incident Response Vehicle",
    41: "Police, Fire, or EMS Vehicle Working at the Scene of an Emergency or Performing Other Traffic Control Activities",
    42: "Other Working Vehicle (Not Construction, Maintenance, Utility, Police, Fire, or EMS Vehicle)",
    43: "Hazardous Materials/Cargo Released From This Vehicle",
    44: "Adaptive Equipment",
    45: "Slide-in Camper",
    99: "Unknown",
    999: "Unknown",
}

VEHICLESF_NODE_ROWS = [{"id": code, "name": name} for code, name in VEHICLESF_NODES.items()]
VALID_VEHICLESF_IDS = set(VEHICLESF_NODES)

MERGE_VEHICLESF_NODES_QUERY = """
UNWIND $rows AS row
MERGE (sf:VehicleSF {id: row.id})
SET sf.name = row.name
RETURN count(sf) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (veh:Vehicle {vehicle_id: row.vehicle_id})
MATCH (sf:VehicleSF {id: row.vehiclesf_id})
MERGE (sf)-[r:VEHICLE_SF_OF]->(veh)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "vehiclesf_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS VehicleSF-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure VehicleSF constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def vehiclesf_ids_for(year: int, code: int) -> list[int]:
    if code == 0:
        return []
    if 1975 <= year <= 1981 and code == 1:
        return [1, 16]
    if code in VALID_VEHICLESF_IDS:
        return [code]
    return []


def add_vehiclesf_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    vehiclesf_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, vehiclesf_id)
    row = edge_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "vehiclesf_id": vehiclesf_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_legacy_source_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year in range(1975, 2010):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle))
        vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
        if vehnit is not None:
            files.append((year, "VEHNIT", vehnit))
    return files


def find_vehicle_sc_source_files(base: Path) -> list[tuple[int, str, Path, tuple[str, str]]]:
    files: list[tuple[int, str, Path, tuple[str, str]]] = []
    for year in range(2010, 2020):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle, ("VEH_SC1", "VEH_SC2")))
        parkwork = find_file(national, ["parkwork.csv", f"PARKWORK_{year}.CSV"])
        if parkwork is not None:
            files.append((year, "PARKWORK", parkwork, ("PVEH_SC1", "PVEH_SC2")))
    return files


def find_vehiclesf_source_files(base: Path) -> list[tuple[int, str, Path, tuple[str, ...]]]:
    files: list[tuple[int, str, Path, tuple[str, ...]]] = []
    for year in range(2020, 2023):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehiclesf.csv", f"VEHICLESF_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLESF", vehicle, ("VEHICLESF",)))
        pvehicle = find_file(national, ["pvehiclesf.csv", f"PVEHICLESF_{year}.CSV"])
        if pvehicle is not None:
            files.append((year, "PVEHICLESF", pvehicle, ("PVEHICLESF",)))
    return files


def iter_source_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    source_columns: tuple[str, ...],
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", *source_columns}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in source_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                for vehiclesf_id in vehiclesf_ids_for(year, code):
                    add_vehiclesf_edge(edge_rows, vehicle_id, year, vehiclesf_id, source_table, source_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, source_table, csv_path in find_legacy_source_files(base):
        for rows in iter_source_batches(year, source_table, csv_path, ("VEH_CF1", "VEH_CF2"), chunk_size):
            yield year, source_table, rows
    for year, source_table, csv_path, source_columns in find_vehicle_sc_source_files(base):
        for rows in iter_source_batches(year, source_table, csv_path, source_columns, chunk_size):
            yield year, source_table, rows
    for year, source_table, csv_path, source_columns in find_vehiclesf_source_files(base):
        for rows in iter_source_batches(year, source_table, csv_path, source_columns, chunk_size):
            yield year, source_table, rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_vehiclesf: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_vehiclesf[int(row["vehiclesf_id"])] += 1

    print(f"Expected VEHICLE_SF_OF relationships : {total_rows:,}")
    print(f"Source edge counts                   : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by VehicleSF id                 : {dict(sorted(rows_by_vehiclesf.items()))}")
    print(f"Dry run seconds                      : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT vehiclesf_id IF NOT EXISTS FOR (sf:VehicleSF) REQUIRE sf.id IS UNIQUE",
        ":VehicleSF(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()
        if not rows:
            continue
        result = session.run(MERGE_EDGE_QUERY, rows=rows)
        matched = result.single()["matched"]
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    args = parse_args()
    total_start = time.time()
    print(f"Started UTC: {utc_now()}")
    print(f"Base: {args.base}")

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        print(f"Finished UTC: {utc_now()}")
        return

    from neo4j import GraphDatabase

    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            node_result = session.run(MERGE_VEHICLESF_NODES_QUERY, rows=VEHICLESF_NODE_ROWS).single()
            print(f"Ensured VehicleSF nodes: {node_result['matched']}")
            if args.ensure_constraints_only:
                return
            rows_sent, rows_matched = import_relationships(session, args.base, args.batch_size)
            total_elapsed = time.time() - total_start
            print(f"\nTotal VEHICLE_SF_OF rows sent: {rows_sent:,}")
            print(f"Total VEHICLE_SF_OF rows matched: {rows_matched:,}")
            print(f"Total seconds: {total_elapsed:.3f}")
            print(f"Total duration: {format_duration(total_elapsed)}")
    finally:
        driver.close()
    print(f"Finished UTC: {utc_now()}")


if __name__ == "__main__":
    main()

Violation to Vehicle Relationship (63.488s)

Average edge import time per year: 1.652s across 1975-1981.

Average edge import time per year: 1.317s across 1982-1996.

Average edge import time per year: 1.237s across 1997-2022.

python
#!/usr/bin/env python3
"""
Create FARS Violatn-to-Vehicle relationships in Neo4j.

Sources:
  * 1975-1996: vehicle.csv VIOL_CHG
  * 1997-2009: vehicle.csv and vehnit.csv VIOLCHG1/VIOLCHG2/VIOLCHG3
  * 2010-2019: violatn.csv MVIOLATN
  * 2020-2022: violatn.csv VIOLATION

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:Violatn)-[:COMMITTED_BY]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

VALID_VIOLATN_IDS = {
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 18, 19,
    21, 22, 23, 24, 25, 26, 29,
    31, 32, 33, 34, 35, 36, 37, 38, 39,
    41, 42, 43, 45, 46, 48, 49,
    51, 52, 53, 54, 55, 56, 58, 59,
    61, 62, 63, 66, 67, 69,
    71, 72, 73, 74, 75, 76, 79,
    81, 82, 83, 84, 85, 86, 89,
    91, 92, 93, 95, 97, 98, 99,
    1001,
}

# 1975-1981 legacy values: 0 None, 1 Yes, 2 Pending, 9 Unknown.
MAP_1975_1981: dict[int, list[int]] = {
    1: [1001],
    9: [99],
}

# 1982-1996 legacy values. These are coarser than the 1997+ codes.
MAP_1982_1996: dict[int, list[int]] = {
    1: [19],       # Alcohol or Drugs -> general alcohol/drug/impairment violation.
    2: [29],       # Speeding -> speed-related violations generally.
    3: [19, 29],   # Alcohol or Drugs and Speeding.
    4: [3],        # Reckless Driving -> unsafe/reckless driving.
    5: [71],       # Driving with suspended/revoked license.
    6: [98],       # Other Moving Violation.
    7: [79],       # Non-Moving Violation.
    8: [99],       # Violation type unknown or other violation.
    9: [99],       # Unknown.
}

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (v:Vehicle {vehicle_id: row.vehicle_id})
MATCH (vn:Violatn {id: row.violatn_id})
MERGE (vn)-[r:COMMITTED_BY]->(v)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.created_from = "violatn_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Violatn-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Vehicle/Violatn constraints, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def map_violation_ids(year: int, code: int) -> list[int]:
    if 1975 <= year <= 1981:
        return MAP_1975_1981.get(code, [])
    if 1982 <= year <= 1996:
        return MAP_1982_1996.get(code, [])
    if code == 0:
        return []
    if code in VALID_VIOLATN_IDS and code != 1001:
        return [code]
    return []


def add_edges(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    violatn_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
) -> None:
    key = (vehicle_id, violatn_id)
    row = edge_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "violatn_id": violatn_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def find_vehicle_source_files(base: Path) -> list[tuple[int, str, Path, list[str]]]:
    files: list[tuple[int, str, Path, list[str]]] = []
    for year in range(1975, 2010):
        national = base / str(year) / "National"
        columns = ["VIOL_CHG"] if year <= 1996 else ["VIOLCHG1", "VIOLCHG2", "VIOLCHG3"]
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle, columns))
        if 1997 <= year <= 2009:
            vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
            if vehnit is not None:
                files.append((year, "VEHNIT", vehnit, columns))
    return files


def find_violatn_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["violatn.csv", f"VIOLATN_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "VIOLATION" in columns:
            files.append((year, path, "VIOLATION"))
        elif "MVIOLATN" in columns:
            files.append((year, path, "MVIOLATN"))
    return files


def iter_vehicle_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    violation_columns: list[str],
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", *violation_columns}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in violation_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                for violatn_id in map_violation_ids(year, code):
                    add_edges(edge_rows, vehicle_id, year, violatn_id, source_table, source_column, code)
        yield list(edge_rows.values())


def iter_violatn_batches(
    year: int,
    csv_path: Path,
    violation_column: str,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", violation_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            code = int_code(record.get(violation_column))
            if code is None:
                continue
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for violatn_id in map_violation_ids(year, code):
                add_edges(edge_rows, vehicle_id, year, violatn_id, "VIOLATN", violation_column, code)
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, source_table, csv_path, columns in find_vehicle_source_files(base):
        for rows in iter_vehicle_batches(year, source_table, csv_path, columns, chunk_size):
            yield year, source_table, rows
    for year, csv_path, violation_column in find_violatn_files(base):
        for rows in iter_violatn_batches(year, csv_path, violation_column, chunk_size):
            yield year, "VIOLATN", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_violatn: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_violatn[int(row["violatn_id"])] += 1

    print(f"Expected COMMITTED_BY relationships : {total_rows:,}")
    print(f"Source edge counts                  : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by Violatn id                  : {dict(sorted(rows_by_violatn.items()))}")
    print(f"Dry run seconds                     : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT violatn_id IF NOT EXISTS FOR (vn:Violatn) REQUIRE vn.id IS UNIQUE",
        ":Violatn(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if rows:
            record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
            matched = int(record["matched"]) if record else 0
            total_rows += len(rows)
            total_matched += matched
            year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)

            if args.ensure_constraints_only:
                print(f"Violatn constraints ensured on database {args.database!r}")
                return

            print(f"Neo4j URI              : {args.uri}")
            print(f"Neo4j database         : {args.database}")
            print(f"Batch size             : {args.batch_size}")
            print(f"Script started UTC     : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Violatn edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:COMMITTED_BY]->() RETURN count(r) AS count").single()["count"]

        print(f"Violatn edge load finished UTC: {utc_now()}")
        print(f"Violatn edge load seconds     : {load_elapsed:.3f}")
        print(f"Total source edge rows        : {total_rows:,}")
        print(f"Total relationships matched   : {total_matched:,}")
        print(f"COMMITTED_BY relationship count: {rel_count:,}")
        print(f"Total script seconds          : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Average edge import time per year: 1.246s across 1975-1981.

Average edge import time per year: 0.787s across 1982-2009.

Average edge import time per year: 0.592s across 2010-2022.

python
#!/usr/bin/env python3
"""
Create FARS Vision-to-Vehicle relationships in Neo4j.

Sources:
  * 1975-1981: accident.csv CF1/CF2/CF3, crash-level fallback linked to all
      vehicles in the crash
  * 1982-2008: vehicle-family DR_CF1/DR_CF2/DR_CF3/DR_CF4 legacy columns
      vehicle.csv, and vehnit.csv where present
  * 2009: vehicle-family D_VISION1/D_VISION2/D_VISION3 columns
  * 2010-2019: vision.csv MVISOBSC
  * 2020-2022: vision.csv VISION

The graph join uses vehicle_id = <year>_<ST_CASE>_<VEH_NO>. Raw ST_CASE and
VEH_NO are not globally unique across years.

Relationship:
  (:Vision)-[:VISION_OF]->(:Vehicle)
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

VISION_NODES: dict[int, str] = {
    1: "Rain, Snow, Fog, Smoke, Sand, Dust",
    2: "Reflected Glare, Bright Sunlight, Headlights",
    3: "Curve, Hill, or Other Roadway Design Features",
    4: "Building, Billboard, or Other Structure",
    5: "Trees, Crops, Vegetation",
    6: "In-Transport Motor Vehicle (Including Load)",
    7: "Not In-Transport Motor Vehicle (Parked, Working)",
    8: "Splash or Spray of Passing Vehicle",
    9: "Inadequate Defrost or Defog System",
    10: "Inadequate Vehicle Lighting System",
    11: "Obstructing Interior to the Vehicle",
    12: "External Mirrors",
    13: "Broken or Improperly Cleaned Windshield",
    14: "Obstructing Angles on Vehicle",
    95: "No Driver Present/Unknown if Driver Present",
    97: "Vision Obscured - No Details",
    98: "Other Visual Obstruction",
    99: "Reported as Unknown",
    1001: "Head Restraints",
}

VISION_NODE_ROWS = [
    {"id": vision_id, "name": name}
    for vision_id, name in VISION_NODES.items()
]

VALID_VISION_IDS = set(VISION_NODES)

MERGE_VISION_NODES_QUERY = """
UNWIND $rows AS row
MERGE (v:Vision {id: row.id})
SET v.name = row.name
RETURN count(v) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (veh:Vehicle {vehicle_id: row.vehicle_id})
MATCH (vis:Vision {id: row.vision_id})
MERGE (vis)-[r:VISION_OF]->(veh)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_columns = row.source_columns,
    r.source_codes = row.source_codes,
    r.source_scope = row.source_scope,
    r.created_from = "vision_edges"
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Vision-to-Vehicle relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read sources and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Vision constraints/nodes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def vehicle_id_for(year: int, st_case: Any, veh_no: Any) -> str:
    st_case_key = identifier_part(st_case)
    if not st_case_key.startswith(f"{year}_"):
        st_case_key = f"{year}_{st_case_key}"
    return f"{st_case_key}_{identifier_part(veh_no)}"


def accident_id_for(year: int, st_case: Any) -> str:
    st_case_key = identifier_part(st_case)
    if st_case_key.startswith(f"{year}_"):
        return st_case_key
    return f"{year}_{st_case_key}"


def find_file(national: Path, names: list[str]) -> Path | None:
    for name in names:
        path = national / name
        if path.exists():
            return path
    lower_names = {name.lower() for name in names}
    for path in sorted(national.iterdir()) if national.exists() else []:
        if path.is_file() and path.name.lower() in lower_names:
            return path
    return None


def map_accident_factor(code: int) -> int | None:
    if 1 <= code <= 7:
        return code
    if code == 8:
        return 98
    return None


def map_legacy_driver_factor(year: int, code: int) -> int | None:
    if not (1982 <= year <= 2008):
        return None
    mapping = {
        61: 1,
        62: 2,
        63: 3,
        64: 4,
        65: 5,
        66: 6,
        67: 7,
        68: 8,
        69: 9,
        70: 10,
        71: 14,
        72: 12,
        75: 13,
        76: 98,
    }
    if 1982 <= year <= 2001 and code == 73:
        return 12
    if 1982 <= year <= 2001 and code == 74:
        return 1001
    return mapping.get(code)


def add_vision_edge(
    edge_rows: dict[tuple[str, int], dict[str, Any]],
    vehicle_id: str,
    year: int,
    vision_id: int,
    source_table: str,
    source_column: str,
    source_code: int,
    source_scope: str,
) -> None:
    key = (vehicle_id, vision_id)
    row = edge_rows.setdefault(
        key,
        {
            "vehicle_id": vehicle_id,
            "vision_id": vision_id,
            "DATA_YEAR": year,
            "source_table": source_table,
            "source_columns": [],
            "source_codes": [],
            "source_scope": source_scope,
        },
    )
    if source_column not in row["source_columns"]:
        row["source_columns"].append(source_column)
    if source_code not in row["source_codes"]:
        row["source_codes"].append(source_code)


def vehicle_ids_by_accident(base: Path, year: int) -> dict[str, list[str]]:
    national = base / str(year) / "National"
    vehicle_path = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
    if vehicle_path is None:
        return {}
    df = pd.read_csv(vehicle_path, encoding="latin-1", low_memory=False, usecols=lambda c: normalize_column(c) in {"ST_CASE", "VEH_NO"})
    df.columns = normalize_columns(df.columns)
    mapping: dict[str, list[str]] = {}
    for record in df.to_dict(orient="records"):
        accident_id = accident_id_for(year, record.get("ST_CASE"))
        vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
        if vehicle_id.rsplit("_", 1)[-1]:
            mapping.setdefault(accident_id, []).append(vehicle_id)
    return mapping


def find_accident_source_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year in range(1975, 1982):
        national = base / str(year) / "National"
        path = find_file(national, ["accident.csv", f"ACCIDENT_{year}.CSV"])
        if path is not None:
            files.append((year, path))
    return files


def find_legacy_source_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    for year in range(1982, 2009):
        national = base / str(year) / "National"
        vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
        if vehicle is not None:
            files.append((year, "VEHICLE", vehicle))
        vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
        if vehnit is not None:
            files.append((year, "VEHNIT", vehnit))
    return files


def find_2009_vision_files(base: Path) -> list[tuple[int, str, Path]]:
    files: list[tuple[int, str, Path]] = []
    year = 2009
    national = base / str(year) / "National"
    vehicle = find_file(national, ["vehicle.csv", f"VEHICLE_{year}.CSV"])
    if vehicle is not None:
        files.append((year, "VEHICLE", vehicle))
    vehnit = find_file(national, ["vehnit.csv", f"VEHNIT_{year}.CSV"])
    if vehnit is not None:
        files.append((year, "VEHNIT", vehnit))
    return files


def find_vision_files(base: Path) -> list[tuple[int, Path, str]]:
    files: list[tuple[int, Path, str]] = []
    for year in range(2010, 2023):
        national = base / str(year) / "National"
        path = find_file(national, ["vision.csv", f"VISION_{year}.CSV"])
        if path is None:
            continue
        header = pd.read_csv(path, nrows=0, encoding="latin-1")
        columns = {normalize_column(column) for column in header.columns}
        if "VISION" in columns:
            files.append((year, path, "VISION"))
        elif "MVISOBSC" in columns:
            files.append((year, path, "MVISOBSC"))
    return files


def iter_accident_batches(base: Path, year: int, csv_path: Path, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    accident_vehicles = vehicle_ids_by_accident(base, year)
    wanted = {"ST_CASE", "CF1", "CF2", "CF3"}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_ids = accident_vehicles.get(accident_id_for(year, record.get("ST_CASE")), [])
            if not vehicle_ids:
                continue
            for source_column in ("CF1", "CF2", "CF3"):
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                vision_id = map_accident_factor(code)
                if vision_id is None:
                    continue
                for vehicle_id in vehicle_ids:
                    add_vision_edge(edge_rows, vehicle_id, year, vision_id, "ACCIDENT", source_column, code, "crash")
        yield list(edge_rows.values())


def iter_legacy_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    header = pd.read_csv(csv_path, nrows=0, encoding="latin-1")
    columns = {normalize_column(column) for column in header.columns}
    dr_columns = [column for column in ("DR_CF1", "DR_CF2", "DR_CF3", "DR_CF4") if column in columns]
    wanted = {"ST_CASE", "VEH_NO", *dr_columns}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in dr_columns:
                code = int_code(record.get(source_column))
                if code is None:
                    continue
                vision_id = map_legacy_driver_factor(year, code)
                if vision_id is None:
                    continue
                add_vision_edge(edge_rows, vehicle_id, year, vision_id, source_table, source_column, code, "vehicle")
        yield list(edge_rows.values())


def iter_2009_batches(
    year: int,
    source_table: str,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", "D_VISION1", "D_VISION2", "D_VISION3"}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            for source_column in ("D_VISION1", "D_VISION2", "D_VISION3"):
                code = int_code(record.get(source_column))
                if code is None or code not in VALID_VISION_IDS:
                    continue
                add_vision_edge(edge_rows, vehicle_id, year, code, source_table, source_column, code, "vehicle")
        yield list(edge_rows.values())


def iter_vision_batches(year: int, csv_path: Path, source_column: str, chunk_size: int) -> Iterable[list[dict[str, Any]]]:
    wanted = {"ST_CASE", "VEH_NO", source_column}
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in wanted,
    )
    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        edge_rows: dict[tuple[str, int], dict[str, Any]] = {}
        for record in chunk.to_dict(orient="records"):
            code = int_code(record.get(source_column))
            if code is None or code not in VALID_VISION_IDS:
                continue
            vehicle_id = vehicle_id_for(year, record.get("ST_CASE"), record.get("VEH_NO"))
            if not vehicle_id.rsplit("_", 1)[-1]:
                continue
            add_vision_edge(edge_rows, vehicle_id, year, code, "VISION", source_column, code, "vehicle")
        yield list(edge_rows.values())


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, csv_path in find_accident_source_files(base):
        for rows in iter_accident_batches(base, year, csv_path, chunk_size):
            yield year, "ACCIDENT", rows
    for year, source_table, csv_path in find_legacy_source_files(base):
        for rows in iter_legacy_batches(year, source_table, csv_path, chunk_size):
            yield year, source_table, rows
    for year, source_table, csv_path in find_2009_vision_files(base):
        for rows in iter_2009_batches(year, source_table, csv_path, chunk_size):
            yield year, source_table, rows
    for year, csv_path, source_column in find_vision_files(base):
        for rows in iter_vision_batches(year, csv_path, source_column, chunk_size):
            yield year, "VISION", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_by_vision: Counter[int] = Counter()
    total_rows = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total_rows += len(rows)
        for row in rows:
            rows_by_vision[int(row["vision_id"])] += 1

    print(f"Expected VISION_OF relationships : {total_rows:,}")
    print(f"Source edge counts              : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by Vision id               : {dict(sorted(rows_by_vision.items()))}")
    print(f"Dry run seconds                 : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT vision_id IF NOT EXISTS FOR (v:Vision) REQUIRE v.id IS UNIQUE",
        ":Vision(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT vehicle_vehicle_id IF NOT EXISTS FOR (v:Vehicle) REQUIRE v.vehicle_id IS UNIQUE",
        ":Vehicle(vehicle_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if rows:
            record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
            matched = int(record["matched"]) if record else 0
            total_rows += len(rows)
            total_matched += matched
            year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} edges in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_VISION_NODES_QUERY, rows=VISION_NODE_ROWS).consume()
            print("Ensured Vision category nodes")

            if args.ensure_constraints_only:
                print(f"Vision constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI          : {args.uri}")
            print(f"Neo4j database     : {args.database}")
            print(f"Batch size         : {args.batch_size}")
            print(f"Script started UTC : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Vision edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:VISION_OF]->() RETURN count(r) AS count").single()["count"]

        print(f"Vision edge load finished UTC : {utc_now()}")
        print(f"Vision edge load seconds      : {load_elapsed:.3f}")
        print(f"Total source edge rows        : {total_rows:,}")
        print(f"Total relationships matched   : {total_matched:,}")
        print(f"VISION_OF relationship count  : {rel_count:,}")
        print(f"Total script seconds          : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Weather to Accident Relationship (183.052s)

Average edge import time per year: 3.951s across 1975-2006.

Average edge import time per year: 3.596s across 2007-2019.

Average edge import time per year: 3.293s across 2020-2022.

python
#!/usr/bin/env python3
"""
Create FARS Accident-to-Weather relationships in Neo4j.

This script maps historical FARS weather encodings onto the normalized
:Weather nodes already used in the graph. It uses accident_id = <year>_<ST_CASE>
for all Accident joins; raw ST_CASE is not globally unique across years.

Source logic:
  * 1975-2006: Accident.WEATHER
  * 1975-1981: Accident.CF1/CF2/CF3 code 20 also indicates Severe Crosswinds
  * 2007-2019: Accident.WEATHER1 and Accident.WEATHER2, because Accident.WEATHER
    is a derived hierarchy and can hide a second condition
  * 2020-2022: Weather data file, one row per atmospheric condition

Relationship:
  (:Accident)-[:OCCURRED_IN]->(:Weather)

When one historical code maps ambiguously to multiple modern Weather nodes,
the relationship gets or_flag = 1. Non-ambiguous relationships do not carry
or_flag.
"""

from __future__ import annotations

import argparse
import math
import os
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

import pandas as pd


DEFAULT_BASE = Path("/datasets/prop_datasets/fars")
DEFAULT_URI = "bolt://localhost:7689"
DEFAULT_USER = "neo4j"
DEFAULT_DATABASE = "fars"
DEFAULT_BATCH_SIZE = 10000

WEATHER_NODES: dict[int, str] = {
    1: "Clear",
    2: "Rain (Mist)",
    3: "Sleet (Hail)",
    4: "Snow",
    6: "Severe Crosswinds",
    7: "Blowing Sand, Soil, Dirt",
    8: "Other",
    9: "Cloudy",
    11: "Blowing Snow",
    12: "Freezing Rain or Drizzle",
    98: "Not Reported",
    99: "Unknown",
    1001: "Fog",
    1002: "Smog, Smoke",
}

WEATHER_NODE_ROWS = [
    {"id": weather_id, "name": name}
    for weather_id, name in WEATHER_NODES.items()
]

MERGE_WEATHER_NODES_QUERY = """
UNWIND $rows AS row
MERGE (w:Weather {id: row.id})
SET w.name = row.name
RETURN count(w) AS matched
"""

MERGE_EDGE_QUERY = """
UNWIND $rows AS row
MATCH (a:Accident {accident_id: row.accident_id})
MATCH (w:Weather {id: row.weather_id})
MERGE (a)-[r:OCCURRED_IN]->(w)
SET r.DATA_YEAR = row.DATA_YEAR,
    r.source_table = row.source_table,
    r.source_column = row.source_column,
    r.source_code = row.source_code,
    r.created_from = "weather_edges"
FOREACH (_ IN CASE WHEN row.or_flag = 1 THEN [1] ELSE [] END | SET r.or_flag = 1)
FOREACH (_ IN CASE WHEN row.or_flag = 1 THEN [] ELSE [1] END | REMOVE r.or_flag)
RETURN count(r) AS matched
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Create FARS Accident-to-Weather relationships in Neo4j.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--base", type=Path, default=Path(os.getenv("FARS_BASE", DEFAULT_BASE)))
    parser.add_argument("--uri", default=os.getenv("NEO4J_URI", DEFAULT_URI))
    parser.add_argument("--user", default=os.getenv("NEO4J_USER", DEFAULT_USER))
    parser.add_argument("--password", default=os.getenv("NEO4J_PASSWORD", ""))
    parser.add_argument("--database", default=os.getenv("NEO4J_DATABASE", DEFAULT_DATABASE))
    parser.add_argument("--batch-size", type=int, default=int(os.getenv("BATCH_SIZE", DEFAULT_BATCH_SIZE)))
    parser.add_argument("--dry-run", action="store_true", help="Read source files and print expected counts only.")
    parser.add_argument(
        "--ensure-constraints-only",
        action="store_true",
        help="Only ensure Weather constraints/indexes, then exit without importing relationships.",
    )
    return parser.parse_args()


def format_duration(seconds: float) -> str:
    mins, secs = divmod(int(seconds), 60)
    hrs, mins = divmod(mins, 60)
    if hrs:
        return f"{hrs}h {mins}m {secs}s"
    if mins:
        return f"{mins}m {secs}s"
    return f"{secs}s"


def seconds_text(seconds: float) -> str:
    return f"{seconds:.3f}s"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def normalize_column(name: Any) -> str:
    return str(name).replace("\ufeff", "").replace("", "").strip().upper()


def normalize_columns(columns: Iterable[Any]) -> list[str]:
    normalized: list[str] = []
    seen: dict[str, int] = {}
    for column in columns:
        name = normalize_column(column)
        if name in seen:
            seen[name] += 1
            name = f"{name}_{seen[name]}"
        else:
            seen[name] = 0
        normalized.append(name)
    return normalized


def identifier_part(value: Any) -> str:
    if value is None or pd.isna(value):
        return ""
    if isinstance(value, str):
        text = value.strip()
        if text.endswith(".0") and text[:-2].isdigit():
            return text[:-2]
        return text
    if isinstance(value, float) and math.isfinite(value) and value.is_integer():
        return str(int(value))
    if hasattr(value, "item"):
        return identifier_part(value.item())
    return str(value).strip()


def int_code(value: Any) -> int | None:
    text = identifier_part(value)
    if not text:
        return None
    try:
        return int(text)
    except ValueError:
        return None


def mapping_for(year: int, code: int, source_column: str) -> list[tuple[int, int | None]]:
    if 1975 <= year <= 1979:
        return {
            1: [(1, None)],
            2: [(2, None)],
            3: [(3, None)],
            4: [(4, None)],
            7: [(9, None)],
            9: [(99, None)],
        }.get(code, [])

    if 1980 <= year <= 1981:
        return {
            1: [(1, None)],
            2: [(2, None)],
            3: [(3, None)],
            4: [(4, None)],
            5: [(1001, None)],
            8: [(1002, 1), (7, 1)],
            9: [(99, None)],
        }.get(code, [])

    if 1982 <= year <= 2006:
        return {
            1: [(1, None)],
            2: [(2, None)],
            3: [(3, None)],
            4: [(4, None)],
            5: [(1001, None)],
            6: [(2, None), (1001, None)],
            7: [(3, None), (1001, None)],
            8: [(1002, 1), (7, 1), (8, 1)],
            9: [(99, None)],
        }.get(code, [])

    if 2007 <= year <= 2009:
        if code == 0 and source_column == "WEATHER2":
            return []
        return {
            0: [(1, None)],
            1: [(1, 1), (9, 1)],
            2: [(2, None)],
            3: [(3, None)],
            4: [(4, 1), (11, 1)],
            5: [(1001, 1), (1002, 1)],
            6: [(6, None)],
            7: [(7, None)],
            8: [(8, None)],
            9: [(99, None)],
        }.get(code, [])

    if 2010 <= year <= 2012:
        if code == 0:
            return []
        return {
            1: [(1, None)],
            2: [(2, None)],
            3: [(3, 1), (12, 1)],
            4: [(4, None)],
            5: [(1001, 1), (1002, 1)],
            6: [(6, None)],
            7: [(7, None)],
            8: [(8, None)],
            10: [(9, None)],
            11: [(11, None)],
            98: [(98, None)],
            99: [(99, None)],
        }.get(code, [])

    if 2013 <= year <= 2022:
        if code == 0:
            return []
        return {
            1: [(1, None)],
            2: [(2, None)],
            3: [(3, None)],
            4: [(4, None)],
            5: [(1001, 1), (1002, 1)],
            6: [(6, None)],
            7: [(7, None)],
            8: [(8, None)],
            10: [(9, None)],
            11: [(11, None)],
            12: [(12, None)],
            98: [(98, None)],
            99: [(99, None)],
        }.get(code, [])

    return []


def find_accident_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if 1975 <= year <= 2019:
            csv_path = year_dir / "National" / "accident.csv"
            if csv_path.exists():
                files.append((year, csv_path))
    return files


def find_weather_files(base: Path) -> list[tuple[int, Path]]:
    files: list[tuple[int, Path]] = []
    for year_dir in sorted(base.iterdir()):
        if not year_dir.is_dir() or not year_dir.name.isdigit():
            continue
        year = int(year_dir.name)
        if 2020 <= year <= 2022:
            csv_path = year_dir / "National" / "weather.csv"
            if csv_path.exists():
                files.append((year, csv_path))
    return files


def relationship_rows_from_code(
    year: int,
    st_case: Any,
    source_table: str,
    source_column: str,
    source_code: Any,
) -> list[dict[str, Any]]:
    code = int_code(source_code)
    if code is None:
        return []

    accident_id = f"{year}_{identifier_part(st_case)}"
    rows = []
    for weather_id, or_flag in mapping_for(year, code, source_column):
        rows.append(
            {
                "accident_id": accident_id,
                "weather_id": weather_id,
                "DATA_YEAR": year,
                "source_table": source_table,
                "source_column": source_column,
                "source_code": code,
                "or_flag": or_flag,
            }
        )
    return rows


def iter_accident_weather_rows(
    year: int,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    needed = {"ST_CASE"}
    if year <= 2006:
        needed.add("WEATHER")
    else:
        needed.update({"WEATHER1", "WEATHER2"})
    if 1975 <= year <= 1981:
        needed.update({"CF1", "CF2", "CF3"})

    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in needed,
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        rows: list[dict[str, Any]] = []
        source_columns = ["WEATHER"] if year <= 2006 else ["WEATHER1", "WEATHER2"]
        for record in chunk.to_dict(orient="records"):
            for source_column in source_columns:
                if source_column in record:
                    rows.extend(
                        relationship_rows_from_code(
                            year,
                            record["ST_CASE"],
                            "ACCIDENT",
                            source_column,
                            record.get(source_column),
                        )
                    )

            if 1975 <= year <= 1981:
                for cf_column in ("CF1", "CF2", "CF3"):
                    if int_code(record.get(cf_column)) == 20:
                        rows.append(
                            {
                                "accident_id": f"{year}_{identifier_part(record['ST_CASE'])}",
                                "weather_id": 6,
                                "DATA_YEAR": year,
                                "source_table": "ACCIDENT",
                                "source_column": cf_column,
                                "source_code": 20,
                                "or_flag": 1,
                            }
                        )
                        break

        yield rows


def iter_table_weather_rows(
    year: int,
    csv_path: Path,
    chunk_size: int,
) -> Iterable[list[dict[str, Any]]]:
    chunks = pd.read_csv(
        csv_path,
        encoding="latin-1",
        low_memory=False,
        chunksize=chunk_size,
        usecols=lambda c: normalize_column(c) in {"ST_CASE", "WEATHER"},
    )

    for chunk in chunks:
        chunk.columns = normalize_columns(chunk.columns)
        rows: list[dict[str, Any]] = []
        for record in chunk.to_dict(orient="records"):
            rows.extend(
                relationship_rows_from_code(
                    year,
                    record["ST_CASE"],
                    "WEATHER",
                    "WEATHER",
                    record.get("WEATHER"),
                )
            )
        yield rows


def iter_all_batches(base: Path, chunk_size: int) -> Iterable[tuple[int, str, list[dict[str, Any]]]]:
    for year, csv_path in find_accident_files(base):
        for rows in iter_accident_weather_rows(year, csv_path, chunk_size):
            yield year, "ACCIDENT", rows
    for year, csv_path in find_weather_files(base):
        for rows in iter_table_weather_rows(year, csv_path, chunk_size):
            yield year, "WEATHER", rows


def dry_run(base: Path, chunk_size: int) -> None:
    start = time.time()
    rows_by_year: Counter[int] = Counter()
    rows_by_weather: Counter[int] = Counter()
    rows_by_source: Counter[str] = Counter()
    rows_with_or_flag = 0
    total = 0

    for year, source, rows in iter_all_batches(base, chunk_size):
        rows_by_year[year] += len(rows)
        rows_by_source[source] += len(rows)
        total += len(rows)
        for row in rows:
            rows_by_weather[int(row["weather_id"])] += 1
            if row["or_flag"] == 1:
                rows_with_or_flag += 1

    print(f"Expected OCCURRED_IN relationships: {total:,}")
    print(f"Rows with or_flag=1              : {rows_with_or_flag:,}")
    print(f"Source edge counts               : {dict(sorted(rows_by_source.items()))}")
    print("Rows by year:")
    for year in sorted(rows_by_year):
        print(f"  {year}: {rows_by_year[year]:,}")
    print(f"Rows by weather id               : {dict(sorted(rows_by_weather.items()))}")
    print(f"Dry run seconds                  : {time.time() - start:.3f}")


def run_ddl(session: Any, query: str, label: str) -> None:
    try:
        session.run(query).consume()
        print(f"Ensured {label}")
    except Exception as exc:
        print(f"WARNING: could not ensure {label}: {exc}")


def ensure_constraints(session: Any) -> None:
    run_ddl(
        session,
        "CREATE CONSTRAINT weather_id IF NOT EXISTS FOR (w:Weather) REQUIRE w.id IS UNIQUE",
        ":Weather(id) unique constraint",
    )
    run_ddl(
        session,
        "CREATE CONSTRAINT accident_id IF NOT EXISTS FOR (a:Accident) REQUIRE a.accident_id IS UNIQUE",
        ":Accident(accident_id) unique constraint",
    )


def import_relationships(session: Any, base: Path, batch_size: int) -> tuple[int, int]:
    total_rows = 0
    total_matched = 0
    current_year: int | None = None
    year_rows = 0
    year_start = time.time()

    for year, _, rows in iter_all_batches(base, batch_size):
        if current_year is None:
            current_year = year
        if year != current_year:
            elapsed = time.time() - year_start
            print(f"  Done {current_year}: {year_rows:,} rows in {seconds_text(elapsed)} ({format_duration(elapsed)})")
            current_year = year
            year_rows = 0
            year_start = time.time()

        if not rows:
            continue

        record = session.run(MERGE_EDGE_QUERY, rows=rows).single()
        matched = int(record["matched"]) if record else 0
        total_rows += len(rows)
        total_matched += matched
        year_rows += len(rows)

    if current_year is not None:
        elapsed = time.time() - year_start
        print(f"  Done {current_year}: {year_rows:,} rows in {seconds_text(elapsed)} ({format_duration(elapsed)})")

    return total_rows, total_matched


def main() -> None:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(line_buffering=True)

    args = parse_args()

    if args.dry_run:
        dry_run(args.base, args.batch_size)
        return

    try:
        from neo4j import GraphDatabase
    except ModuleNotFoundError as exc:
        raise SystemExit(
            "Missing Python package 'neo4j'. Install it with: "
            "python -m pip install --user neo4j"
        ) from exc

    total_start = time.time()
    driver = GraphDatabase.driver(args.uri, auth=(args.user, args.password))
    try:
        driver.verify_connectivity()
        with driver.session(database=args.database) as session:
            ensure_constraints(session)
            session.run(MERGE_WEATHER_NODES_QUERY, rows=WEATHER_NODE_ROWS).consume()
            print("Ensured Weather category nodes")

            if args.ensure_constraints_only:
                print(f"Weather constraints/nodes ensured on database {args.database!r}")
                return

            print(f"Neo4j URI             : {args.uri}")
            print(f"Neo4j database        : {args.database}")
            print(f"Batch size            : {args.batch_size}")
            print(f"Script started UTC    : {utc_now()}")
            print("")

            load_start = time.time()
            print(f"Weather edge load started UTC: {utc_now()}")
            total_rows, total_matched = import_relationships(session, args.base, args.batch_size)
            load_elapsed = time.time() - load_start

            rel_count = session.run("MATCH ()-[r:OCCURRED_IN]->() RETURN count(r) AS count").single()["count"]
            or_count = session.run("MATCH ()-[r:OCCURRED_IN]->() WHERE r.or_flag = 1 RETURN count(r) AS count").single()["count"]

        print(f"Weather edge load finished UTC: {utc_now()}")
        print(f"Weather edge load seconds     : {load_elapsed:.3f}")
        print(f"Total source rows processed   : {total_rows:,}")
        print(f"Total relationships matched   : {total_matched:,}")
        print(f"OCCURRED_IN relationship count: {rel_count:,}")
        print(f"OCCURRED_IN or_flag count     : {or_count:,}")
        print(f"Total script seconds          : {time.time() - total_start:.3f}")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

Queries

Select a query below to view its logic and description.

1. Neighborhood
2. Path
3. Pattern
4. Aggregation
5. Analytics
6. Hybrid
7. Others

Q1

TBD

Q2

TBD

Q3

TBD

Q4

TBD

Q5

TBD

Q6

TBD

Q7

TBD

Q8

TBD

Q9

TBD

Q10

TBD

Q11

TBD

Q12

TBD

Q13

TBD

Q14

TBD

Q15

TBD

Q16

TBD

Q17

TBD

Q18

TBD

Q19

TBD

Q20

TBD

Q21

TBD

Q22

TBD

Q23

TBD

Q24

TBD

Q25

TBD

Q26

TBD

Q27

TBD

Q28

TBD

Q29

TBD

Q30

TBD

Q31

TBD

Q32

TBD

Q33

TBD