#!/usr/bin/env python3
"""Generate the four embeddable charts for field study No. 06 (Nashville /
Davidson County, TN).
Every plotted value is READ FROM nashville-commercial-record-2026-09.csv at run
time. Nothing is typed in by hand: if a metric is not in the CSV, this script
raises rather than draw it. The CSV is produced by count_nashville.py,
published beside it.
python3 make-charts.py [path/to/nashville-commercial-record-2026-09.csv]
Same drawing code as studies No. 03, 04 and 05, so the six studies share one
visual language."""
import csv, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
CSV_PATH = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
HERE, "nashville-commercial-record-2026-09.csv")
def load(path):
vals = {}
with open(path, newline="", encoding="utf-8") as fh:
rows = [ln for ln in fh if not ln.startswith("#")]
for r in csv.DictReader(rows):
vals[r["metric"]] = (r["value"], r["share"])
if not vals:
raise SystemExit("no metrics parsed from " + path)
return vals
V = load(CSV_PATH)
def num(metric):
"""A figure, straight from the CSV. KeyError if it was never counted."""
if metric not in V:
raise KeyError("not in the CSV, so it was not counted: " + metric)
s = V[metric][0]
return float(s) if "." in s else int(s)
def share(metric):
"""The share as the CSV prints it, as a number. Never recomputed here."""
if metric not in V or not V[metric][1]:
raise KeyError("no share in the CSV for: " + metric)
return float(V[metric][1].rstrip("%"))
SURFACE="#f7f5ed"; INK="#173e32"; MUTED="#7b9384"
ACCENT="#0d6b47"; GRAY="#b6c4b5"; GRID="#d9e0d4"
FONT="system-ui,-apple-system,'Segoe UI',Helvetica,Arial,sans-serif"
CREDIT="Sterling Digital Partners ยท glvtl.com/field-study"
def bar(x,y,w,h,r=4):
if w<=r: return f''
return (f'')
def wrap(t,n=112):
out,cur=[],""
for w in t.split():
if len(cur)+len(w)+1>n: out.append(cur); cur=w
else: cur=(cur+" "+w).strip()
if cur: out.append(cur)
return out
def chart(fn,title,sub,rows,axis_max,ticks,legend,note,W=720,L=248,fmt="{:,}",tickfmt=None):
n=len(rows); band=38; top=92
tickfmt = tickfmt or fmt
nl=wrap(note)
H=top+n*band+34+len(legend)*19+len(nl)*16+32
R=66; pw=W-L-R
s=[f'')
open(os.path.join(HERE,fn),'w').write('\n'.join(s))
print("wrote",fn,f"({W}x{H})")
def stacked(fn,title,sub,rows,legend,note,W=720,L=206):
nl=wrap(note); band=54; top=98
H=top+len(rows)*band+18+len(legend)*19+len(nl)*16+32
R=26; pw=W-L-R
s=[f'')
open(os.path.join(HERE,fn),'w').write('\n'.join(s))
print("wrote",fn,f"({W}x{H})")
# --- Finding 01: the address is not the government ---------------------------
CITIES = ["Berry Hill", "Belle Meade", "Oak Hill", "Forest Hills",
"Goodlettsville", "Ridgetop"]
rows = []
for c in CITIES:
pc = share(f"{c} parcels with a NASHVILLE property address")
rows.append((f"{c} ({num(c + ' parcels'):,} parcels)", pc, pc > 99))
chart("field-study-06-satellite-cities.svg",
"Six cities inside Davidson County. Four of them are addressed Nashville",
"Share of each separately incorporated city's parcels whose property "
"address reads NASHVILLE",
rows, 100, [0, 25, 50, 75, 100],
[(ACCENT, "Every parcel in the city carries a Nashville address"),
(GRAY, "Some or none do")],
"Berry Hill and Belle Meade return exactly one distinct property-city "
"value between them, and it is NASHVILLE. Counted 20 September 2026 from "
"Metro Nashville's own parcel service.",
fmt="{:.1f}%", tickfmt="{}%")
# --- Finding 02: the whitespace that hides a district -------------------------
rows = []
for code, label in [("USD", "Urban Services District"),
("GSD", "General Services District"),
("GO", "Goodlettsville"), ("BH", "Berry Hill"),
("FH", "Forest Hills"), ("OH", "Oak Hill")]:
k = f"TaxDist {code} parcels missed by an exact match"
rows.append((f"{label} ({code})", num(k), code in ("USD", "GSD")))
chart("field-study-06-whitespace.svg",
"43,132 parcels are invisible to an exact-match filter",
"Parcels whose tax district is stored with trailing whitespace, and so "
"are missed by a query for the district's own code",
rows, 40000, [0, 10000, 20000, 30000, 40000],
[(ACCENT, "Metro Nashville's own service districts"),
(GRAY, "Separately incorporated cities")],
"The column holds 22 distinct values. There are 15 districts. Nothing "
"errors; the rows simply do not come back. Counted 20 September 2026.",
fmt="{:,}", tickfmt="{:,}")
# --- Finding 04: one code, two classifications --------------------------------
SPLITS = [("012", "DUPLEX"), ("015", "RESIDENTIAL CONDO"),
("020", "VACANT COMMERCIAL LAND"), ("016", "ZERO LOT LINE"),
("010", "VACANT RESIDENTIAL LAND"), ("011", "SINGLE FAMILY")]
rows = []
for code, desc in SPLITS:
k = f"Code {code} ({desc}) assessed at 40%"
rows.append((f"{code} {desc.title()}", share(k), code in ("012", "011")))
chart("field-study-06-classification.svg",
"The same land use code, assessed under two different classifications",
"Share of each code's parcels assessed at Tennessee's 40% commercial "
"and industrial ratio; the rest are at 25% or zero",
rows, 100, [0, 25, 50, 75, 100],
[(ACCENT, "Codes whose own description reads as residential"),
(GRAY, "Other codes split across both ratios")],
"Tennessee assesses commercial and industrial real property at 40% of "
"appraised value and residential and farm property at 25%. Seven codes "
"carry parcels on both sides. Counted 20 September 2026.",
fmt="{:.1f}%", tickfmt="{}%")
# --- Finding 06: what the sale record carries ---------------------------------
comm = num("Parcels classified commercial or industrial by the assessor")
noprice = num("Commercial parcels carrying no sale price at all")
priced = num("Commercial parcels carrying a sale price")
stacked("field-study-06-sale-record.svg",
"Two in five commercial parcels carry no sale price at all",
"Davidson County parcels the assessor classifies commercial or "
"industrial, by whether the record carries a price",
[("Commercial parcels", noprice, priced,
f"{comm:,} parcels assessed at 40%")],
[(ACCENT, "No sale price in the record"),
(GRAY, "A sale price, under one of 28 undocumented codes")],
"The field named ValidSale is populated on 4 of the county's 287,112 "
"parcels. Counted 20 September 2026.")