Files
bls-data/generate_report.py
Dave Boyd 0242977abe Add Baltimore/Richmond MSAs and employment level chart
- Added Baltimore-Columbia-Towson MSA (CBSA 12580) and Richmond MSA (CBSA 40060)
- Now covers 6 geographies: DC, MD, VA states + DC/Baltimore/Richmond MSAs
- Added second Chart.js line chart showing employment levels over 24 months
- Cards grid updated to 3-column layout (2 rows of 3)
- Y-axis auto-formats to K/M for employment chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:43:25 -04:00

448 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 '<span class="neutral">—</span>'
try:
n = float(val)
if n > 0:
return f'<span class="up">▲ {n:+.1f}</span>'
if n < 0:
return f'<span class="down">▼ {n:.1f}</span>'
return f'<span class="neutral">— {n:.1f}</span>'
except (ValueError, TypeError):
return '<span class="neutral">—</span>'
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"""
<div class="card" style="border-top: 4px solid {color}">
<div class="card-geo">{LABELS[geo]}</div>
<div class="card-rate" style="color:{color}">{rate}</div>
<div class="card-period">{period}</div>
<div class="card-changes">
<div><span class="chg-label">MoM</span> {mom} pp</div>
<div><span class="chg-label">YoY</span> {yoy} pp</div>
</div>
</div>""")
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"<tr><td><strong>{label}</strong></td>"
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"""<td>
<div class="tval">{val}</div>
<div class="tchg"><span class="chg-label">MoM</span> {arrow(chg(obs,"1"))}{' ' + suffix if suffix else ''}</div>
<div class="tchg"><span class="chg-label">YoY</span> {arrow(chg(obs,"12"))}{' ' + suffix if suffix else ''}</div>
</td>"""
row += "</tr>"
rows.append(row)
geo_headers = "".join(f'<th style="color:{COLORS[g]}">{LABELS[g]}</th>' for g in GEOS)
return f"""
<table>
<thead><tr><th>Measure</th>{geo_headers}</tr></thead>
<tbody>{"".join(rows)}</tbody>
</table>"""
# ---------------------------------------------------------------------------
# HTML template
# ---------------------------------------------------------------------------
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DC / MD / VA Unemployment Report — {report_date}</title>
<script>{chartjs}</script>
<style>
*, *::before, *::after {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f4f6f9;
color: #222;
margin: 0;
padding: 24px;
}}
.header {{
background: #1a2744;
color: #fff;
padding: 24px 32px;
border-radius: 8px;
margin-bottom: 24px;
}}
.header h1 {{ margin: 0 0 4px; font-size: 1.5rem; }}
.header .subtitle {{ opacity: 0.7; font-size: 0.875rem; }}
.cards {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}}
.card {{
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.08);
}}
.card-geo {{ font-size: 0.8rem; font-weight: 600; text-transform: uppercase;
letter-spacing: .05em; color: #666; margin-bottom: 6px; }}
.card-rate {{ font-size: 2.2rem; font-weight: 700; line-height: 1; margin-bottom: 4px; }}
.card-period {{ font-size: 0.75rem; color: #888; margin-bottom: 12px; }}
.card-changes {{ display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; }}
.panel {{
background: #fff;
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 4px rgba(0,0,0,.08);
margin-bottom: 24px;
}}
.panel h2 {{ margin: 0 0 20px; font-size: 1rem; color: #333; }}
.chart-wrap {{ position: relative; height: 340px; }}
table {{ width: 100%; border-collapse: collapse; font-size: 0.875rem; }}
th {{ text-align: left; padding: 10px 12px; border-bottom: 2px solid #e5e7eb;
font-size: 0.75rem; text-transform: uppercase; letter-spacing: .04em; }}
td {{ padding: 12px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
tr:last-child td {{ border-bottom: none; }}
.tval {{ font-weight: 600; font-size: 1rem; }}
.tchg {{ font-size: 0.75rem; color: #555; margin-top: 2px; }}
.up {{ color: #c0392b; font-weight: 600; }}
.down {{ color: #1e8449; font-weight: 600; }}
.neutral {{ color: #888; }}
.chg-label {{ background: #eef0f3; border-radius: 3px; padding: 1px 4px;
font-size: 0.7rem; font-weight: 600; color: #555; margin-right: 2px; }}
.note {{ font-size: 0.75rem; color: #999; margin-top: 10px; }}
@media (max-width: 960px) {{
.cards {{ grid-template-columns: repeat(2, 1fr); }}
}}
@media (max-width: 600px) {{
.cards {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<div class="header">
<h1>DC / MD / VA Unemployment Report</h1>
<div class="subtitle">
Source: BLS Local Area Unemployment Statistics (LAUS) &nbsp;|&nbsp;
Not seasonally adjusted &nbsp;|&nbsp;
Generated {report_date}
</div>
</div>
<div class="cards">
{cards}
</div>
<div class="panel">
<h2>Unemployment Rate Trend — Last 24 Months</h2>
<div class="chart-wrap">
<canvas id="rateChart"></canvas>
</div>
<p class="note">▲ Red = rate rising &nbsp;|&nbsp; ▼ Green = rate falling &nbsp;|&nbsp; Not seasonally adjusted</p>
</div>
<div class="panel">
<h2>Employment Level Trend — Last 24 Months</h2>
<div class="chart-wrap">
<canvas id="emplChart"></canvas>
</div>
<p class="note">State totals (DC/MD/VA) are significantly larger than MSA figures. Hover to compare values.</p>
</div>
<div class="panel">
<h2>Current Month Summary</h2>
{summary_table}
<p class="note">MoM = month-over-month net change &nbsp;|&nbsp; YoY = year-over-year net change &nbsp;|&nbsp; pp = percentage points</p>
</div>
<script>
const rateData = {rate_chart_json};
const emplData = {empl_chart_json};
new Chart(document.getElementById("rateChart"), {{
type: "line",
data: {{ labels: rateData.labels, datasets: rateData.datasets }},
options: {{
responsive: true,
maintainAspectRatio: false,
interaction: {{ mode: "index", intersect: false }},
plugins: {{
legend: {{ position: "top", labels: {{ usePointStyle: true, padding: 16 }} }},
tooltip: {{
callbacks: {{
label: ctx => ` ${{ctx.dataset.label}}: ${{ctx.parsed.y !== null ? ctx.parsed.y.toFixed(1) + "%" : "N/A"}}`
}}
}}
}},
scales: {{
y: {{
title: {{ display: true, text: "Unemployment Rate (%)" }},
ticks: {{ callback: v => v + "%" }},
grid: {{ color: "#f0f0f0" }},
}},
x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 12 }} }}
}}
}}
}});
new Chart(document.getElementById("emplChart"), {{
type: "line",
data: {{ labels: emplData.labels, datasets: emplData.datasets }},
options: {{
responsive: true,
maintainAspectRatio: false,
interaction: {{ mode: "index", intersect: false }},
plugins: {{
legend: {{ position: "top", labels: {{ usePointStyle: true, padding: 16 }} }},
tooltip: {{
callbacks: {{
label: ctx => ` ${{ctx.dataset.label}}: ${{ctx.parsed.y !== null ? ctx.parsed.y.toLocaleString() : "N/A"}}`
}}
}}
}},
scales: {{
y: {{
title: {{ display: true, text: "Employed Persons" }},
ticks: {{ callback: v => (v >= 1000000 ? (v/1000000).toFixed(1)+"M" : (v/1000).toFixed(0)+"K") }},
grid: {{ color: "#f0f0f0" }},
}},
x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 12 }} }}
}}
}}
}});
</script>
</body>
</html>
"""
# ---------------------------------------------------------------------------
# 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()