saulsolis09 commited on
Commit
9b6465c
·
1 Parent(s): bb973ac

Upload 2 files

Browse files
Files changed (1) hide show
  1. app.py +429 -4
app.py CHANGED
@@ -58,6 +58,17 @@ FILTER_DOMINANT_COUNTY = False # process ALL counties (multi-county / full-stat
58
  # The individual per-category files are CSV in both modes.
59
  MASTER_OUTPUT = "excel"
60
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  def _mem_mb():
63
  """Current process memory (RSS) in MB. psutil if available, else peak RSS."""
@@ -690,6 +701,9 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
690
  original_final["Replace with Census Tracts"] = original_final["Cluster_ID"].map(
691
  dict(zip(cen_w["Cluster_ID"], cen_w["Replace with Census Tracts"])))
692
 
 
 
 
693
  # Normalize ZIPs (drop float '.0', restore 5-digit zero-padding).
694
  original_final["Replace with Zip Codes"] = (
695
  original_final["Replace with Zip Codes"].apply(clean_zip))
@@ -944,8 +958,7 @@ def run_from_dataset(input_repo, output_repo):
944
  except Exception as e:
945
  return f"Failed to download '{fn}' from '{input_repo}': {e}", []
946
 
947
- log, outputs = run_pipeline(local, None, STRICT_BLANK_CITIES, FILTER_DOMINANT_COUNTY,
948
- CLUSTER_THRESHOLD, REPLACE_THRESHOLD)
949
 
950
  if output_repo and outputs:
951
  try:
@@ -962,6 +975,419 @@ def run_from_dataset(input_repo, output_repo):
962
  return log, outputs
963
 
964
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
965
  # =====================================================================
966
  # STARTUP HEALTH CHECK
967
  # =====================================================================
@@ -1032,8 +1458,7 @@ with gr.Blocks() as demo:
1032
 
1033
  def _run(files, reference):
1034
  # All settings are fixed (see constants at top). No user-facing options.
1035
- return run_pipeline(files, reference, STRICT_BLANK_CITIES, FILTER_DOMINANT_COUNTY,
1036
- CLUSTER_THRESHOLD, REPLACE_THRESHOLD)
1037
 
