Skip to content
uhsearPublic

About

Refuse a block-to-district assignment that does not reconcile to its block extract, or uses the wrong block vintage.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

blockfit

Refuse a block-to-district assignment that does not reconcile to its block extract, or that was built on a different block vintage. It reads two CSV files and standard-library Python. It never reads a shapefile.

A district table is built from Census blocks. The usual method takes each block's centroid and asks which district holds it. A block that is mostly water has its centroid in the water, or just outside the district tiling. That block is in no district. Its people are in no district either.

The district table still prints. Each district has a believable number. The five numbers add up to a little less than the county total, and nothing in the job complains. In the fixture below the short table is 380 against 730. In a real county the gap can be a fraction of a percent, which is small enough to survive a review and large enough to be wrong.

The same job can fail a second way. Census redraws its blocks every decade. A district file made from the 2020 blocks, joined to 2010 people, shares many GEOIDs with the right file and disagrees about the rest. The join runs. The rows that did not match drop out. The totals are low again, and the table looks like any other.

blockfit takes the block extract and the assignment, and it refuses unless every block is in exactly one district, both files name the same blocks, and the district sums equal the extract.

$ python blockfit.py --blocks blocks.csv --assignment centroid.csv --year 2020 --county 99001
blockfit: blocks.csv with assignment centroid.csv
  county 99001, 2020 blocks, count column 'total'
  vintage: not verified. Neither header carries a GEOID10 or GEOID20 tag, so 2020 is your claim, not a finding.

  blocks in the extract       : 6
  blocks in the assignment    : 5
  blocks in both              : 5

  DISTRICT    BLOCKS        total     arealand
  1                2          300         3000
  2                3           80         4100

  total          districts 380 / extract 730
  arealand       districts 7100 / extract 11100

REFUSED: 1 reason(s)
  1. UNASSIGNED: 1 extract block(s) are in no district, holding 350 total: 990010001001003

The exit code is 1. County 99001 does not exist. Every number in this file comes from a synthetic fixture that --self-test builds, so you can run all of it without a download.

The existing tool, and what it does well

maup is the geospatial toolkit for redistricting data, and it is MIT licensed. Its assign function takes two sets of geometries and maps each source geometry to the target that covers it. When no single target covers a source, assign gives it to the target that covers the largest part of its area. That is the rule blockfit's --parts mode applies, and maup applies it to real geometry with spatial indexes, which blockfit does not do at all. Its doctor function also finds gaps, overlaps and invalid geometry in a shapefile. If you have geometry and a Python environment with geopandas, use maup to make the assignment.

What blockfit adds is the refusal after the assignment. maup's README shows how to count unassigned blocks, and how to compare the summed block totals with the summed target totals to see the loss, by hand and by printing the two sums. That comparison is the one blockfit makes, as a refusal with an exit code. maup's README does not describe a check that the sums equal a control total, a check that the assignment and the extract hold the same blocks, or a check of block vintage. blockfit makes those checks and nothing else. Read on 2026-10-24; maup may have changed since.

Self-test

$ python blockfit.py --self-test
blockfit self-test: no network, no census download, no arcpy
--------------------------------------------------------------------
...
PASS  an assignment missing the block whose centroid fell on water is refused and reports the 350 people it lost  <-- pinned defect
...
PASS  the district sums add to 380, not the extract's 730, with no error from the arithmetic  <-- pinned defect
...
PASS  a block with no people missing from the assignment is refused although the people still add up  <-- pinned defect
...
PASS  blocks missing on one side and extra on the other are called a vintage mismatch  <-- pinned defect
...
PASS  a GEOID10 extract against --year 2020 is refused  <-- pinned defect
...
PASS  an empty extract is refused, not reconciled as 0 equals 0  <-- pinned defect
...
PASS  a run without --apply writes nothing at all  <-- pinned defect
...
PASS  and the report says the vintage was not verified  <-- pinned defect
...
PASS  a district label of 5000 digits reconciles and does not end in a traceback  <-- pinned defect
...
PASS  --apply does NOT overwrite the table when the check refused  <-- pinned defect
...
PASS  and still exits 1, because the extract is still wrong  <-- pinned defect
...
PASS  --ap is refused by the parser with exit 2 and writes nothing  <-- pinned defect
...
PASS  a closed pipe exits 2 and the CSV is not written  <-- pinned defect
...
PASS  a real closed pipe exits 2, not the interpreter's 120, and writes no file  <-- pinned defect
...
PASS  --out sorts whole numbers as numbers, then text: 1, 2, 10, A, from rows given as A, 10, 2, 1  <-- pinned defect
...
PASS  an extract one person ABOVE the control total is refused too, not only one below  <-- pinned defect
...
PASS  the whole command line half made no network call
...
PASS  the file imports only the standard library: no arcpy, no third party package
...
PASS  the harness records a false check, a missing exception and a wrong exception as three failures, so a broken tool turns this self-test red  <-- pinned defect
...
--------------------------------------------------------------------
176 assertions, 0 failed

