""" DC/MD/VA Unemployment — Static HTML Report Generator Run: python3 generate_report.py Output: dc_md_va_unemployment_report.html (open in any browser) """ 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 (same as dashboard script) # --------------------------------------------------------------------------- GEOS = ["DC", "MD", "VA", "DCMSA"] LABELS = { "DC": "District of Columbia", "MD": "Maryland", "VA": "Virginia", "DCMSA": "DC Metro MSA", } COLORS = { "DC": "#c0392b", "MD": "#2471a3", "VA": "#1e8449", "DCMSA": "#7d3c98", } SERIES = { "DC_rate": "LAUST110000000000003", "DC_unemployed": "LAUST110000000000004", "DC_employed": "LAUST110000000000005", "DC_laborforce": "LAUST110000000000006", "MD_rate": "LAUST240000000000003", "MD_unemployed": "LAUST240000000000004", "MD_employed": "LAUST240000000000005", "MD_laborforce": "LAUST240000000000006", "VA_rate": "LAUST510000000000003", "VA_unemployed": "LAUST510000000000004", "VA_employed": "LAUST510000000000005", "VA_laborforce": "LAUST510000000000006", "DCMSA_rate": "LAUMT114790000000003", "DCMSA_unemployed":"LAUMT114790000000004", "DCMSA_employed": "LAUMT114790000000005", "DCMSA_laborforce":"LAUMT114790000000006", } # --------------------------------------------------------------------------- # 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 def pct_chg(obs, window): try: return obs["calculations"]["pct_changes"].get(window) except (KeyError, TypeError): return None # --------------------------------------------------------------------------- # Prepare chart data — chronological, last 24 months, no gaps # --------------------------------------------------------------------------- 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): """Return observations sorted chronologically, monthly only, no nulls.""" 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_rate_chart_data(results, months=24): """Return labels list and per-geo value lists for the rate trend chart.""" # Build a merged label set from all four geos all_labels = {} for geo in GEOS: sid = SERIES[f"{geo}_rate"] for obs in sort_obs(results.get(sid, [])): key = (obs["year"], obs["period"]) m = MONTH_ORDER.get(obs["period"], 0) label = f"{MONTH_NAMES[m]} {obs['year']}" all_labels[key] = label 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}_rate"] obs_by_key = { (o["year"], o["period"]): float(o["value"]) for o in sort_obs(results.get(sid, [])) } values = [obs_by_key.get(k) for k in sorted_keys] datasets.append({ "label": LABELS[geo], "data": values, "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")) mom = chg(obs, "1") yoy = chg(obs, "12") suffix = "pp" if is_rate else "" mom_html = arrow(mom) yoy_html = arrow(yoy) row += f"""
{val}
MoM {mom_html}{' ' + suffix if suffix else ''}
YoY {yoy_html}{' ' + 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
""" # --------------------------------------------------------------------------- # Main 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 (more unemployment). ▼ Green = rate falling.

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 # 3 years covers 24+ months for the chart 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() labels, datasets = build_rate_chart_data(results, months=24) rate_chart_json = json.dumps({"labels": labels, "datasets": datasets}) html = HTML_TEMPLATE.format( report_date=report_date, cards=build_cards(results), summary_table=build_summary_table(results), rate_chart_json=rate_chart_json, 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()