""" DC/MD/VA Unemployment — Static HTML Report Generator Run: python3 generate_report.py Output: dc_md_va_unemployment_report.html (open in any browser, no internet required) """ import requests import json import os from datetime import datetime from config import BLS_API_KEY, BLS_API_BASE SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # --------------------------------------------------------------------------- # Series config # --------------------------------------------------------------------------- GEOS = ["DC", "MD", "VA", "DCMSA", "BAL", "RIC"] LABELS = { "DC": "District of Columbia", "MD": "Maryland", "VA": "Virginia", "DCMSA": "DC Metro MSA", "BAL": "Baltimore MSA", "RIC": "Richmond MSA", } COLORS = { "DC": "#c0392b", "MD": "#2471a3", "VA": "#1e8449", "DCMSA": "#7d3c98", "BAL": "#d68910", "RIC": "#117a65", } SERIES = { # District of Columbia (state FIPS 11) "DC_rate": "LAUST110000000000003", "DC_unemployed": "LAUST110000000000004", "DC_employed": "LAUST110000000000005", "DC_laborforce": "LAUST110000000000006", # Maryland (state FIPS 24) "MD_rate": "LAUST240000000000003", "MD_unemployed": "LAUST240000000000004", "MD_employed": "LAUST240000000000005", "MD_laborforce": "LAUST240000000000006", # Virginia (state FIPS 51) "VA_rate": "LAUST510000000000003", "VA_unemployed": "LAUST510000000000004", "VA_employed": "LAUST510000000000005", "VA_laborforce": "LAUST510000000000006", # DC-Arlington-Alexandria MSA (state 11, CBSA 47900) "DCMSA_rate": "LAUMT114790000000003", "DCMSA_unemployed":"LAUMT114790000000004", "DCMSA_employed": "LAUMT114790000000005", "DCMSA_laborforce":"LAUMT114790000000006", # Baltimore-Columbia-Towson MSA (state 24, CBSA 12580) "BAL_rate": "LAUMT241258000000003", "BAL_unemployed": "LAUMT241258000000004", "BAL_employed": "LAUMT241258000000005", "BAL_laborforce": "LAUMT241258000000006", # Richmond MSA (state 51, CBSA 40060) "RIC_rate": "LAUMT514006000000003", "RIC_unemployed": "LAUMT514006000000004", "RIC_employed": "LAUMT514006000000005", "RIC_laborforce": "LAUMT514006000000006", } # --------------------------------------------------------------------------- # Data fetching # --------------------------------------------------------------------------- def fetch(series_ids, start_year, end_year): payload = { "seriesid": series_ids, "startyear": str(start_year), "endyear": str(end_year), "registrationkey": BLS_API_KEY, "catalog": True, "calculations": True, "annualaverage": False, } r = requests.post(f"{BLS_API_BASE}/timeseries/data/", json=payload, timeout=30) r.raise_for_status() body = r.json() if body["status"] != "REQUEST_SUCCEEDED": raise RuntimeError(f"BLS API error: {body['message']}") return {s["seriesID"]: s["data"] for s in body["Results"]["series"]} def latest(data): for obs in data: if obs["value"] != "-": return obs return {} def chg(obs, window): try: return obs["calculations"]["net_changes"].get(window) except (KeyError, TypeError): return None # --------------------------------------------------------------------------- # Chart data builders # --------------------------------------------------------------------------- MONTH_ORDER = {f"M{i:02d}": i for i in range(1, 13)} MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] def sort_obs(data): monthly = [o for o in data if o["period"].startswith("M") and o["value"] != "-"] return sorted(monthly, key=lambda o: (o["year"], MONTH_ORDER.get(o["period"], 0))) def build_chart_data(results, measure, months=24): """Build Chart.js labels + datasets for any measure (rate, employed, etc.).""" all_labels = {} for geo in GEOS: sid = SERIES[f"{geo}_{measure}"] for obs in sort_obs(results.get(sid, [])): key = (obs["year"], obs["period"]) m = MONTH_ORDER.get(obs["period"], 0) all_labels[key] = f"{MONTH_NAMES[m]} {obs['year']}" sorted_keys = sorted(all_labels.keys())[-months:] labels = [all_labels[k] for k in sorted_keys] datasets = [] for geo in GEOS: sid = SERIES[f"{geo}_{measure}"] obs_by_key = { (o["year"], o["period"]): float(o["value"]) for o in sort_obs(results.get(sid, [])) } datasets.append({ "label": LABELS[geo], "data": [obs_by_key.get(k) for k in sorted_keys], "borderColor": COLORS[geo], "backgroundColor": COLORS[geo] + "22", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": False, }) return labels, datasets # --------------------------------------------------------------------------- # HTML helpers # --------------------------------------------------------------------------- def arrow(val): if val is None: return '' try: n = float(val) if n > 0: return f'▲ {n:+.1f}' if n < 0: return f'▼ {n:.1f}' return f'— {n:.1f}' except (ValueError, TypeError): return '' def fmt_rate(val): try: return f"{float(val):.1f}%" except (ValueError, TypeError): return "—" def fmt_num(val): try: return f"{float(val):,.0f}" except (ValueError, TypeError): return "—" def build_cards(results): cards_html = [] for geo in GEOS: sid = SERIES[f"{geo}_rate"] obs = latest(results.get(sid, [])) rate = fmt_rate(obs.get("value")) mom = arrow(chg(obs, "1")) yoy = arrow(chg(obs, "12")) period = f"{obs.get('periodName', '')} {obs.get('year', '')}" color = COLORS[geo] cards_html.append(f"""
{LABELS[geo]}
{rate}
{period}
MoM {mom} pp
YoY {yoy} pp
""") return "\n".join(cards_html) def build_summary_table(results): measures = [ ("rate", "Unemp Rate", True), ("unemployed", "Unemployed", False), ("employed", "Employed", False), ("laborforce", "Labor Force", False), ] rows = [] for measure, label, is_rate in measures: row = f"{label}" for geo in GEOS: sid = SERIES[f"{geo}_{measure}"] obs = latest(results.get(sid, [])) val = fmt_rate(obs.get("value")) if is_rate else fmt_num(obs.get("value")) suffix = "pp" if is_rate else "" row += f"""
{val}
MoM {arrow(chg(obs,"1"))}{' ' + suffix if suffix else ''}
YoY {arrow(chg(obs,"12"))}{' ' + suffix if suffix else ''}
""" row += "" rows.append(row) geo_headers = "".join(f'{LABELS[g]}' for g in GEOS) return f""" {geo_headers}{"".join(rows)}
Measure
""" # --------------------------------------------------------------------------- # HTML template # --------------------------------------------------------------------------- HTML_TEMPLATE = """ DC / MD / VA Unemployment Report — {report_date}