The block is abridged. ... marks each place where lines are left out. The same 176 assertions pass on Windows with Python 3.13, 3.12, 3.10 and 3.9, with ArcGIS Pro's own Python (3.13.7), and on Ubuntu with Python 3.12. I did not run Python 3.11. The full output on Windows and the full output on Ubuntu are byte for byte identical apart from line ends. Branch coverage of blockfit.py under --self-test is 100 percent (800 statements, 170 branches, coverage.py 7.16.1, measured on Windows with Python 3.13). I did not measure coverage on Ubuntu.

Requirements

Python 3.9 or newer. Standard library only: argparse, csv, io, math, os and sys. The self-test also imports ast, contextlib, runpy, shutil, socket, subprocess and tempfile. It runs on ArcGIS Pro's default Python environment and on a plain python3. The self-test asserts this import list, so a new dependency turns it red.

git clone https://github.com/uhsear/blockfit.git
python blockfit.py --self-test

--self-test needs no network and no download. It writes its CSV files to a temporary directory, then runs the real command line against them, once in-process and once as a separate process. During the in-process runs it replaces socket.socket, getaddrinfo and create_connection with a trap that raises, and it asserts that nothing called the trap. It does not prove the tool is offline on every code path. It proves that none of the runs it makes touched the network.

Quick start

python blockfit.py --blocks blocks.csv --assignment assign.csv --year 2020 --county 99001
python blockfit.py --blocks blocks.csv --parts pieces.csv --year 2020 --county 99001

Each prints the district table and the reconciliation, and writes nothing.

python blockfit.py --blocks blocks.csv --parts pieces.csv --year 2020 --county 99001 \
    --out districts.csv --assign-out assigned.csv --apply

Usage

Flag Default What it does
--blocks none Block extract CSV: one GEOID column and whole-number count columns. Required.
--assignment none CSV with geoid and district. Use this or --parts.
--parts none CSV with geoid, district and area: the pieces of an overlay of blocks on districts.
--year none Vintage of --blocks: 2010 or 2020. Required.
--county none Five digits, state FIPS then county FIPS. Required. There is no default.
--measure total The count column that means people.
--control-total off The extract's --measure must equal this whole number.
--out none Path for the district summary CSV. Needs --apply.
--assign-out none Path for the block-to-district CSV that --parts produced. Needs --apply.
--apply off Write --out and --assign-out. Without it blockfit writes no file. --self-test is the one exception (see Output).
--allow-unreconciled off Write the outputs even when the check refused. The exit code stays 1.
--self-test off Run the offline assertions and exit.

Flags must be spelled out. The parser is built with allow_abbrev=False, so --ap, --app and --appl are refused with exit 2 and write nothing. They are not read as --apply. So are --allow and --assign. The self-test runs each of them, and runs --ap again in a separate process.

Column names are not case sensitive. A GEOID column is geoid, geoid10 or geoid20, and an input may have only one of them. A header of GEOID10 or GEOID20 is also a claim about vintage, and blockfit checks it against --year.

An output path that is also an input is a usage error. The tool will not overwrite its own input.

What it checks

Each check below is a refusal. A refusal prints a reason and exits 1.

  1. Every block is in a district. A block in the extract and not in the assignment is UNASSIGNED. The reason gives the number of blocks, the people in them and example GEOIDs. The count is by block, not only by people. A block with no residents moves no population total, so a population sum alone cannot see it leave. The reason names the block count and the --measure total only. The loss in every other count column shows in the sums table.
  2. The two files describe one block set. When some extract blocks are missing from the assignment and some assignment blocks are missing from the extract, the reason is VINTAGE. It says how many assignment blocks do match. A block in the assignment only is an ORPHAN when nothing is missing the other way.
  3. The header tag. A GEOID10 header against --year 2020 is a VINTAGE refusal, on either file.
  4. One district per block. A block named twice with the same district is REPEATED. A block named with two districts is a CONFLICT. Neither is double counted: the first district is kept for the table, and the run is refused.
  5. The county. A block whose GEOID does not start with --county is a COUNTY refusal on whichever file holds it. It is not counted as an orphan or as unassigned.
  6. The control total. With --control-total, the extract's --measure must equal it.
  7. Not empty. An extract with no rows is EMPTY. Zero equals zero is not a reconciliation.

The district sums are printed beside the extract sums for every count column. They are equal when no block is lost, so equal sums are needed for RECONCILED. They are not enough. A repeated block, a conflict, a control total mismatch, an empty extract and an out-of-county block all print equal sums and are still refused. The reasons, and the last line, are the verdict. The sums line is not.

The inputs are read strictly. These are errors with exit 2: a GEOID that is not 15 ASCII digits (a 14 digit one gets a message about a lost leading zero), a count that is not a whole number, a block twice in the extract, a short or long row, a repeated column name, a district that is empty or not printable ASCII, an area that is not a finite number of 0 or more, a NUL byte, and a file that is not UTF-8. Most name the line. A repeated column name, a NUL byte and a file that is not UTF-8 name only the file. A leading zero in a state FIPS survives, because GEOIDs are read as text.

Every column in the extract except the GEOID must be a whole number. A text column, such as a NAME column in a Census download, ends the run with exit 2 and not a whole number. Remove it first.

--parts: largest overlap

--parts takes the pieces of an overlay of blocks on districts. The pieces of one block that fall in the same district are added. The block goes to the district with the largest total.

  • A tie goes to the lowest district. Whole numbers sort as numbers, so a tie between 2 and 10 goes to 2. The tie count is printed.
  • A piece with an area of 0 is ignored and counted. A block with no positive piece is left out of the assignment, so the reconciliation reports it as unassigned.
  • The areas are added with math.fsum, which returns the correctly rounded sum of the exact inputs. A running float sum depends on the order of the rows, and the built-in sum is only compensated from Python 3.12. The self-test pins a case where order decides the tie. That pin catches a switch to sum on Python 3.9 to 3.11 only. On 3.12 and later sum gives the same answer.
  • The report counts blocks that touch more than one district and the people in them. The rule puts each whole block in one district, so that number is an upper bound on the people it can place in the wrong one.
$ python blockfit.py --blocks blocks.csv --parts pieces.csv --year 2020 --county 99001
...
  DISTRICT    BLOCKS        total     arealand
  1                3          330         3600
  2                3          400         7500

  total          districts 730 / extract 730
  arealand       districts 11100 / extract 11100

  blocks split across districts: 2, holding 230 total (whole blocks, so this bounds the people the largest-overlap rule can misplace)
  ties on area broken by the lowest district: 1
  pieces with zero area ignored: 1

RECONCILED: every block is in exactly one district and the district sums equal the extract.

The block with 350 people in water is in the pieces file here, with a part of its own, so the same fixture that failed under the centroid rule reconciles.

A vintage mismatch

$ python blockfit.py --blocks blocks.csv --assignment old.csv --year 2020 --county 99001
...
REFUSED: 1 reason(s)
  1. VINTAGE: 2 extract block(s) (350 total) are in no district and 2 assignment block(s) are not in the extract; 4 of 6 assignment blocks match. Census redraws blocks every decade, so these files describe different block sets. Both files must use the 2020 blocks, and blockfit cannot tell which one does not. Extract only: 990010001001003, 990010001001004. Assignment only: 990010001009100, 990010001009200

The reason says the files disagree about which blocks exist. blockfit cannot tell you which file is on the wrong vintage. It can tell you the two are not on the same one. Any run with blocks on both sides that the other side lacks gets this wording, so one mistyped GEOID in each file gets it too.

