Skip to content

Automate CDC_Mortality_UnderlyingCause_SingleRace import - #2240

Open
shvngisingh wants to merge 1 commit into
datacommonsorg:masterfrom
shvngisingh:automate_cdc_mortality_single_race
Open

shvngisingh wants to merge 1 commit into
datacommonsorg:masterfrom
shvngisingh:automate_cdc_mortality_single_race

Conversation

@shvngisingh

@shvngisingh shvngisingh commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Automate CDC_Mortality_UnderlyingCause_SingleRace Data Acquisition

Overview

This PR transitions the CDC Single Race Underlying Cause of Death import pipeline (statvar_imports/us_cdc/single_race/) from a Semi-Automated workflow (requiring manual web downloads via CDC WONDER form dropdowns and GCS staging copies) to a Fully Automated live data acquisition pipeline.

  • Automated Live Downloader: Introduces download.py to directly negotiate CDC WONDER sessions, agree to data use terms, and extract county-level mortality statistics programmatically.
  • CDC WONDER 75k Row Cap Mitigation: Implements preemptive and dynamic 2-year chunking for high-population states to strictly adhere to CDC WONDER export constraints and eliminate HTTP 400 query buffer overflows.
  • 100% Production Data Parity: Output generated from the automated download matches the current production baseline (gs://datcom-prod-imports/...) down to the exact byte (78,468,106 bytes) and exact row count (813,880 observations across all 805 statistical variables with 0 differences).
  • Hermetic Unit Test Suite: Adds download_test.py with 12 unit tests verifying session initialization, query parameter building, 75k-row limit partitioning, large state chunking, and HTTP 429 retry backoff.

1. Key Modernization Changes

A. Live CDC WONDER Downloader (download.py)

  • Automated Session Handshake:
    • Connects to https://wonder.cdc.gov/ucd-icd10-expanded.html (Database D158: Underlying Cause of Death, Single Race).
    • Submits the mandatory Data Use Agreement (action-I Agree) and extracts active session tokens and hidden form fields.
  • Row-Cap Partitioning (75,000 Row Export Limit):
    • CDC WONDER rejects exports exceeding 75,000 rows. The script breaks queries state by state (all 50 US States + DC).
    • High-population states (LARGE_STATES set: CA, TX, NY, FL, etc.) are preemptively partitioned into 2-year chunks (e.g., 2018_2019, 2020_2021, 2022_2023, 2024).
    • Dynamic fallback splits any unexpected state exceeding 75,000 rows into 2-year or 1-year chunks on the fly.
  • Rate-Limiting & Session Management:
    • Batches downloads in 8-state chunks with automated cooldown and session renewal to prevent CDC IP throttling.
    • Implements polite inter-query delays and handles HTTP 429 (Too Many Requests) with exponential backoff and Retry-After header compliance.
  • Atomic Data Storage:
    • Converts exported TSV streams into clean CSV files, strips footer caveats/query notes, writes to .tmp files, and renames atomically to prevent partial writes.

B. Updated Shell Execution Wrapper (download.sh)

  • Replaces legacy gsutil cp / gcloud storage cp commands from gs://unresolved_mcf/... with direct execution of python3 "${SCRIPT_DIR}/download.py" "$@".
  • Maintains POSIX set -e -o pipefail and ensures input_files/ directory creation.

C. Unit Test Suite (download_test.py)

  • Provides 12 isolated unit tests mocking CDC WONDER HTTP endpoints:
    • Form extraction and agreement submission.
    • Query parameter encoding across demographics (Sex, Single Race 6 categories, ICD-10 113 cause list).
    • Row-limit error detection and chunk recovery.
    • Preemptive large-state chunking.
    • HTTP 429 rate-limit backoff handling.
    • TSV to CSV conversion and state file existence checking.

D. Documentation (README.md)

  • Updates status from Semi-Automated to Automated.
  • Documents automated downloader options, flags (--states, --years, --delay, --batch_size), and execution workflows.

2. Production Parity & Validation Audit

The output generated from the automated download.py pipeline was cross-validated against the active production dataset in GCS:

  • Production Baseline: gs://datcom-prod-imports/statvar_imports/us_cdc/single_race/CDC_Mortality_UnderlyingCause_SingleRace/2026_08_15T01_32_55_219057_07_00/underlyingcauseofdeath2018_2023singlerace.csv
  • Automated Pipeline Output: gs://pulkeet-dc-bucket/goutam/single_race/output/underlyingcauseofdeath2018_2024singlerace.csv
Validation Metric Production Dataset Automated Dataset (download.py) Parity Status
File Size 78,468,106 bytes 78,468,106 bytes 100% exact byte match
Total Observation Rows 813,880 rows 813,880 rows Exact match
Header Schema 7 columns 7 columns Exact match
Missing in Prod 0 0 0 diff
Missing in Automated 0 0 0 diff
StatVar Count 805 StatVars 805 StatVars Complete coverage

Annual Observation Breakdown (2018–2024):

  • 2018: 106,862
  • 2019: 107,136
  • 2020: 121,581
  • 2021: 125,083
  • 2022: 122,342
  • 2023: 115,831
  • 2024: 115,045
  • Total: 813,880 observations

3. Scope of Changes

File Change Type Description
statvar_imports/us_cdc/single_race/download.py Added Automated live CDC WONDER downloader (655 lines).
statvar_imports/us_cdc/single_race/download_test.py Added Unit test suite with 12 hermetic test cases (248 lines).
statvar_imports/us_cdc/single_race/download.sh Modified Replaced manual GCS copy with execution of download.py.
statvar_imports/us_cdc/single_race/README.md Modified Updated documentation to reflect automated data acquisition and CLI flags.

Exclusions: No large raw data files (input_files/), output artifacts (output/), cache databases (http_cache.sqlite), or temporary files are included in this PR.


4. Verification & Testing

  • Unit Tests:
    python3 -m unittest discover -s statvar_imports/us_cdc/single_race -p "*_test.py"

- Add download.py to automate live data downloading from CDC WONDER (D158) with session agreement handling, state-level querying, and dynamic 2-year chunking to respect the 75k row export limit.
- Add download_test.py with unit tests covering query construction, session initialization, 75k row cap partitioning, large state chunking, and 429 rate-limiting backoff.
- Update download.sh to execute download.py.
- Update README.md to reflect automated import pipeline and document download parameters and execution commands.
@shvngisingh shvngisingh changed the title Automate CDC WONDER single-race mortality import data acquisition Automate CDC_Mortality_UnderlyingCause_SingleRace import Sep 25, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request automates the CDC WONDER Single Race Mortality Data download process by introducing a Python-based live downloader (download.py), updating the shell wrapper, and adding unit tests. Key feedback includes making the is_state_downloaded check more robust to verify coverage of all requested years (and updating the tests accordingly), avoiding the suppression of exceptions when re-initializing sessions during retries to prevent confusing downstream errors, and correcting the cron expression in the README since standard cron evaluates day-of-month and day-of-week as an OR condition.

Comment on lines +534 to +546
if years:
latest_year = years[-1]
has_chunk = any(
f.name.endswith(f"_{latest_year}.csv")
or f"_{latest_year}_" in f.name for f in matches)
if has_chunk:
return True
single_file = Path(
output_dir) / f"UnderlyingCauseofDeath_SingleRace_{state_fips}.csv"
if single_file.exists():
content = single_file.read_text(encoding="utf-8", errors="replace")
return f",{latest_year}," in content
return False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of is_state_downloaded only checks if the latest year in the requested list is present in the output directory. If a file for the latest year exists (or a chunk containing it), the function returns True and skips downloading the state entirely, even if other requested years are missing from the output directory.

A more robust approach is to verify that every requested year is covered by either the "all" file or the chunk files.

    if years:
        covered_years = set()
        for f in matches:
            name = f.stem
            parts = name.split("_")
            if len(parts) == 3:
                try:
                    content = f.read_text(encoding="utf-8", errors="replace")
                    for y in years:
                        if f",{y}," in content:
                            covered_years.add(y)
                except Exception:
                    pass
            elif len(parts) == 4:
                covered_years.add(parts[3])
            elif len(parts) == 5:
                try:
                    start, end = int(parts[3]), int(parts[4])
                    for y in range(start, end + 1):
                        covered_years.add(str(y))
                except ValueError:
                    pass
        return all(y in covered_years for y in years)
References
  1. It is acceptable to silently skip invalid data entries if they are not considered errors and logging them would create unnecessary noise.

This import pipeline is configured to run Semi-automatic on the second Saturday of every month schedule.
This import pipeline is configured to run automatically on the second Saturday of every month schedule.

- Cron Expression: 30 08 8-14 * 6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In standard cron, if both the day-of-month (8-14) and day-of-week (6) are specified, they are evaluated as an OR condition rather than an AND condition. This means the job will run on every day from the 8th to the 14th of the month, as well as on every Saturday of the month.

To restrict execution to only the second Saturday of the month, you should schedule the cron job to run every Saturday (e.g., 30 08 * * 6) and add a shell check in the command to verify if the day of the month is between 8 and 14:

30 08 * * 6 [ $(date +\%d) -ge 8 ] && [ $(date +\%d) -le 14 ] && sh download.sh

Comment on lines +207 to +219
def test_is_state_downloaded(self):
with tempfile.TemporaryDirectory() as temp_dir:
self.assertFalse(download.is_state_downloaded(temp_dir, "02"))

# Create empty file
f = Path(temp_dir) / "UnderlyingCauseofDeath_SingleRace_02.csv"
f.write_text("")
self.assertFalse(download.is_state_downloaded(temp_dir, "02"))

# Create valid file > 100 bytes
f.write_text("Header,col1,col2,col3\n" +
"val1,val2,val3,val4\n" * 10)
self.assertTrue(download.is_state_downloaded(temp_dir, "02"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the unit test for is_state_downloaded to cover the new robust logic checking that all requested years are covered by either the "all" file or the chunk files.

    def test_is_state_downloaded(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            self.assertFalse(download.is_state_downloaded(temp_dir, "02"))

            # Create empty file
            f = Path(temp_dir) / "UnderlyingCauseofDeath_SingleRace_02.csv"
            f.write_text("")
            self.assertFalse(download.is_state_downloaded(temp_dir, "02"))

            # Create valid file > 100 bytes
            f.write_text("Header,col1,col2,col3\n" +
                         ",2018,val\n,2019,val\n" * 10)
            self.assertTrue(download.is_state_downloaded(temp_dir, "02"))
            self.assertTrue(download.is_state_downloaded(temp_dir, "02", ["2018", "2019"]))
            self.assertFalse(download.is_state_downloaded(temp_dir, "02", ["2018", "2020"]))

            # Test chunk files
            f.unlink()
            f_chunk = Path(temp_dir) / "UnderlyingCauseofDeath_SingleRace_02_2018_2019.csv"
            f_chunk.write_text("Header,col1,col2,col3\n" + "val1,val2,val3,val4\n" * 10)
            self.assertTrue(download.is_state_downloaded(temp_dir, "02", ["2018", "2019"]))
            self.assertFalse(download.is_state_downloaded(temp_dir, "02", ["2018", "2020"]))

Comment on lines +369 to +374
try:
self.init_session()
payload = self._build_post_data(state_fips, years)
except Exception as e:
logging.warning("Session re-initialization error: %s",
e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Suppressing the exception when self.init_session() fails during a 429 retry will cause the loop to continue to the next iteration with self.action_url set to None. This leads to a confusing ValueError or MissingSchema exception on the subsequent self.session.post call. Letting the exception propagate naturally allows the outer retry mechanism to handle it correctly.

                    self.init_session()
                    payload = self._build_post_data(state_fips, years)
References
  1. In pipeline scripts, critical failure points should raise exceptions to ensure the process exits with a non-zero status code, rather than returning False and allowing the script to exit with success.

Comment on lines +397 to +402
try:
self.init_session()
payload = self._build_post_data(state_fips, years)
except Exception as session_err:
logging.warning("Session re-initialization error: %s",
session_err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Suppressing the exception when self.init_session() fails during standard retry recovery will cause the loop to continue to the next iteration with self.action_url set to None. This leads to a confusing ValueError or MissingSchema exception on the subsequent self.session.post call. Letting the exception propagate naturally allows the outer retry mechanism to handle it correctly.

                self.init_session()
                payload = self._build_post_data(state_fips, years)
References
  1. In pipeline scripts, critical failure points should raise exceptions to ensure the process exits with a non-zero status code, rather than returning False and allowing the script to exit with success.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant