Files
bls-data/bls_client/series.py
Dave Boyd 9e2c55c568 Add QCEW CSV module, ECEC breakdown, response caching + typed errors
Item 1 — QCEW county/industry detail:
- new bls_client/qcew.py: thin client over the QCEW Open Data CSV service
  (area/industry/size slices; no API key, no quota), with filter_rows helper
- covers the county/industry detail the timeseries API can't reach

Item 2 — ECEC benefit breakdown:
- new series.ecec() builder from the authoritative cm.estimate decode table
- real helpers: wages, total benefits, paid leave, supplemental pay,
  health insurance, retirement & savings, legally required + ecec_dashboard()
- FIX: ecec_total_benefits was CMU1036... (education/health industries only);
  correct all-civilian total benefits is CMU1030000000000D

Item 3 — caching + typed errors:
- bls_client/cache.py FileCache (on-disk, TTL); BLSClient(cache=True),
  queries_used counter (cache hits don't spend quota)
- bls_client/errors.py: BLSError / BLSQuotaError / BLSRequestError; quota
  exhaustion now raises a clear BLSQuotaError instead of a generic RuntimeError

Tests: 119 offline (+24) incl. ECEC goldens, QCEW fixture parse, cache/quota
behavior; live smoke extended to ECEC + QCEW. Helper sweep: 96/96 live.
README: caching/QCEW usage, updated coverage + limitations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:38:12 -04:00

426 lines
15 KiB
Python

"""
Series ID builders for major BLS surveys.
These functions construct the coded series IDs used by the BLS API.
Pass the returned string directly to BLSClient.fetch().
Example:
from bls_client.series import laus_state, ces_national
client.fetch([laus_state(11, "rate"), ces_national("00000000", "01")], 2020, 2025)
"""
# ---------------------------------------------------------------------------
# LAUS — Local Area Unemployment Statistics
# Series format: LA[adj][area_type+area_code 15 chars][measure 2 chars]
# ---------------------------------------------------------------------------
_LAUS_MEASURE = {
"rate": "03",
"unemployed": "04",
"employed": "05",
"laborforce": "06",
"emp_pop": "07",
"lfpr": "08",
"pop": "09",
}
def laus_state(state_fips: int, measure: str = "rate", seasonal: bool = False) -> str:
"""
LAUS series for a state.
Args:
state_fips: 2-digit state FIPS (e.g. 11=DC, 24=MD, 51=VA)
measure: "rate" | "unemployed" | "employed" | "laborforce"
seasonal: True for seasonally adjusted
Examples:
laus_state(11, "rate") → "LAUST110000000000003"
laus_state(24, "employed") → "LAUST240000000000005"
"""
adj = "S" if seasonal else "U"
m = _LAUS_MEASURE.get(measure, measure)
# Area code = ST(2) + state_fips(2) + 11 zeros = 15 chars → total series = 20
return f"LA{adj}ST{state_fips:02d}{'0'*11}{m}"
def laus_county(state_fips: int, county_fips: int, measure: str = "rate", seasonal: bool = False) -> str:
"""
LAUS series for a county.
Args:
state_fips: 2-digit state FIPS
county_fips: 3-digit county FIPS
measure: "rate" | "unemployed" | "employed" | "laborforce"
seasonal: True for seasonally adjusted
Examples:
laus_county(11, 1, "rate") → "LAUCN110010000000003"
"""
adj = "S" if seasonal else "U"
m = _LAUS_MEASURE.get(measure, measure)
# Area code = CN(2) + state_fips(2) + county_fips(3) + 8 zeros = 15 chars → total = 20
return f"LA{adj}CN{state_fips:02d}{county_fips:03d}{'0'*8}{m}"
def laus_msa(state_fips: int, cbsa_code: int, measure: str = "rate") -> str:
"""
LAUS series for a Metropolitan Statistical Area.
The state_fips should be the primary state in the MSA.
Args:
state_fips: 2-digit FIPS of the primary state
cbsa_code: 5-digit CBSA code
measure: "rate" | "unemployed" | "employed" | "laborforce"
Examples:
laus_msa(11, 47900, "rate") → DC-Arlington MSA unemployment rate
laus_msa(24, 12580, "rate") → Baltimore MSA unemployment rate
laus_msa(51, 40060, "rate") → Richmond MSA unemployment rate
"""
m = _LAUS_MEASURE.get(measure, measure)
return f"LAUMT{state_fips:02d}{cbsa_code:05d}000000{m}"
# ---------------------------------------------------------------------------
# CES — Current Employment Statistics (National)
# Series format: CE[adj][industry 8 chars][data_type 2 chars]
# ---------------------------------------------------------------------------
_CES_SUPERSECTOR = {
"total_nonfarm": "00000000",
"total_private": "05000000",
"mining_logging": "10000000",
"construction": "20000000",
"manufacturing": "30000000",
"durable": "31000000",
"nondurable": "32000000",
"trade_trans_util":"40000000",
"wholesale": "41000000",
"retail": "42000000",
"information": "50000000",
"financial": "55000000",
"professional": "60000000",
"education_health":"65000000",
"leisure": "70000000",
"other_services": "80000000",
"government": "90000000",
}
_CES_DATATYPE = {
"employees": "01",
"avg_weekly_hours": "02",
"avg_hourly_earn": "03",
"prod_employees": "06",
"women_employees": "10",
"avg_weekly_earn": "11",
}
def ces_national(
industry: str = "total_nonfarm",
data_type: str = "employees",
seasonal: bool = True,
) -> str:
"""
CES national employment series.
Args:
industry: Supersector name (see _CES_SUPERSECTOR) or 8-digit code string
data_type: Measure name (see _CES_DATATYPE) or 2-digit code string
seasonal: True for seasonally adjusted
Examples:
ces_national() → "CES0000000001"
ces_national("manufacturing", "employees") → "CES3000000001"
ces_national("retail", "avg_hourly_earn") → "CES4200000003"
"""
adj = "S" if seasonal else "U"
ind = _CES_SUPERSECTOR.get(industry, industry)
dtype = _CES_DATATYPE.get(data_type, data_type)
return f"CE{adj}{ind}{dtype}"
def ces_state(
state_fips: int,
industry: str = "total_nonfarm",
data_type: str = "employees",
seasonal: bool = True,
area_code: str = "00000",
) -> str:
"""
State or metro employment series (SM prefix).
Args:
state_fips: 2-digit state FIPS
industry: Supersector name or 8-digit code
data_type: Measure name or 2-digit code
seasonal: True for SA
area_code: 5-digit metro area code; "00000" = statewide
Examples:
ces_state(11) → "SMS110000000000001" (DC total nonfarm)
ces_state(24, "retail") → "SMS240000042000001" (MD retail)
"""
adj = "S" if seasonal else "U"
ind = _CES_SUPERSECTOR.get(industry, industry)
dtype = _CES_DATATYPE.get(data_type, data_type)
return f"SM{adj}{state_fips:02d}{area_code}{ind}{dtype}"
# ---------------------------------------------------------------------------
# CPI — Consumer Price Index
# Series format: CU[adj][periodicity][area 4 chars][item 6 chars]
# ---------------------------------------------------------------------------
_CPI_ITEM = {
"all_items": "SA0",
"core": "SA0L1E", # all items less food & energy
"food": "SAF",
"food_at_home": "SAF1",
"energy": "SA0E",
"shelter": "SAH1",
"apparel": "SAA",
"transportation":"SAT",
"medical": "SAM",
"education": "SAE",
"recreation": "SAR",
"gasoline": "SS47014",
"electricity": "SEHF01",
"new_vehicles": "SETA01",
}
def cpi(
item: str = "all_items",
area: str = "0000",
seasonal: bool = False,
series: str = "U",
) -> str:
"""
CPI series ID.
Args:
item: Item code name (see _CPI_ITEM) or raw item code
area: 4-char area code ("0000" = US city average)
seasonal: True for seasonally adjusted
series: "U" = CPI-U, "W" = CPI-W, "S" = Chained CPI-U
Examples:
cpi() → "CUUR0000SA0"
cpi("core", seasonal=True) → "CUSR0000SA0L1E"
cpi("gasoline", seasonal=True) → "CUSR0000SS47014"
cpi("all_items", series="W") → "CWUR0000SA0"
"""
prefix_map = {"U": "CU", "W": "CW", "S": "SU"}
prefix = prefix_map.get(series, "CU")
adj = "S" if seasonal else "U"
itm = _CPI_ITEM.get(item, item)
return f"{prefix}{adj}R{area}{itm}"
# ---------------------------------------------------------------------------
# PPI — Producer Price Index (commodity-based)
# ---------------------------------------------------------------------------
def ppi_commodity(commodity_code: str = "00000000") -> str:
"""
PPI commodity series.
Args:
commodity_code: 8-char commodity code ("00000000" = all commodities)
Examples:
ppi_commodity() → "WPU00000000"
ppi_commodity("1012") → "WPU1012" (iron & steel scrap)
"""
return f"WPU{commodity_code}"
# ---------------------------------------------------------------------------
# OES — Occupational Employment & Wage Statistics
# ---------------------------------------------------------------------------
_OES_DATATYPE = {
"employment": "01",
"hourly_mean": "03",
"annual_mean": "04",
"hourly_10pct": "06",
"hourly_25pct": "07",
"hourly_median":"08",
"hourly_75pct": "09",
"hourly_90pct": "10",
"annual_median":"13",
}
def oes_national(
occupation_code: str = "000000",
industry_code: str = "000000",
data_type: str = "employment",
) -> str:
"""
OES national series.
Args:
occupation_code: 6-digit SOC code or "000000" for all occupations
industry_code: 6-digit NAICS or "000000" for cross-industry
data_type: Measure name (see _OES_DATATYPE) or 2-digit code
Examples:
oes_national() → all occupations, employment
oes_national("151252", data_type="annual_median") → software devs median wage
"""
dtype = _OES_DATATYPE.get(data_type, data_type)
# OE + U + N(area_type) + 0000000(national area, 7 chars) + industry(6) + occupation(6) + dtype(2) = 25
return f"OEUN0000000{industry_code:0<6}{occupation_code:0<6}{dtype}"
# ---------------------------------------------------------------------------
# JOLTS — Job Openings & Labor Turnover
# ---------------------------------------------------------------------------
_JOLTS_ELEMENT = {
"job_openings": "JO",
"hires": "HI",
"quits": "QU",
"layoffs": "LD",
"total_separations": "TS",
}
def jolts(
element: str = "job_openings",
rate_level: str = "L",
industry: str = "000000",
ownership: str = "00",
seasonal: bool = True,
) -> str:
"""
JOLTS series.
Args:
element: "job_openings" | "hires" | "quits" | "layoffs" | "total_separations"
rate_level: "L" = level (thousands) | "R" = rate
industry: 6-digit industry code; "000000" = total nonfarm
ownership: "00" = total | "10" = private | "20" = government
seasonal: True for SA
Examples:
jolts() → "JTS000000000000000JOL" (openings level)
jolts("quits", rate_level="R") → "JTS000000000000000QUR" (quits rate)
jolts("hires", seasonal=False) → "JTU000000000000000HIL" (hires level, NSA)
"""
adj = "S" if seasonal else "U"
elem = _JOLTS_ELEMENT.get(element, element)
# Format: JT + adj + industry(6) + state(2) + area(5) + sizeclass(2) + elem(2) + rate_level(1) = 21 chars
# state/area/sizeclass default to national / all (the "ownership" arg is reserved; not part of the ID)
ind = industry[:6].ljust(6, "0")
return f"JT{adj}{ind}{'0'*9}{elem}{rate_level}"
# ---------------------------------------------------------------------------
# ECI — Employment Cost Index
# ---------------------------------------------------------------------------
def eci(
owner: str = "10", # 10=civilian, 20=private, 30=state/local gov
component: str = "10", # 10=total compensation, 20=wages & salaries, 30=benefits
estimate: str = "A", # A=12-month % change, Q=3-month % change, I=index
seasonal: bool = False, # the 12-month % change estimate is published unadjusted (CIU)
) -> str:
"""
ECI series for employment cost changes.
Format: CI + adj + owner(2) + component(2) + 9 zeros + estimate(1) = 17 chars.
Note: the 12-month % change estimate ("A") is published unadjusted only;
seasonally adjusted ECI ("CIS") publishes the 3-month change ("Q") and index ("I").
Examples:
eci() → "CIU1010000000000A" (civilian, total compensation, 12-mo % chg)
eci(component="20") → "CIU1020000000000A" (civilian, wages & salaries)
eci(component="30") → "CIU1030000000000A" (civilian, benefits)
eci(owner="20") → "CIU2010000000000A" (private, total compensation)
"""
adj = "S" if seasonal else "U"
return f"CI{adj}{owner}{component}{'0'*9}{estimate}"
# ---------------------------------------------------------------------------
# ECEC — Employer Costs for Employee Compensation
# Component ("estimate") codes from the BLS cm.estimate decode table.
# ---------------------------------------------------------------------------
_ECEC_COMPONENT = {
"total_compensation": "01",
"wages_and_salaries": "02",
"total_benefits": "03",
"paid_leave": "04",
"supplemental_pay": "09",
"insurance": "13",
"health_insurance": "15",
"retirement_and_savings": "18",
"legally_required": "21",
"workers_compensation": "27",
}
_ECEC_OWNER = {
"civilian": "1",
"private": "2",
"state_local_gov": "3",
}
def ecec(
component: str = "total_compensation",
owner: str = "civilian",
datatype: str = "D", # D = cost per hour worked (dollars)
) -> str:
"""
ECEC series — employer cost per hour worked, all industries / all workers.
Format: CM + U + owner(1) + component(2) + 10 zeros + datatype(1) = 17 chars.
Args:
component: key in _ECEC_COMPONENT (e.g. "health_insurance") or a raw 2-digit code
owner: "civilian" | "private" | "state_local_gov" (or raw "1"/"2"/"3")
datatype: "D" = cost per hour in dollars (default); "P" = percent of total comp
Examples:
ecec() → "CMU1010000000000D" (civilian total compensation)
ecec("health_insurance") → "CMU1150000000000D"
ecec("retirement_and_savings") → "CMU1180000000000D"
ecec("total_benefits", "private") → "CMU2030000000000D"
"""
own = _ECEC_OWNER.get(owner, owner)
code = _ECEC_COMPONENT.get(component, component)
return f"CMU{own}{code}{'0'*10}{datatype}"
# ---------------------------------------------------------------------------
# Productivity (Major Sector)
# ---------------------------------------------------------------------------
_PR_SECTOR = {
"business": "8400",
"nonfarm_business": "8500",
"manufacturing": "3000",
"durable_mfg": "3100",
"nondurable_mfg": "3200",
}
_PR_MEASURE = {
"output_per_hour": "6092",
"output": "6042",
"hours": "6032",
"compensation": "6062", # hourly compensation
"real_comp_per_hr": "6152", # real hourly compensation
"unit_labor_cost": "6112",
}
def productivity(
sector: str = "nonfarm_business",
measure: str = "output_per_hour",
seasonal: bool = True,
) -> str:
"""
Major Sector Productivity series.
Format: PR + adj + sector(4) + measure(4) = 11 chars. Published seasonally adjusted (PRS).
Examples:
productivity() → "PRS85006092" (nonfarm business output per hour)
productivity("manufacturing", "unit_labor_cost") → "PRS30006112"
"""
adj = "S" if seasonal else "U"
sec = _PR_SECTOR.get(sector, sector)
mea = _PR_MEASURE.get(measure, measure)
return f"PR{adj}{sec}{mea}"