Producing the inputs

The extract is a CSV with a GEOID column and count columns. The CSV that pl94 writes fits: its 15 columns load, 14 of them as counts, and the self-test loads a synthetic CSV with that header. blockfit does not read the PL 94-176 zip. The extract carries the people. It does not carry a second copy of the county total, which is why --control-total exists. Take the control from the county record in the same PL file, which pl94 already reconciles to the extract.

The assignment is geoid,district. The pieces are geoid,district,area, one row per piece of a block that lies in a district. In ArcGIS Pro, the Intersect tool writes "features or portions of features that overlap in all layers or feature classes" to its output, and copies the attributes of the inputs to it. That is the shape --parts needs. Export the block GEOID, the district and the polygon area. I have not run an Intersect for this release. The SHAPE@AREA token is documented as "a double of the feature's area" and the page does not say the units. blockfit compares areas as plain numbers and cannot know their unit. Take every area from one projection. maup's README warns that "many of maup's functions behave badly in geographic projections", and an equal-area projection is the safe choice. blockfit does not check any of this.

Exit codes

Code Meaning
0 The assignment reconciles to the extract.
1 It does not. The reasons are printed. A failed --self-test also exits 1.
2 A file could not be read or is malformed, an output could not be written, or the report could not be written to stdout. argparse also exits 2 for a flag it does not know, such as --ap.
64 Usage error found by blockfit: a missing --blocks, --year or --county, a bad value, both or neither of --assignment and --parts, an output without --apply, --apply with no output, or an output that is also an input.

Exit 1 and exit 2 are kept apart on purpose. A scheduled job reads 1 as "the numbers disagreed", which is a fact about the data. A file that could not be written is a fact about the disk.

Output

Without --apply, a normal run writes no file. The self-test lists the directory before and after a run without it, and asserts that the listing is unchanged. --self-test is the one exception: it writes about two dozen fixture files into a new folder in the system temporary directory, runs against them, and deletes the folder when it passes. It never writes beside the inputs or in the current directory.

--out writes one row per district, sorted with whole numbers first, then text. --assign-out writes geoid,district, sorted by GEOID. Both use LF line ends on every host, so two runs on the same inputs produce the same bytes.

district,blocks,total,arealand
1,2,300,3000
2,4,430,8100

When the check refused, --apply writes nothing and says so. --allow-unreconciled writes the short table anyway and the run still exits 1. A job that overrides the refusal cannot also report success. The report is flushed before any file is written. If stdout is a closed pipe the run exits 2 and writes no file. On a closed pipe Python would flush its stdout buffer a second time at shutdown, fail again, and exit 120. blockfit points its stdout descriptor at the null device first so that the exit code stays 2. The self-test runs this against a real closed pipe in a child process, and checks the descriptor redirect in process.

The file written by --assign-out goes back through the check as --assignment. The self-test does that and it reconciles.

Why the obvious version is wrong

The handwritten version assigns by centroid and adds up what it assigned:

for block in blocks:
    district = district_containing(block.centroid)      # None on water: the block is dropped
    if district is not None:
        totals[district] += block.population            # no error, no warning
print(totals)                                           # the table looks fine

Nothing in that loop compares sum(totals.values()) to the sum over blocks. A warning is the tempting repair:

if sum(totals.values()) != county_total:
    log.warning("district sum != county total")         # wrong
write_table(totals)

The table is still written, still loaded into a map, and the warning scrolls off. With --apply, blockfit writes nothing when it refuses. --allow-unreconciled writes the short table on purpose, and the run still exits 1.

Comparing only population hides a second loss. Under a centroid rule, the blocks that fall out are often the empty ones: water, and a highway median. They move no population total. The self-test removes a block with 0 people and asserts that the run is refused anyway, because it counts blocks and not only people.

Limits

  • Fixtures are synthetic. County 99001 does not exist. I did not run blockfit against a real county's block file or a real overlay for this release. The pl94 column layout it accepts was checked by loading a synthetic CSV with pl94's header, not a CSV pl94 wrote.
  • Vintage is inferred. blockfit sees a header tag and a difference between two block sets. If a county's blocks were identical in both decades, a vintage mismatch would reconcile and nothing would show it. A one-sided difference is called UNASSIGNED or ORPHAN, not VINTAGE, because a single-sided gap is also what a centroid loss looks like. The Census Bureau publishes a 2010 to 2020 block relationship file for moving counts between vintages. blockfit does not read it and does not convert between vintages.
  • 15 digit GEOIDs. The Census page on geographic identifiers describes a block GEOID as state, county, tract and block, 2 + 3 + 6 + 4 digits, and notes that some blocks carry a one character suffix. blockfit refuses a 16 character value rather than guess where the suffix goes.
  • No geometry. blockfit never decides which district holds a block. It checks a table that something else made. With --parts it applies the largest-overlap rule to areas you supply and trusts them.
  • Exact integers only. Counts must be whole numbers, and the reconciliation has no tolerance. One person off is a refusal.
  • One count column means people. --measure names it. The other columns are summed and printed, and reconciled by the same argument, but only --measure goes into the reasons and the control.
  • Vintage is a tag and a difference. --year matters only when a header carries a GEOID10 or GEOID20 tag, or when the two block sets differ. Two files with a plain geoid header on the wrong decade and identical block sets reconcile. A wrong --year given with consistent files reconciles. --year is a claim that blockfit cannot verify. pl94 writes a plain geoid header for both vintages. When neither file carries a tag, the report says "vintage: not verified" and that the year is your claim, and the exit code stays 0. One tagged file is enough to drop that line, and the tag is then checked on that file only.
  • The control total is trusted. --control-total checks the extract against the number. It cannot check the number. A wrong control gives a wrong verdict.
  • District labels are text. A label is compared as text after trimming. 1 and 01 are two districts. A missing value written as nan, None, 0 or NULL is a district of its own. Such an assignment reconciles with exit 0 and a phantom row in the table, so read the district table. blockfit does not know which labels are valid. A label that starts with =, +, - or @ is written as it is, so a spreadsheet may read it as a formula.
  • Areas are trusted. --parts areas are not checked for units or projection. A mixed set gives a wrong largest part and no warning. Duplicate piece rows (the same GEOID, district and area) are summed as separate pieces and are not detected, so they can change which district wins. The areas are read with float(), which also accepts 1_0 and non-ASCII digits. GEOIDs and counts are stricter and take ASCII digits only.
  • Large numbers. Recent Python releases cap int() at 4300 digits (3.11 and later, and the security releases of 3.9 and 3.10; I saw it on 3.13 and 3.9.25). A count cell or --control-total of more than 4300 digits ends in a traceback with exit 1, and exit 1 means "refused" to a scheduler. Do not feed the tool numbers longer than 4300 digits. Pieces of one block that sum past about 1.8e308 in area make math.fsum raise OverflowError, with the same result. A district label of any length is fine, because labels are sorted without int(); the self-test pins a label of 5000 digits.
  • A traceback is a crash. Any uncaught exception exits 1, the same code as a data refusal. The only reachable ones found are the two above, and a missing sys.stdout (Python started with stdout closed, or pythonw), which raises AttributeError and exits 1 instead of 2. Treat a traceback as a crash even though the exit code is 1.
  • Outputs are not atomic. --out and --assign-out are opened for writing in place, not written to a temporary file and renamed. A full disk part way through leaves a truncated table, and the run exits 2. With both flags, a failure on the second leaves the first file written. The exit code is 2.
  • The input check is by path. The test that an output is an input uses realpath and normcase. It does not follow hard links, NTFS alternate data streams or 8.3 short names. It is case sensitive on a case-insensitive POSIX file system, such as the default on macOS. Do not point --out at a hard link of an input.
  • Mutants the self-test cannot tell apart. I ran a mutation sweep of the code above self_test() on an earlier build, before the vintage line and the district_key change. Two surviving mutants are equivalent. A further dozen or so are cosmetic or sit on a boundary that the self-test does not pin. None of them could change a verdict or a write, and the decision core had no other survivor. I did not repeat the sweep after those two changes. The two equivalent mutants are the sample() boundary (exactly 5 items prints without "first 5 of 5"), which has no observable difference that a test could pin, and the second if not args.apply: return code guard, which is redundant behind usage_problem. The unpinned ones: the self-test does not pin the report's blank separator lines, the numbering of reasons (it starts at 1), the extract label in the header line, the assignment or parts word in the header line, the closing flush, or the wrote line for --assign-out. The upper boundary of the printable-ASCII rule for district labels (the tilde) is not pinned by the self-test. Replacing math.fsum with sum is caught on Python 3.11 and earlier only. On 3.12 and 3.13 the built-in sum is compensated, so the Python 3.9 and 3.10 runs are the ones that catch it.
  • Blank and odd lines. A trailing line of spaces or commas is a malformed row and ends the run with exit 2, not a skipped blank line. Only comma separated files are read; a semicolon or tab file exits 2 with a missing-GEOID-column message that does not name the delimiter. A blank district cell, as a spatial join writes for a block outside the district tiling, is exit 2, not an UNASSIGNED refusal with exit 1.
  • The output path is not checked for type. A device name such as NUL or /dev/null is reported as written, and nothing is stored. File names are echoed with non-ASCII escaped but ASCII control characters left as they are.
  • Float noise is not a tie. Areas that are equal in exact arithmetic but differ by binary float noise in the file (0.30000000000000004 against 0.3) are not reported as ties.
  • A short table has no marker. A file written under --allow-unreconciled carries the same header as a good one. Only the exit code and the report on stdout say that it is short.
  • The self-test cleans up on success only. A crash part way through leaves a blockfit-* folder in the system temporary directory.
  • Not run elsewhere. Windows with Python 3.9, 3.10, 3.12 and 3.13, and Ubuntu with Python 3.12. Not Python 3.11. Not macOS. The self-test never imports arcpy, so it says nothing about how blockfit behaves on data that came out of ArcGIS Pro.