1038
  run_btn.click(
1039
  _run,
 
58
  # The individual per-category files are CSV in both modes.
59
  MASTER_OUTPUT = "excel"
60
 
61
+ # How records are processed:
62
+ # "stream" -> ONE COUNTY AT A TIME. The input is split into per-county temp
63
+ # files (chunked read, never holding the whole state), each county
64
+ # is processed and its results appended. Peak RAM ~ the largest
65
+ # single county, so full states run on a small/cheap tier.
66
+ # "memory" -> the whole dataset is loaded and processed at once (faster on
67
+ # small files, but needs enough RAM to hold everything).
68
+ # Results are identical either way. Use "stream" for state-sized files.
69
+ PROCESS_MODE = "stream"
70
+ STREAM_CHUNK_ROWS = 400_000 # rows per chunk while splitting the input by county
71
+
72
 
73
  def _mem_mb():
74
  """Current process memory (RSS) in MB. psutil if available, else peak RSS."""
 
701
  original_final["Replace with Census Tracts"] = original_final["Cluster_ID"].map(
702
  dict(zip(cen_w["Cluster_ID"], cen_w["Replace with Census Tracts"])))
703
 
704
+ # Clean integer Cluster_ID for output (no "2.0"); consistent with streaming.
705
+ original_final["Cluster_ID"] = original_final["Cluster_ID"].astype("Int64")
706
+
707
  # Normalize ZIPs (drop float '.0', restore 5-digit zero-padding).
708
  original_final["Replace with Zip Codes"] = (
709
  original_final["Replace with Zip Codes"].apply(clean_zip))
 
958
  except Exception as e:
959
  return f"Failed to download '{fn}' from '{input_repo}': {e}", []
960
 
961
+ log, outputs = process(local, None)
 
962
 
963
  if output_repo and outputs:
964
  try:
 
975
  return log, outputs
976
 
977
 
978
+ # =====================================================================
979
+ # PER-COUNTY STREAMING (bounded memory for state-sized files)
980
+ # =====================================================================
981
+ _DESIRED_LEADING = [
982
+ "RecordKey", "ComesFromMtg2", "IsFlagged", "Cluster_ID", "LegalSubDivision",
983
+ "County", "Replace with Zip Codes", "Replace with Cities", "Replace with Census Tracts",
984
+ "DDPPropertyZIPFinal", "DDPPropertyCityFinal", "DDPRevisedCensusTract",
985
+ "LotBlockSubdivisionTract", "DDPRevisedLatitude", "DDPRevisedLongitude",
986
+ ]
987
+
988
+
989
+ def process_county(cdf, wl, fips_col, cluster_offset,
990
+ blank_cities_not_in_list, cluster_threshold, replace_threshold):
991
+ """Full per-county transform on ONE county's raw rows. Mirrors run_pipeline
992
+ exactly, scoped to a single county. Returns a dict of that county's outputs
993
+ plus how many clusters it produced (to continue global Cluster_ID numbering).
994
+ Cluster_IDs are offset by `cluster_offset` so, processing counties in sorted
995
+ order, the numbering matches the whole-dataset ("memory") path."""
996
+ ignore = []
997
+ cdf, audit = fips_validate(cdf, blank_cities_not_in_list, ignore, wl=wl, fips_col=fips_col)
998
+
999
+ for col in ["DDPPropertyCityFinal", "DDPPropertyZIPFinal",
1000
+ "DDPRevisedCensusTract", "LegalSubDivision"]:
1001
+ if col in cdf.columns:
1002
+ cdf[col] = cdf[col].replace(["UNDETERMINED CITY", "UNDETERMINED ZIP", "NULL"], np.nan)
1003
+
1004
+ if fips_col != "County":
1005
+ if "County" in cdf.columns:
1006
+ cdf = cdf.drop(columns=["County"])
1007
+ cdf = cdf.rename(columns={fips_col: "County"})
1008
+
1009
+ city_df = cdf.loc[cdf["DDPPropertyCityFinal"].notna()].copy()
1010
+ zip_df = cdf.loc[cdf["DDPPropertyZIPFinal"].notna()].copy()
1011
+ census_tracts_df = cdf.loc[cdf["DDPRevisedCensusTract"].notna()].copy()
1012
+ original = cdf.loc[cdf["LegalSubDivision"].notna()].copy()
1013
+
1014
+ cluster_df = build_clusters(city_df, zip_df, census_tracts_df, cluster_threshold)
1015
+ if len(cluster_df):
1016
+ cluster_df["Cluster_ID"] = cluster_df["Cluster_ID"] + cluster_offset
1017
+ n_clusters = int(cluster_df["Cluster_ID"].nunique()) if len(cluster_df) else 0
1018
+
1019
+ zip_w = build_replace_with(
1020
+ build_cluster_wide_table(zip_df, cluster_df, "DDPPropertyZIPFinal", "ZIP_"),
1021
+ "ZIP_", replace_threshold).rename(columns={"Replace with": "Replace with Zip Codes"})
1022
+ city_w = build_replace_with(
1023
+ build_cluster_wide_table(city_df, cluster_df, "DDPPropertyCityFinal", "City_"),
1024
+ "City_", replace_threshold).rename(columns={"Replace with": "Replace with Cities"})
1025
+ cen_w = build_replace_with(
1026
+ build_cluster_wide_table(census_tracts_df, cluster_df, "DDPRevisedCensusTract", "CensusTract_"),
1027
+ "CensusTract_", replace_threshold).rename(columns={"Replace with": "Replace with Census Tracts"})
1028
+
1029
+ lk = cluster_df[["County", "LegalSubDivision", "Cluster_ID"]].drop_duplicates(
1030
+ ["County", "LegalSubDivision"])
1031
+ cmap = {(c, s): cid for c, s, cid in
1032
+ zip(lk["County"].astype(str).str.strip(),
1033
+ lk["LegalSubDivision"].astype(str).str.strip(), lk["Cluster_ID"])}
1034
+ original_final = original.copy()
1035
+ _oc = original_final["County"].astype(str).str.strip()
1036
+ _os = original_final["LegalSubDivision"].astype(str).str.strip()
1037
+ original_final["Cluster_ID"] = [cmap.get((c, s)) for c, s in zip(_oc, _os)]
1038
+ original_final["Replace with Zip Codes"] = original_final["Cluster_ID"].map(
1039
+ dict(zip(zip_w["Cluster_ID"], zip_w["Replace with Zip Codes"])))
1040
+ original_final["Replace with Cities"] = original_final["Cluster_ID"].map(
1041
+ dict(zip(city_w["Cluster_ID"], city_w["Replace with Cities"])))
1042
+ original_final["Replace with Census Tracts"] = original_final["Cluster_ID"].map(
1043
+ dict(zip(cen_w["Cluster_ID"], cen_w["Replace with Census Tracts"])))
1044
+ original_final["Cluster_ID"] = original_final["Cluster_ID"].astype("Int64")
1045
+ original_final["Replace with Zip Codes"] = original_final["Replace with Zip Codes"].apply(clean_zip)
1046
+
1047
+ existing = original_final.columns.tolist()
1048
+ lead = [c for c in _DESIRED_LEADING if c in existing]
1049
+ other = [c for c in existing if c not in lead]
1050
+ original_final = original_final[lead + other]
1051
+
1052
+ keep = ["RecordKey", "ComesFromMtg2", "IsFlagged",
1053
+ "Replace with Zip Codes", "Replace with Cities", "Replace with Census Tracts",
1054
+ "DDPPropertyZIPFinal", "DDPPropertyCityFinal", "DDPRevisedCensusTract"]
1055
+ keep = [c for c in keep if c in original_final.columns]
1056
+ of = original_final[keep].rename(columns={
1057
+ "DDPPropertyZIPFinal": "DDPPropertyZIPFinalToRemove",
1058
+ "DDPPropertyCityFinal": "DDPPropertyCityFinalToRemove",
1059
+ "DDPRevisedCensusTract": "DDPRevisedCensusTractToRemove",
1060
+ "Replace with Zip Codes": "DDPPropertyZIPFinal",
1061
+ "Replace with Cities": "DDPPropertyCityFinal",
1062
+ "Replace with Census Tracts": "DDPRevisedCensusTract",
1063
+ })
1064
+ of["DDPRevisedCensusTract"] = of["DDPRevisedCensusTract"].apply(clean_census_tract_suffix)
1065
+
1066
+ idcols = [c for c in ["RecordKey", "ComesFromMtg2", "IsFlagged"] if c in of.columns]
1067
+ final_cities = of[idcols + ["DDPPropertyCityFinalToRemove", "DDPPropertyCityFinal"]].copy()
1068
+ final_zips = of[idcols + ["DDPPropertyZIPFinalToRemove", "DDPPropertyZIPFinal"]].copy()
1069
+ final_ct = of[idcols + ["DDPRevisedCensusTractToRemove", "DDPRevisedCensusTract"]].copy()
1070
+ final_cities = final_cities[final_cities["DDPPropertyCityFinalToRemove"].isna()].copy()
1071
+ final_zips = final_zips[final_zips["DDPPropertyZIPFinalToRemove"].isna()].copy()
1072
+ final_ct = final_ct[final_ct["DDPRevisedCensusTractToRemove"].isna()].copy()
1073
+ final_cities = final_cities.drop(columns=["DDPPropertyCityFinalToRemove"]).copy()
1074
+ final_zips = final_zips.drop(columns=["DDPPropertyZIPFinalToRemove"]).copy()
1075
+ final_ct = final_ct.drop(columns=["DDPRevisedCensusTractToRemove"]).copy()
1076
+ final_cities = final_cities[final_cities["DDPPropertyCityFinal"].notna()].copy()
1077
+ final_zips = final_zips[final_zips["DDPPropertyZIPFinal"].notna()].copy()
1078
+ final_ct = final_ct[final_ct["DDPRevisedCensusTract"].notna()].copy()
1079
+ for d in (final_cities, final_zips, final_ct):
1080
+ for bc in ("ComesFromMtg2", "IsFlagged"):
1081
+ if bc in d.columns:
1082
+ d[bc] = boolify(d[bc])
1083
+
1084
+ # ---- summary row for this county (totals from full county frame) ----
1085
+ def _blank(g, col):
1086
+ return g[col].isna() if col in g.columns else pd.Series(False, index=g.index)
1087
+
1088
+ def _fill(g, col):
1089
+ return g[col].notna() if col in g.columns else pd.Series(False, index=g.index)
1090
+
1091
+ label = cdf["County"].dropna().iloc[0] if ("County" in cdf.columns and cdf["County"].notna().any()) else ""
1092
+ raw = str(label)
1093
+ parts = raw.split("-")
1094
+ fips = parts[0].strip()
1095
+ name = parts[1].strip().title() if len(parts) > 1 else raw.strip().title()
1096
+ g = original_final
1097
+ city_blank, zip_blank, tract_blank = (_blank(g, "DDPPropertyCityFinal"),
1098
+ _blank(g, "DDPPropertyZIPFinal"), _blank(g, "DDPRevisedCensusTract"))
1099
+ city_fill, zip_fill, tract_fill = (_fill(g, "Replace with Cities"),
1100
+ _fill(g, "Replace with Zip Codes"), _fill(g, "Replace with Census Tracts"))
1101
+ summary_row = {
1102
+ "County": name, "FIPS": fips,
1103
+ "Total Records in County": len(cdf),
1104
+ "Total Subdivisions": int(cdf["LegalSubDivision"].count()) if "LegalSubDivision" in cdf.columns else 0,
1105
+ "Total Clusters Made": n_clusters,
1106
+ "Total Blank Cities": int(city_blank.sum()),
1107
+ "Total Cities Assignment": int((city_blank & city_fill).sum()),
1108
+ "Total Blank Zips": int(zip_blank.sum()),
1109
+ "Total Zips Assignment": int((zip_blank & zip_fill).sum()),
1110
+ "Total Blank Census": int(tract_blank.sum()),
1111
+ "Total Census Assignment": int((tract_blank & tract_fill).sum()),
1112
+ }
1113
+
1114
+ return {
1115
+ "validated": cdf, "audit": audit, "cluster_df": cluster_df,
1116
+ "output_df": original_final, "final_cities": final_cities,
1117
+ "final_zips": final_zips, "final_ct": final_ct,
1118
+ "summary_row": summary_row, "n_clusters": n_clusters,
1119
+ }
1120
+
1121
+
1122
+ class _RollingCsv:
1123
+ """Append rows to CSV part files, rolling to a new part every row_limit rows."""
1124
+ def __init__(self, out_dir, base, row_limit=1_000_000):
1125
+ self.out_dir, self.base, self.row_limit = out_dir, base, row_limit
1126
+ self.part, self.count, self.header = 0, 0, False
1127
+ self.paths = []
1128
+ self._new()
1129
+
1130
+ def _new(self):
1131
+ self.part += 1
1132
+ self.count, self.header = 0, False
1133
+ self.path = self.out_dir / f"{self.base}_part_{self.part}.csv"
1134
+ self.paths.append(self.path)
1135
+
1136
+ def write(self, dfx):
1137
+ i, n = 0, len(dfx)
1138
+ while i < n:
1139
+ room = self.row_limit - self.count
1140
+ take = dfx.iloc[i:i + room]
1141
+ take.to_csv(self.path, mode="a", header=not self.header, index=False)
1142
+ self.header = True
1143
+ self.count += len(take)
1144
+ i += len(take)
1145
+ if self.count >= self.row_limit and i < n:
1146
+ self._new()
1147
+
1148
+ def finalize(self):
1149
+ # If everything fit in one part, drop the "_part_1" suffix to match memory mode.
1150
+ if self.part == 1 and self.paths[0].exists():
1151
+ final = self.out_dir / f"{self.base}.csv"
1152
+ os.replace(self.paths[0], final)
1153
+ return [str(final)]
1154
+ return [str(p) for p in self.paths if p.exists()]
1155
+
1156
+
1157
+ def _split_by_county(uploaded_files, out_dir, log, chunksize=STREAM_CHUNK_ROWS):
1158
+ """Stream the input(s) in chunks and route rows to one temp CSV per county.
1159
+ Never holds more than one chunk in memory. Returns (fips_col, reason,
1160
+ [(county_label, path), ...] sorted by county)."""
1161
+ cdir = out_dir / "_by_county"
1162
+ cdir.mkdir(parents=True, exist_ok=True)
1163
+
1164
+ # detect the FIPS-county column from a small sample of the first file
1165
+ first = uploaded_files[0].name
1166
+ if str(first).lower().endswith(".csv"):
1167
+ sample = pd.read_csv(first, nrows=50_000, low_memory=False)
1168
+ else:
1169
+ sample = pd.read_excel(first)
1170
+ fips_col, reason = detect_fips_county_column(sample)
1171
+ del sample
1172
+ if fips_col is None:
1173
+ return None, reason, []
1174
+
1175
+ county_paths = {} # county label -> temp path
1176
+ counter = [0]
1177
+
1178
+ def path_for(county):
1179
+ if county not in county_paths:
1180
+ counter[0] += 1
1181
+ county_paths[county] = cdir / f"county_{counter[0]:05d}.csv"
1182
+ return county_paths[county]
1183
+
1184
+ def route(chunk):
1185
+ keys = chunk[fips_col].astype(str).str.strip()
1186
+ for county, idx in chunk.groupby(keys).groups.items():
1187
+ p = path_for(county)
1188
+ chunk.loc[idx].to_csv(p, mode="a", header=not p.exists(), index=False)
1189
+
1190
+ total = 0
1191
+ for f in uploaded_files:
1192
+ nm = f.name
1193
+ if str(nm).lower().endswith(".csv"):
1194
+ for chunk in pd.read_csv(nm, chunksize=chunksize, low_memory=False):
1195
+ route(chunk)
1196
+ total += len(chunk)
1197
+ _step(log, f"Split: routed {total:,} rows into {len(county_paths)} counties")
1198
+ else:
1199
+ chunk = pd.read_excel(nm)
1200
+ route(chunk)
1201
+ total += len(chunk)
1202
+ _step(log, f"Split: routed {total:,} rows into {len(county_paths)} counties")
1203
+
1204
+ ordered = sorted(county_paths.items(), key=lambda kv: str(kv[0]))
1205
+ return fips_col, reason, ordered
1206
+
1207
+
1208
+ def run_streaming(uploaded_files, reference_file, blank_cities_not_in_list,
1209
+ filter_dominant_county, cluster_threshold, replace_threshold):
1210
+ """Per-county streaming pipeline: bounded memory, identical results."""
1211
+ t_start = time.time()
1212
+ log = []
1213
+ if not uploaded_files:
1214
+ return "Please upload at least one file.", []
1215
+ out_dir = Path(tempfile.mkdtemp(prefix="subdiv_"))
1216
+ outputs = []
1217
+
1218
+ try:
1219
+ wl = build_whitelist_from_path(reference_file.name) if reference_file is not None else load_whitelist()
1220
+ _step(log, "Loaded safety list" + (" (uploaded override)" if reference_file is not None else ""))
1221
+ except FileNotFoundError as e:
1222
+ return "\n".join(log) + f"\n\nREFERENCE FILE ERROR:\n{e}", []
1223
+ except Exception as e:
1224
+ return "\n".join(log) + f"\n\nCould not parse reference list: {e}", []
1225
+
1226
+ _step(log, "Splitting input by county (streaming)...")
1227
+ fips_col, reason, counties = _split_by_county(uploaded_files, out_dir, log)
1228
+ if fips_col is None:
1229
+ return ("\n".join(log) + "\n\nCOLUMN ERROR: could not find a FIPS-county column. "
1230
+ "Expected values like '05045-FAULKNER'."), []
1231
+ _step(log, f"FIPS-county column: '{fips_col}' ({reason}) | {len(counties)} counties")
1232
+
1233
+ # combined output writers
1234
+ audit_path = out_dir / "FIPS_Validation_Audit_ChangedRecords.csv"
1235
+ raw_path = out_dir / "RawFile_FIPSValidated.csv"
1236
+ fc_path = out_dir / "final_cities.csv"
1237
+ fz_path = out_dir / "final_zips.csv"
1238
+ fct_path = out_dir / "final_census_tracts.csv"
1239
+ clu_path = out_dir / "cluster_df.csv"
1240
+ out_roll = _RollingCsv(out_dir,
1241
+ f"OutputFile_{replace_threshold}Threshold_{cluster_threshold}ClustersRapidFuzz",
1242
+ row_limit=1_000_000)
1243
+
1244
+ def _append(dfx, path):
1245
+ if len(dfx) == 0 and Path(path).exists():
1246
+ return
1247
+ dfx.to_csv(path, mode="a", header=not Path(path).exists(), index=False)
1248
+
1249
+ summary_rows = []
1250
+ cluster_offset = 0
1251
+ any_audit = False
1252
+ for i, (label, cpath) in enumerate(counties, start=1):
1253
+ cdf = pd.read_csv(cpath, low_memory=False)
1254
+ res = process_county(cdf, wl, fips_col, cluster_offset,
1255
+ blank_cities_not_in_list, cluster_threshold, replace_threshold)
1256
+ cluster_offset += res["n_clusters"]
1257
+
1258
+ _append(res["validated"], raw_path)
1259
+ if len(res["audit"]) > 0:
1260
+ _append(res["audit"], audit_path); any_audit = True
1261
+ _append(res["final_cities"], fc_path)
1262
+ _append(res["final_zips"], fz_path)
1263
+ _append(res["final_ct"], fct_path)
1264
+ if len(res["cluster_df"]):
1265
+ _append(res["cluster_df"], clu_path)
1266
+ out_roll.write(res["output_df"])
1267
+ summary_rows.append(res["summary_row"])
1268
+
1269
+ del cdf, res
1270
+ gc.collect()
1271
+ _step(log, f"County {i}/{len(counties)} done ({label})")
1272
+
1273
+ # finalize combined files
1274
+ for p in [audit_path, raw_path, fc_path, fz_path, fct_path, clu_path]:
1275
+ if p.exists():
1276
+ outputs.append(str(p))
1277
+ output_parts = out_roll.finalize()
1278
+ outputs.extend(output_parts)
1279
+
1280
+ summary_cols = ["County", "FIPS", "Total Records in County", "Total Subdivisions",
1281
+ "Total Clusters Made", "Total Blank Cities", "Total Cities Assignment",
1282
+ "Total Blank Zips", "Total Zips Assignment", "Total Blank Census",
1283
+ "Total Census Assignment"]
1284
+ summary_df = pd.DataFrame(summary_rows, columns=summary_cols)
1285
+ summary_path = out_dir / "Summary.csv"
1286
+ summary_df.to_csv(summary_path, index=False)
1287
+ outputs.append(str(summary_path))
1288
+ _step(log, f"Summary: {len(summary_df)} county row(s)")
1289
+
1290
+ # ---- Master ------------------------------------------------------
1291
+ _step(log, f"Building master ({MASTER_OUTPUT})...")
1292
+ if MASTER_OUTPUT == "csv":
1293
+ master_path = out_dir / "Master_Dataset.zip"
1294
+ member_names = ["FIPS_Validation_Audit_ChangedRecords.csv", "cluster_df.csv",
1295
+ "final_cities.csv", "final_zips.csv", "final_census_tracts.csv",
1296
+ "Summary.csv"]
1297
+ members = [out_dir / n for n in member_names if (out_dir / n).exists()]
1298
+ members += [Path(p) for p in output_parts]
1299
+ with zipfile.ZipFile(master_path, "w", zipfile.ZIP_DEFLATED) as zf:
1300
+ for p in members:
1301
+ zf.write(p, arcname=Path(p).name)
1302
+ else:
1303
+ # Stream the big Output sheet from the combined CSV; small sheets load fully.
1304
+ master_path = out_dir / "Master_Dataset.xlsx"
1305
+ _write_master_streaming(master_path, output_parts, audit_path, clu_path,
1306
+ fc_path, fz_path, fct_path, summary_df)
1307
+ outputs.append(str(master_path))
1308
+
1309
+ elapsed_min = (time.time() - t_start) / 60
1310
+ log.append(f"Done. {len(outputs)} files ready.")
1311
+ _step(log, f"DONE — {len(outputs)} files ready in {elapsed_min:.1f} min")
1312
+ log.append(f"Total processing time: {elapsed_min:.1f} minutes.")
1313
+ gc.collect()
1314
+ return "\n".join(log), outputs
1315
+
1316
+
1317
+ def _write_master_streaming(path, output_parts, audit_path, clu_path,
1318
+ fc_path, fz_path, fct_path, summary_df, row_limit=1_048_575):
1319
+ """Build Master_Dataset.xlsx in low memory: stream the Output sheet from the
1320
+ combined output CSV(s) in chunks; load the (small) side sheets fully."""
1321
+ try:
1322
+ import xlsxwriter
1323
+ except Exception:
1324
+ xlsxwriter = None
1325
+
1326
+ def _read(p):
1327
+ return pd.read_csv(p, low_memory=False) if Path(p).exists() else pd.DataFrame()
1328
+
1329
+ if xlsxwriter is None:
1330
+ # openpyxl fallback (higher memory): load Output fully.
1331
+ frames = []
1332
+ for p in output_parts:
1333
+ frames.append(pd.read_csv(p, low_memory=False))
1334
+ output_df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
1335
+ write_master_workbook(path, [
1336
+ ("Output_70_95_RapidFuzz", output_df),
1337
+ ("ValidationAuditResults", _read(audit_path)),
1338
+ ("cluster_df", _read(clu_path)),
1339
+ ("final_cities", _read(fc_path)),
1340
+ ("final_zips", _read(fz_path)),
1341
+ ("final_census_tracts", _read(fct_path)),
1342
+ ("Summary", summary_df),
1343
+ ], row_limit=row_limit)
1344
+ return
1345
+
1346
+ wb = xlsxwriter.Workbook(str(path), {"constant_memory": True, "nan_inf_to_errors": True})
1347
+
1348
+ def _write_df(name, dfx):
1349
+ ws = wb.add_worksheet(str(name)[:31])
1350
+ ws.write_row(0, 0, [str(c) for c in dfx.columns])
1351
+ clean = dfx.where(pd.notna(dfx), None)
1352
+ for r, rowvals in enumerate(clean.itertuples(index=False, name=None), start=1):
1353
+ ws.write_row(r, 0, rowvals)
1354
+
1355
+ # Output sheet(s): stream from the combined CSV, split at row_limit
1356
+ sheet_idx, row_in_sheet, ws, header = 0, 0, None, None
1357
+ for p in output_parts:
1358
+ for chunk in pd.read_csv(p, chunksize=200_000, low_memory=False):
1359
+ if header is None:
1360
+ header = [str(c) for c in chunk.columns]
1361
+ clean = chunk.where(pd.notna(chunk), None)
1362
+ for rowvals in clean.itertuples(index=False, name=None):
1363
+ if ws is None or row_in_sheet >= row_limit:
1364
+ sheet_idx += 1
1365
+ ws = wb.add_worksheet(f"Output_70_95_RapidFuzz_{sheet_idx}"[:31])
1366
+ ws.write_row(0, 0, header)
1367
+ row_in_sheet = 0
1368
+ row_in_sheet += 1
1369
+ ws.write_row(row_in_sheet, 0, rowvals)
1370
+ if ws is None: # no output rows at all
1371
+ wb.add_worksheet("Output_70_95_RapidFuzz").write_row(0, 0, header or ["(empty)"])
1372
+
1373
+ _write_df("ValidationAuditResults", _read(audit_path))
1374
+ _write_df("cluster_df", _read(clu_path))
1375
+ _write_df("final_cities", _read(fc_path))
1376
+ _write_df("final_zips", _read(fz_path))
1377
+ _write_df("final_census_tracts", _read(fct_path))
1378
+ _write_df("Summary", summary_df)
1379
+ wb.close()
1380
+
1381
+
1382
+ def process(uploaded_files, reference_file):
1383
+ """Dispatcher used by the UI: streaming or in-memory per PROCESS_MODE."""
1384
+ if PROCESS_MODE == "stream":
1385
+ return run_streaming(uploaded_files, reference_file, STRICT_BLANK_CITIES,
1386
+ FILTER_DOMINANT_COUNTY, CLUSTER_THRESHOLD, REPLACE_THRESHOLD)
1387
+ return run_pipeline(uploaded_files, reference_file, STRICT_BLANK_CITIES,
1388
+ FILTER_DOMINANT_COUNTY, CLUSTER_THRESHOLD, REPLACE_THRESHOLD)
1389
+
1390
+
1391
  # =====================================================================
1392
  # STARTUP HEALTH CHECK
1393
  # =====================================================================
 
1458
 
1459
  def _run(files, reference):
1460
  # All settings are fixed (see constants at top). No user-facing options.
1461
+ return process(files, reference)
 
1462
 
1463
  run_btn.click(
1464
  _run,