[object Object]

← back to La Permits School

Add summary.py: class-ready descriptive stats (by year, top contractors, by council district, permit-type mix, biggest projects)

70572990f248ddf9cf01ae92e1fc72ef3566bc53 · 2026-08-10 13:07:49 -0700 · Steve Abrams

Files touched

Diff

commit 70572990f248ddf9cf01ae92e1fc72ef3566bc53
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 13:07:49 2026 -0700

    Add summary.py: class-ready descriptive stats (by year, top contractors, by council district, permit-type mix, biggest projects)
---
 README.md  |   8 +++-
 summary.py | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 128 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index bdc1b22..8fa0dbb 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,13 @@ python3 pull_permits.py --limit 500 --out sample.csv
    ```
    Example: 1,000 permits → ~290 unique firms; top row = Hensel Phelps ($1.6B / 10
    permits). This deduped list IS your "top LA contractors" ranking.
-3. **CA SOS + CSLB** (free lookups) → open the URLs on the firms you care about to
+3. **`summary.py`** → class-ready descriptive stats printed to the terminal
+   (headline totals, permits & $ by year, top contractors, build $ by council
+   district, permit-type mix, biggest single projects):
+   ```bash
+   python3 summary.py --top 8
+   ```
+4. **CA SOS + CSLB** (free lookups) → open the URLs on the firms you care about to
    resolve the legal entity + agent (SOS) and license status/classification (CSLB).
 
 ### Why the enrichment is a worklist, not an auto-scrape
diff --git a/summary.py b/summary.py
new file mode 100644
index 0000000..de4a547
--- /dev/null
+++ b/summary.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""
+summary.py — class-ready descriptive stats from permits.csv (school project). $0, stdlib only.
+
+Prints: headline totals, permits & $ by year, top contractors by build value,
+total build $ by City Council district, permit-type mix, and the biggest single
+projects — all as clean text tables you can paste into a report or slide.
+
+Usage:
+  python3 summary.py                 # reads permits.csv
+  python3 summary.py --in firms.csv --top 15
+"""
+
+import argparse
+import csv
+from collections import defaultdict
+
+OWNER_PLACEHOLDERS = {"OWNER-BUILDER", "OWNER BUILDER", "OWNER/BUILDER"}
+
+
+def money(v):
+    try:
+        return float(v)
+    except (TypeError, ValueError):
+        return 0.0
+
+
+def usd(n):
+    return "$" + format(int(n), ",")
+
+
+def bar(frac, width=24):
+    return "█" * int(round(frac * width))
+
+
+def table(title, rows, cols):
+    """rows = list of tuples; cols = list of (header, width, align) where align in 'lr'."""
+    print(f"\n{title}")
+    print("-" * len(title))
+    head = "  ".join(("{:<%d}" % w if a == "l" else "{:>%d}" % w).format(h) for h, w, a in cols)
+    print(head)
+    for r in rows:
+        print("  ".join(("{:<%d}" % w if a == "l" else "{:>%d}" % w).format(str(c)) for c, (_, w, a) in zip(r, cols)))
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Descriptive stats from a permits CSV.")
+    ap.add_argument("--in", dest="infile", default="permits.csv")
+    ap.add_argument("--top", type=int, default=10, help="rows per ranking (default 10)")
+    args = ap.parse_args()
+
+    rows = list(csv.DictReader(open(args.infile, newline="", encoding="utf-8")))
+    if not rows:
+        print("No rows in", args.infile)
+        return
+
+    total_val = sum(money(r.get("valuation")) for r in rows)
+    dates = sorted(r["issue_date"][:10] for r in rows if r.get("issue_date"))
+
+    by_year = defaultdict(lambda: [0, 0.0])           # year -> [count, $]
+    by_cd = defaultdict(lambda: [0, 0.0])             # council district -> [count, $]
+    by_type = defaultdict(lambda: [0, 0.0])           # permit_type -> [count, $]
+    by_firm = defaultdict(lambda: [0, 0.0])           # contractor -> [count, $]
+    biggest = []                                      # (val, address, contractor)
+
+    for r in rows:
+        v = money(r.get("valuation"))
+        yr = (r.get("issue_date") or "")[:4]
+        if yr:
+            by_year[yr][0] += 1; by_year[yr][1] += v
+        cd = (r.get("council_district") or "?").strip() or "?"
+        by_cd[cd][0] += 1; by_cd[cd][1] += v
+        pt = (r.get("permit_type") or "?").strip() or "?"
+        by_type[pt][0] += 1; by_type[pt][1] += v
+        name = (r.get("contractors_business_name") or "").strip()
+        if name and name.upper() not in OWNER_PLACEHOLDERS:
+            by_firm[name][0] += 1; by_firm[name][1] += v
+        biggest.append((v, r.get("address", ""), name or "(none)"))
+
+    # ---- headline ----
+    print("=" * 58)
+    print("LA BUILDING PERMITS — SUMMARY   (cost: $0, local)")
+    print("=" * 58)
+    print(f"  Permits           : {len(rows):,}")
+    print(f"  Total valuation   : {usd(total_val)}")
+    print(f"  Avg per permit    : {usd(total_val / len(rows))}")
+    print(f"  Date range        : {dates[0]}  ->  {dates[-1]}")
+    print(f"  Unique contractors: {len(by_firm):,} (excl. owner-builder)")
+
+    # ---- by year ----
+    ymax = max(c for c, _ in by_year.values())
+    table("PERMITS BY YEAR", [
+        (y, f"{c:,}", usd(s), bar(c / ymax)) for y, (c, s) in sorted(by_year.items())
+    ], [("Year", 6, "l"), ("Permits", 9, "r"), ("Valuation", 18, "r"), ("", 24, "l")])
+
+    # ---- top contractors ----
+    top_firms = sorted(by_firm.items(), key=lambda kv: kv[1][1], reverse=True)[:args.top]
+    table(f"TOP {args.top} CONTRACTORS BY BUILD VALUE", [
+        (i + 1, usd(s), f"{c}", name[:38]) for i, (name, (c, s)) in enumerate(top_firms)
+    ], [("#", 3, "r"), ("Total $", 18, "r"), ("Permits", 8, "r"), ("Contractor", 38, "l")])
+
+    # ---- by council district ----
+    top_cd = sorted(by_cd.items(), key=lambda kv: kv[1][1], reverse=True)[:args.top]
+    table(f"TOP {args.top} COUNCIL DISTRICTS BY BUILD VALUE", [
+        (cd, f"{c:,}", usd(s)) for cd, (c, s) in top_cd
+    ], [("District", 9, "l"), ("Permits", 9, "r"), ("Valuation", 18, "r")])
+
+    # ---- permit type mix ----
+    table("PERMIT-TYPE MIX", [
+        (pt[:20], f"{c:,}", usd(s)) for pt, (c, s) in sorted(by_type.items(), key=lambda kv: kv[1][1], reverse=True)
+    ], [("Type", 20, "l"), ("Permits", 9, "r"), ("Valuation", 18, "r")])
+
+    # ---- biggest single projects ----
+    table(f"BIGGEST {args.top} SINGLE PROJECTS", [
+        (usd(v), (a or "?")[:30], c[:30]) for v, a, c in sorted(biggest, reverse=True)[:args.top]
+    ], [("Valuation", 16, "r"), ("Address", 30, "l"), ("Contractor", 30, "l")])
+    print()
+
+
+if __name__ == "__main__":
+    main()

← 4e1bc46 Add enrich_contractors.py: dedupe permits to unique contract  ·  back to La Permits School  ·  Add FINDINGS.md: written analysis (2020 vs 2023 dip, CD-11/L d130dd7 →