Sources checked

Read on 2026-10-22.

  • Census Bureau, geographic identifiers (census.gov/programs-surveys/geography/guidance/geo-identifiers.html): a block GEOID is state + county + tract + block, 2 + 3 + 6 + 4 digits, 15 in all; some blocks carry a one character suffix.
  • Census Bureau, glossary (census.gov/programs-surveys/geography/about/glossary.html): blocks are "numbered uniquely with a four-digit census block number from 0000 to 9999 within census tract", and the first digit is the block group.
  • Census Bureau, "Data Gem: Can I Compare 2020 Census and 2010 Census Redistricting Data?" (census.gov/data/academy/data-gems/2021/compare-2020-census-and-2010-census-redistricting-data.html): most blocks, tracts and block groups were redrawn between the two censuses, so a direct block comparison across decades is not advised. This is my paraphrase. I did not re-confirm the exact wording of the transcript on 2026-10-23, so it is not in quotation marks.
  • Census Bureau, relationship files page (census.gov/geographies/reference-files/time-series/geo/relationship-files.2020.html): the block relationship files compare the universe of 2010 tabulation blocks to the universe of 2020 tabulation blocks.
  • Esri, Intersect tool and SearchCursor documentation (doc.esri.com/en/arcgis-pro/latest/): the wording quoted above.
  • maup README (github.com/mggg/maup): the behaviour of assign and doctor, the MIT licence, and the geographic coordinates warning.

I did not read anything about SQL Server, PROJ or FEMA for this tool, because blockfit relies on none of them.

Related

  • pl94 reads the PL 94-176 bulk file and refuses a block extract that does not reconcile to the county record in the same file. blockfit is its companion: pl94 decides the extract is right, and blockfit decides the districts account for all of it.
  • maup assigns blocks, precincts and districts to each other from real geometry. Use it to make an assignment. Use blockfit to check one.
  • Other repositories are called blockfit or close to it. gh search repos blockfit --limit 10 on 2026-10-24 returned ten, and this repository was not among them; it appears at --limit 30. The ones with a real description were a genetic-algorithm shape-packing program (BlockFitter), a hackathon project from 2018 and its demo, and a minimal heap allocator in C (kundurz/blockfit). None is about Census blocks as far as the descriptions say. The rest had no description or a placeholder, and I did not open them. A later search may list different repositories.

Contributing

Open an issue or pull request on GitHub.

About

Refuse a block-to-district assignment that does not reconcile to its block extract, or uses the wrong block vintage.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages