#!/usr/bin/env python3
"""Generate the four embeddable charts for field study No. 05 (Charleston
Tri-County, SC).
Every plotted value is READ FROM charleston-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_charleston.py,
published beside it.
python3 make-charts.py [path/to/charleston-commercial-record-2026-09.csv]
Same drawing code as the Harris and Mecklenburg studies, so the five 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, "charleston-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)
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})")
BUCKETS = ["Sold before 2012", "Sold 2012-2014", "Sold 2015-2017",
"Sold 2018-2021", "Sold 2022-2025"]
SHORT = {"Sold before 2012": "before 2012", "Sold 2012-2014": "2012-14",
"Sold 2015-2017": "2015-17", "Sold 2018-2021": "2018-21",
"Sold 2022-2025": "2022-25"}
# --- Finding 01: the gradient -------------------------------------------------
rows = []
for county, label, hot in (("Berkeley commercial", "Berkeley", True),
("Dorchester 6% class", "Dorchester", False)):
for b in BUCKETS:
rows.append((f"{label}, sold {SHORT[b].replace('before ', 'before ')}",
num(f"{county} / {b} / median gap below market"), hot))
chart("field-study-05-reset-gradient.svg",
"What a commercial property is taxed on depends on when it last sold",
"Median shortfall of taxable value below the county's own market value, "
"by the year the property last changed hands",
rows, 60, [0, 15, 30, 45, 60],
[(ACCENT, "Berkeley County, parcels with a commercial building (n=3,357)"),
(GRAY, "Dorchester County, the 6% assessment class (n=26,182)")],
"South Carolina resets a property's taxable value when it changes hands. "
"Two counties, two unrelated CAMA vendors, the same gradient. Counted "
"20 September 2026 from each county's own public parcel service.",
fmt="{:.1f}%", tickfmt="{}%")
# --- Finding 01b: how many show any gap at all --------------------------------
rows = []
for county, label, hot in (("Berkeley commercial", "Berkeley", True),
("Dorchester 6% class", "Dorchester", False)):
for b in BUCKETS:
rows.append((f"{label}, sold {SHORT[b]}",
num(f"{county} / {b} / share showing any gap"), hot))
chart("field-study-05-any-gap.svg",
"The longer it has been held, the likelier it is taxed below market",
"Share of commercial parcels whose taxable value sits more than 1% below "
"market value",
rows, 100, [0, 25, 50, 75, 100],
[(ACCENT, "Berkeley County, parcels with a commercial building"),
(GRAY, "Dorchester County, the 6% assessment class")],
"Even among the most recently sold, some gap remains: a sale is not the "
"only thing that can reset a value, and not every transfer is an "
"assessable transfer of interest. Counted 20 September 2026.",
fmt="{:.1f}%", tickfmt="{}%")
# --- Finding 03: the column Charleston does not have --------------------------
chart("field-study-05-charleston-blank.svg",
"The biggest county in the metro cannot show this at all",
"Charleston County parcels carrying each value, of 197,620 in the "
"county's public parcel layer",
[("Appraised (market) value",
num("Charleston parcels carrying an appraised (market) value"), False),
("Taxable or assessed value",
num("Charleston parcels carrying a taxable or assessed value"), True)],
200000, [0, 50000, 100000, 150000, 200000],
[(GRAY, "Published by Charleston County"),
(ACCENT, "Not present in any of the layer's 47 columns")],
"The gap between taxable and market value can only be measured where "
"both are published. Charleston County publishes one of them. Counted "
"20 September 2026.",
fmt="{:,}", tickfmt="{:,}")
# --- Finding 04: an undocumented code set -------------------------------------
codes = sorted(((k.split()[3], num(k)) for k in V
if k.startswith("Berkeley Validity code ")
and not k.startswith("Berkeley Validity code (blank)")),
key=lambda kv: -kv[1])[:10]
chart("field-study-05-validity.svg",
"The metro's only sale-qualification field, and no published legend",
"Rows carrying each Validity code on Berkeley County's 123,050-parcel "
"roll, ten most common",
[(f"Code {c}", n, c in ("0", "0A")) for c, n in codes],
max(n for _, n in codes), [0, 15000, 30000, 45000, 60000],
[(ACCENT, "Codes 0 and 0A, which behave like arm's-length in the data"),
(GRAY, "Codes whose meaning the county does not publish")],
"Every field in the service returns a null domain, the subtype list is "
"empty, and no county or vendor document defines these codes. We count "
"them; we do not name them. Counted 20 September 2026.",
fmt="{:,}", tickfmt="{:,}")