Spaces:
Sleeping
Sleeping
Commit ·
9b6465c
1
Parent(s): bb973ac
Upload 2 files
Browse files
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 =
|
| 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
|
| 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,
|