33# requires-python = ">=3.11"
44# dependencies = ["click", "pandas", "pyarrow"]
55# ///
6- """Supplement AASHTO crashes with NJSP-only fatals (AASHTO ingestion lag).
6+ """Supplement AASHTO crashes with NJSP-only fatals (AASHTO ingestion lag)
7+ and per-crash VTC (victim-type × condition) counts.
78
89AASHTO 2025 has a fatal-classification lag concentrated in specific
9- counties (Middlesex, Hudson , Essex etc.) — fatal status takes time to
10+ counties (Monmouth, Mercer , Essex etc.) — fatal status takes time to
1011propagate (death-cert / 30-day rule), while injury/PDO records arrive
1112on schedule. NJSP carries the up-to-date fatal classifications, so we
1213back-fill the 111 NJSP-only-2025 fatals into the AASHTO output to keep
1314the homepage NJDOT plot honest until AASHTO catches up.
1415
16+ Also computes 25-column VTC matrix (`df`/`ds`/.../`un`) from the AASHTO
17+ person supplements so `agg.py` can emit per-VT aggregations for AASHTO
18+ years, matching the legacy master crashes parquet. NJSP-supplement rows
19+ land in the `uf` bucket (driver/passenger/ped/cyclist breakdown not
20+ available at residual granularity).
21+
1522Removable: when a fresh `Crash.csv` shows AASHTO in sync with NJSP,
1623delete this step from the pipeline.
1724
1825Reads:
1926 - njdot/data/aashto_combined_crashes.parquet (pure AASHTO, schema-mapped)
2027 - njsp/data/njsp_njdot_residuals.parquet (NJSP-side residuals from match_njdot)
28+ - njdot/data/aashto_supplemented_occupants.parquet (for VTC, optional)
29+ - njdot/data/aashto_supplemented_pedestrians.parquet (for VTC, optional)
2130
2231Writes:
2332 - njdot/data/aashto_supplemented_crashes.parquet
3140
3241err = partial (print , file = sys .stderr )
3342
43+ # VTC matrix: 5 victim types × 5 conditions = 25 columns
44+ VICTIM_TYPES = ['d' , 'o' , 'p' , 'b' , 'u' ] # driver, passenger, pedestrian, bicyclist, unknown
45+ CONDITIONS = ['f' , 's' , 'm' , 'p' , 'n' ] # fatal, serious, minor, possible, none
46+ VTC_COLS = [f'{ vt } { c } ' for vt in VICTIM_TYPES for c in CONDITIONS ]
47+ CONDITION_MAP = {1 : 'f' , 2 : 's' , 3 : 'm' , 4 : 'p' , 5 : 'n' , 0 : 'n' }
48+ CRASH_PK = ['year' , 'cc' , 'mc' , 'case' ]
49+
50+
51+ def _pos_to_vt (pos ):
52+ if pd .isna (pos ) or pos == 0 :
53+ return 'u'
54+ return 'd' if pos == 1 else 'o'
55+
56+
57+ def compute_vtc (occupants : pd .DataFrame , pedestrians : pd .DataFrame ) -> pd .DataFrame :
58+ """Compute 25-col VTC matrix aggregated by (year, cc, mc, case).
59+
60+ Inputs are AASHTO-supplemented O/P frames with `condition` (Int 1-5),
61+ `pos` (occupants, 1=driver / 2-12=passenger), `cyclist` (peds).
62+ Returns a DataFrame indexed by CRASH_PK with VTC_COLS columns (int).
63+ """
64+ o = occupants .copy ()
65+ o ['cond' ] = o ['condition' ].map (CONDITION_MAP ).fillna ('n' )
66+ o ['vt' ] = o ['pos' ].apply (_pos_to_vt )
67+ o ['vtc' ] = o ['vt' ] + o ['cond' ]
68+
69+ p = pedestrians .copy ()
70+ p ['cond' ] = p ['condition' ].map (CONDITION_MAP ).fillna ('n' )
71+ p ['vt' ] = p ['cyclist' ].apply (lambda b : 'b' if b else 'p' )
72+ p ['vtc' ] = p ['vt' ] + p ['cond' ]
73+
74+ parts = []
75+ for df in (o , p ):
76+ if not len (df ):
77+ continue
78+ agg = df .groupby (CRASH_PK + ['vtc' ]).size ().unstack (fill_value = 0 )
79+ parts .append (agg )
80+ if not parts :
81+ return pd .DataFrame (columns = CRASH_PK + VTC_COLS ).set_index (CRASH_PK )
82+
83+ combined = parts [0 ]
84+ for extra in parts [1 :]:
85+ combined = combined .add (extra , fill_value = 0 )
86+ for col in VTC_COLS :
87+ if col not in combined .columns :
88+ combined [col ] = 0
89+ combined = combined [VTC_COLS ].fillna (0 ).astype (int )
90+ return combined
91+
3492
3593@click .command ()
3694@click .option ("-a" , "--aashto" , type = click .Path (path_type = Path ),
3795 default = Path ("njdot/data/aashto_combined_crashes.parquet" ))
3896@click .option ("-r" , "--residuals" , type = click .Path (path_type = Path ),
3997 default = Path ("njsp/data/njsp_njdot_residuals.parquet" ))
98+ @click .option ("-O" , "--occupants-supplement" , type = click .Path (path_type = Path ),
99+ default = Path ("njdot/data/aashto_supplemented_occupants.parquet" ))
100+ @click .option ("-P" , "--pedestrians-supplement" , type = click .Path (path_type = Path ),
101+ default = Path ("njdot/data/aashto_supplemented_pedestrians.parquet" ))
40102@click .option ("-o" , "--out" , type = click .Path (path_type = Path ),
41103 default = Path ("njdot/data/aashto_supplemented_crashes.parquet" ))
42- def main (aashto : Path , residuals : Path , out : Path ):
104+ def main (aashto : Path , residuals : Path , occupants_supplement : Path ,
105+ pedestrians_supplement : Path , out : Path ):
43106 a = pd .read_parquet (aashto )
44107 aashto_years = sorted (a ["year" ].dropna ().astype (int ).unique ())
45108 err (f"AASHTO: { len (a ):,} crashes, years { aashto_years [0 ]} –{ aashto_years [- 1 ]} " )
@@ -48,41 +111,64 @@ def main(aashto: Path, residuals: Path, out: Path):
48111 sp_only = res [(res ["side" ] == "njsp" ) & res ["year" ].isin (aashto_years )].copy ()
49112 err (f"NJSP-side residuals in AASHTO years: { len (sp_only )} "
50113 f"({ sp_only ['tk' ].sum ()} deaths)" )
51- if not len (sp_only ):
52- a .to_parquet (out , index = False )
53- err (f"No supplement needed; copied AASHTO → { out } " )
54- return
55-
56- # Build AASHTO-schema rows from NJSP residuals. Only fields that matter
57- # for downstream `agg.py` (year, cc, mc, severity, tk, ti, pk, pi, tv).
58- # `case` carries an `NJSP-{i}` synthetic id for provenance.
59- supp = pd .DataFrame ({
60- "year" : sp_only ["year" ].astype ("int32" ).values ,
61- "cc" : sp_only ["cc" ].astype ("Int8" ).values ,
62- "mc" : sp_only ["mc" ].astype ("Int16" ).values ,
63- "case" : [f"NJSP-supplement-{ i } " for i in range (len (sp_only ))],
64- # Use date as datetime (no time-of-day in residuals)
65- "dt" : pd .to_datetime (sp_only ["date" ]).values ,
66- "severity" : pd .array (["f" ] * len (sp_only ), dtype = "string" ),
67- "tk" : sp_only ["tk" ].astype ("int8" ).values ,
68- "tk_broad" : sp_only ["tk" ].astype ("int8" ).values ,
69- "ti" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
70- "pk" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
71- "pi" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
72- "tv" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
73- "cc0" : sp_only ["cc" ].astype ("Int8" ).values ,
74- "mc0" : sp_only ["mc" ].astype ("Int16" ).values ,
75- })
76- # Add any AASHTO columns not yet set, as nulls.
77- for col in a .columns :
78- if col not in supp .columns :
79- supp [col ] = pd .NA
80- supp = supp [a .columns ]
81-
82- out_df = pd .concat ([a , supp ], ignore_index = True )
114+ if len (sp_only ):
115+ # Build AASHTO-schema rows from NJSP residuals. Only fields that matter
116+ # for downstream `agg.py` (year, cc, mc, severity, tk, ti, pk, pi, tv).
117+ # `case` carries an `NJSP-{i}` synthetic id for provenance.
118+ supp = pd .DataFrame ({
119+ "year" : sp_only ["year" ].astype ("int32" ).values ,
120+ "cc" : sp_only ["cc" ].astype ("Int8" ).values ,
121+ "mc" : sp_only ["mc" ].astype ("Int16" ).values ,
122+ "case" : [f"NJSP-supplement-{ i } " for i in range (len (sp_only ))],
123+ # Use date as datetime (no time-of-day in residuals)
124+ "dt" : pd .to_datetime (sp_only ["date" ]).values ,
125+ "severity" : pd .array (["f" ] * len (sp_only ), dtype = "string" ),
126+ "tk" : sp_only ["tk" ].astype ("int8" ).values ,
127+ "tk_broad" : sp_only ["tk" ].astype ("int8" ).values ,
128+ "ti" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
129+ "pk" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
130+ "pi" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
131+ "tv" : pd .array ([0 ] * len (sp_only ), dtype = "int8" ),
132+ "cc0" : sp_only ["cc" ].astype ("Int8" ).values ,
133+ "mc0" : sp_only ["mc" ].astype ("Int16" ).values ,
134+ })
135+ # Add any AASHTO columns not yet set, as nulls.
136+ for col in a .columns :
137+ if col not in supp .columns :
138+ supp [col ] = pd .NA
139+ supp = supp [a .columns ]
140+ out_df = pd .concat ([a , supp ], ignore_index = True )
141+ n_supplemented = len (supp )
142+ else :
143+ out_df = a .copy ()
144+ n_supplemented = 0
145+
146+ # VTC enrichment from person supplements
147+ if occupants_supplement .exists () and pedestrians_supplement .exists ():
148+ err (f"\n Computing VTC from { occupants_supplement .name } + { pedestrians_supplement .name } …" )
149+ occ = pd .read_parquet (occupants_supplement )
150+ ped = pd .read_parquet (pedestrians_supplement )
151+ err (f" loaded { len (occ ):,} occupants + { len (ped ):,} pedestrians" )
152+ vtc = compute_vtc (occ , ped ).reset_index ()
153+ # Align dtypes for merge (out_df has Int8/Int16 for cc/mc; vtc has Int8/Int16 too via supplements)
154+ out_df = out_df .merge (vtc , on = CRASH_PK , how = 'left' )
155+ for col in VTC_COLS :
156+ out_df [col ] = out_df [col ].fillna (0 ).astype ('int8' )
157+ # NJSP-supplemented rows: each row's tk into `uf` (we don't know VT
158+ # breakdown for residual fatals). cc/mc nullable issues left as-is.
159+ is_njsp_supp = out_df ['case' ].astype (str ).str .startswith ('NJSP-supplement-' )
160+ if is_njsp_supp .any ():
161+ out_df .loc [is_njsp_supp , 'uf' ] = out_df .loc [is_njsp_supp , 'tk' ].astype ('int8' )
162+ err (f" marked { is_njsp_supp .sum ()} NJSP-supplement rows with uf={ out_df .loc [is_njsp_supp , 'uf' ].sum ()} " )
163+ err (f" total VTC fatals (df+of+pf+bf+uf): "
164+ f"{ int (out_df [['df' ,'of' ,'pf' ,'bf' ,'uf' ]].sum ().sum ()):,} " )
165+ else :
166+ err (f" Person supplements not found; VTC columns omitted "
167+ f"({ occupants_supplement .name } , { pedestrians_supplement .name } )" )
168+
83169 out .parent .mkdir (parents = True , exist_ok = True )
84170 out_df .to_parquet (out , index = False )
85- err (f"Wrote { out } ({ len (out_df ):,} crashes; +{ len ( supp ) } from NJSP)" )
171+ err (f"Wrote { out } ({ len (out_df ):,} crashes; +{ n_supplemented } from NJSP)" )
86172
87173 # Quick verification: post-supplement fatals per year
88174 fa = out_df [out_df ["severity" ] == "f" ]
0 commit comments