DC / MD / VA Unemployment Report

Source: BLS Local Area Unemployment Statistics (LAUS)  |  Not seasonally adjusted  |  Generated {report_date}
{cards}

Unemployment Rate Trend — Last 24 Months

▲ Red = rate rising  |  ▼ Green = rate falling  |  Not seasonally adjusted

Employment Level Trend — Last 24 Months

State totals (DC/MD/VA) are significantly larger than MSA figures. Hover to compare values.

Current Month Summary

{summary_table}

MoM = month-over-month net change  |  YoY = year-over-year net change  |  pp = percentage points

""" # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main(): current_year = datetime.now().year start_year = current_year - 2 print(f"Fetching {len(SERIES)} LAUS series ({start_year}–{current_year})...") results = fetch(list(SERIES.values()), start_year, current_year) report_date = datetime.now().strftime("%B %d, %Y") chartjs_path = os.path.join(SCRIPT_DIR, "chart.umd.min.js") with open(chartjs_path) as f: chartjs = f.read() rate_labels, rate_datasets = build_chart_data(results, "rate", months=24) empl_labels, empl_datasets = build_chart_data(results, "employed", months=24) html = HTML_TEMPLATE.format( report_date=report_date, cards=build_cards(results), summary_table=build_summary_table(results), rate_chart_json=json.dumps({"labels": rate_labels, "datasets": rate_datasets}), empl_chart_json=json.dumps({"labels": empl_labels, "datasets": empl_datasets}), chartjs=chartjs, ) out = "dc_md_va_unemployment_report.html" with open(out, "w") as f: f.write(html) print(f"Report written → {out}") if __name__ == "__main__": main()