Files
bls-data/bls_client/series.py
Dave Boyd 4fe734381e Fix broken series-ID builders; env-var config; project README
Repair every dead series-ID encoding (library now 82/82 query helpers
return live data, verified against the BLS API):

- JOLTS: 21-char format (was 18) — add state/area/sizeclass fields
- OES: national area code 0000000 (was invalid 0000400)
- ECI: correct owner/component/estimate encoding, default unadjusted (CIU)
- Productivity: 4-digit sector + 4-digit measure codes (was 2+3)
- QCEW: 13-char timeseries-API form (ENUUS00010510 / ENU{fips}00010{own}10)
- PPI: repoint finished-goods -> final demand (WPUFD4); keep alias
- ECEC: drop fabricated health-insurance/retirement helpers; add total benefits
- wages SOC: software developers 151132 -> 151252 (2018 SOC)

Tooling/docs:
- config reads BLS_API_KEY env var (takes precedence; config.py gitignored)
- add requirements.txt
- rewrite README as project front door + coverage table + limitations
- correct JOLTS/OES tables in series_id_formats.md, USAGE.md, dataset explorer

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

377 lines
13 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}"
# ---------------------------------------------------------------------------
# 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}"