diff --git a/flows/ETL_US_BirthData_to_OMOP.json b/flows/ETL_US_BirthData_to_OMOP.json new file mode 100644 index 0000000..b76bb1c --- /dev/null +++ b/flows/ETL_US_BirthData_to_OMOP.json @@ -0,0 +1 @@ +{"id":"59745eff-9bb9-486a-ab64-04433cb586d5","name":"ETL US BirthData to OMOP","nodes":[{"id":"node-load-bronze","data":{"name":"parse_nat2022_to_staging_csv","description":"Bronze: parse the fixed-width Nat2022 file (pruned columns) into a staging CSV on the shared trex volume -- no database access; node-facts consumes the CSV directly","python_code":"# Deployment-topology constants (deliberately NOT pipeline variables): these\n# paths are fixed by the platform's container mounts and images, not user-\n# tunable -- the flow container mounts the trex data volume at /app/duckdb_data\n# (trex sees that same directory as /usr/src/data), and the source-data folder\n# is part of the flow image at /app/data_load. Changing them in a pipeline\n# variable could never make a flow work; if the platform's mounts ever change,\n# update them here.\nNAT2022_DATA_DIR = \"/app/data_load\"\nSTAGING_DIR_FLOW = \"/app/duckdb_data/flow_staging\" # this container's view of the shared staging dir\nSTAGING_DIR_TREX = \"/usr/src/data/flow_staging\" # trex's view of the same directory\n\nFIELDS = [\n (\"DOB_YY\", 9, 12),\n (\"DOB_MM\", 13, 14),\n (\"DOB_TT\", 19, 22),\n (\"DOB_WK\", 23, 23),\n (\"OCTERR\", 24, 25),\n (\"OCNTYFIPS\", 28, 30),\n (\"OCNTYPOP\", 31, 31),\n (\"BFACIL\", 32, 32),\n (\"F_BFACIL\", 33, 33),\n (\"BFACIL3\", 50, 50),\n (\"MAGE_IMPFLG\", 73, 73),\n (\"MAGE_REPFLG\", 74, 74),\n (\"MAGER\", 75, 76),\n (\"MAGER14\", 77, 78),\n (\"MAGER9\", 79, 79),\n (\"MBCNTRY\", 80, 81),\n (\"MBSTATE_REC\", 84, 84),\n (\"MRCNTRY\", 85, 86),\n (\"MRTERR\", 89, 90),\n (\"RCNTY\", 91, 93),\n (\"RCNTY_POP\", 99, 99),\n (\"RCITY_POP\", 100, 100),\n (\"RECTYPE\", 103, 103),\n (\"RESTATUS\", 104, 104),\n (\"MRACE31\", 105, 106),\n (\"MRACE6\", 107, 107),\n (\"MRACE15\", 108, 109),\n (\"MRACEIMP\", 111, 111),\n (\"MHISPX\", 112, 112),\n (\"MHISP_R\", 115, 115),\n (\"F_MHISP\", 116, 116),\n (\"MRACEHISP\", 117, 117),\n (\"MAR_P\", 119, 119),\n (\"DMAR\", 120, 120),\n (\"MAR_IMP\", 121, 121),\n (\"F_MAR_P\", 123, 123),\n (\"MEDUC\", 124, 124),\n (\"F_MEDUC\", 126, 126),\n (\"FAGERPT_FLG\", 142, 142),\n (\"FAGECOMB\", 147, 148),\n (\"FAGEREC11\", 149, 150),\n (\"FRACE31\", 151, 152),\n (\"FRACE6\", 153, 153),\n (\"FRACE15\", 154, 155),\n (\"FHISPX\", 159, 159),\n (\"FHISP_R\", 160, 160),\n (\"F_FHISP\", 161, 161),\n (\"FRACEHISP\", 162, 162),\n (\"FEDUC\", 163, 163),\n (\"F_FEDUC\", 165, 165),\n (\"PRIORLIVE\", 171, 172),\n (\"PRIORDEAD\", 173, 174),\n (\"PRIORTERM\", 175, 176),\n (\"LBO_REC\", 179, 179),\n (\"TBO_REC\", 182, 182),\n (\"ILLB_R\", 198, 200),\n (\"ILLB_R11\", 201, 202),\n (\"ILOP_R\", 206, 208),\n (\"ILOP_R11\", 209, 210),\n (\"ILP_R\", 214, 216),\n (\"ILP_R11\", 217, 218),\n (\"PRECARE\", 224, 225),\n (\"F_MPCB\", 226, 226),\n (\"PRECARE5\", 227, 227),\n (\"PREVIS\", 238, 239),\n (\"PREVIS_REC\", 242, 243),\n (\"F_TPCV\", 244, 244),\n (\"WIC\", 251, 251),\n (\"F_WIC\", 252, 252),\n (\"CIG_0\", 253, 254),\n (\"CIG_1\", 255, 256),\n (\"CIG_2\", 257, 258),\n (\"CIG_3\", 259, 260),\n (\"CIG0_R\", 261, 261),\n (\"CIG1_R\", 262, 262),\n (\"CIG2_R\", 263, 263),\n (\"CIG3_R\", 264, 264),\n (\"F_CIGS_0\", 265, 265),\n (\"F_CIGS_1\", 266, 266),\n (\"F_CIGS_2\", 267, 267),\n (\"F_CIGS_3\", 268, 268),\n (\"CIG_REC\", 269, 269),\n (\"F_TOBACO\", 270, 270),\n (\"M_HT_IN\", 280, 281),\n (\"F_M_HT\", 282, 282),\n (\"BMI\", 283, 286),\n (\"BMI_R\", 287, 287),\n (\"PWGT_R\", 292, 294),\n (\"F_PWGT\", 295, 295),\n (\"DWGT_R\", 299, 301),\n (\"F_DWGT\", 303, 303),\n (\"WTGAIN\", 304, 305),\n (\"WTGAIN_REC\", 306, 306),\n (\"F_WTGAIN\", 307, 307),\n (\"RF_PDIAB\", 313, 313),\n (\"RF_GDIAB\", 314, 314),\n (\"RF_PHYPE\", 315, 315),\n (\"RF_GHYPE\", 316, 316),\n (\"RF_EHYPE\", 317, 317),\n (\"RF_PPTERM\", 318, 318),\n (\"F_RF_PDIAB\", 319, 319),\n (\"F_RF_GDIAB\", 320, 320),\n (\"F_RF_PHYPER\", 321, 321),\n (\"F_RF_GHYPER\", 322, 322),\n (\"F_RF_ECLAMP\", 323, 323),\n (\"F_RF_PPB\", 324, 324),\n (\"RF_INFTR\", 325, 325),\n (\"RF_FEDRG\", 326, 326),\n (\"RF_ARTEC\", 327, 327),\n (\"F_RF_INFT\", 328, 328),\n (\"F_RF_INF_DRG\", 329, 329),\n (\"F_RF_INF_ART\", 330, 330),\n (\"RF_CESAR\", 331, 331),\n (\"RF_CESARN\", 332, 333),\n (\"F_RF_CESAR\", 335, 335),\n (\"F_RF_NCESAR\", 336, 336),\n (\"NO_RISKS\", 337, 337),\n (\"IP_GON\", 343, 343),\n (\"IP_SYPH\", 344, 344),\n (\"IP_CHLAM\", 345, 345),\n (\"IP_HEPB\", 346, 346),\n (\"IP_HEPC\", 347, 347),\n (\"F_IP_GONOR\", 348, 348),\n (\"F_IP_SYPH\", 349, 349),\n (\"F_IP_CHLAM\", 350, 350),\n (\"F_IP_HEPATB\", 351, 351),\n (\"F_IP_HEPATC\", 352, 352),\n (\"NO_INFEC\", 353, 353),\n (\"OB_ECVS\", 360, 360),\n (\"OB_ECVF\", 361, 361),\n (\"F_OB_SUCC\", 363, 363),\n (\"F_OB_FAIL\", 364, 364),\n (\"LD_INDL\", 383, 383),\n (\"LD_AUGM\", 384, 384),\n (\"LD_STER\", 385, 385),\n (\"LD_ANTB\", 386, 386),\n (\"LD_CHOR\", 387, 387),\n (\"LD_ANES\", 388, 388),\n (\"F_LD_INDL\", 389, 389),\n (\"F_LD_AUGM\", 390, 390),\n (\"F_LD_STER\", 391, 391),\n (\"F_LD_ANTB\", 392, 392),\n (\"F_LD_CHOR\", 393, 393),\n (\"F_LD_ANES\", 394, 394),\n (\"NO_LBRDLV\", 395, 395),\n (\"ME_PRES\", 401, 401),\n (\"ME_ROUT\", 402, 402),\n (\"ME_TRIAL\", 403, 403),\n (\"F_ME_PRES\", 404, 404),\n (\"F_ME_ROUT\", 405, 405),\n (\"F_ME_TRIAL\", 406, 406),\n (\"RDMETH_REC\", 407, 407),\n (\"DMETH_REC\", 408, 408),\n (\"F_DMETH_REC\", 409, 409),\n (\"MM_MTR\", 415, 415),\n (\"MM_PLAC\", 416, 416),\n (\"MM_RUPT\", 417, 417),\n (\"MM_UHYST\", 418, 418),\n (\"MM_AICU\", 419, 419),\n (\"F_MM_MTR\", 421, 421),\n (\"F_MM_PLAC\", 422, 422),\n (\"F_MM_RUPT\", 423, 423),\n (\"F_MM_UHYST\", 424, 424),\n (\"F_MM_AICU\", 425, 425),\n (\"NO_MMORB\", 427, 427),\n (\"ATTEND\", 433, 433),\n (\"MTRAN\", 434, 434),\n (\"PAY\", 435, 435),\n (\"PAY_REC\", 436, 436),\n (\"F_PAY\", 437, 437),\n (\"F_PAY_REC\", 438, 438),\n (\"APGAR5\", 444, 445),\n (\"APGAR5R\", 446, 446),\n (\"F_APGAR5\", 447, 447),\n (\"APGAR10\", 448, 449),\n (\"APGAR10R\", 450, 450),\n (\"DPLURAL\", 454, 454),\n (\"IMP_PLUR\", 456, 456),\n (\"SETORDER_R\", 459, 459),\n (\"SEX\", 475, 475),\n (\"IMP_SEX\", 476, 476),\n (\"DLMP_MM\", 477, 478),\n (\"DLMP_YY\", 481, 484),\n (\"COMPGST_IMP\", 488, 488),\n (\"OBGEST_FLG\", 489, 489),\n (\"COMBGEST\", 490, 491),\n (\"GESTREC10\", 492, 493),\n (\"GESTREC3\", 494, 494),\n (\"LMPUSED\", 498, 498),\n (\"OEGEST_COMB\", 499, 500),\n (\"OEGEST_R10\", 501, 502),\n (\"OEGEST_R3\", 503, 503),\n (\"DBWT\", 504, 507),\n (\"BWTR12\", 509, 510),\n (\"BWTR4\", 511, 511),\n (\"AB_AVEN1\", 517, 517),\n (\"AB_AVEN6\", 518, 518),\n (\"AB_NICU\", 519, 519),\n (\"AB_SURF\", 520, 520),\n (\"AB_ANTI\", 521, 521),\n (\"AB_SEIZ\", 522, 522),\n (\"F_AB_VENT\", 524, 524),\n (\"F_AB_VENT6\", 525, 525),\n (\"F_AB_NIUC\", 526, 526),\n (\"F_AB_SURFAC\", 527, 527),\n (\"F_AB_ANTIBIO\", 528, 528),\n (\"F_AB_SEIZ\", 529, 529),\n (\"NO_ABNORM\", 531, 531),\n (\"CA_ANEN\", 537, 537),\n (\"CA_MNSB\", 538, 538),\n (\"CA_CCHD\", 539, 539),\n (\"CA_CDH\", 540, 540),\n (\"CA_OMPH\", 541, 541),\n (\"CA_GAST\", 542, 542),\n (\"F_CA_ANEN\", 543, 543),\n (\"F_CA_MENIN\", 544, 544),\n (\"F_CA_HEART\", 545, 545),\n (\"F_CA_HERNIA\", 546, 546),\n (\"F_CA_OMPHA\", 547, 547),\n (\"F_CA_GASTRO\", 548, 548),\n (\"CA_LIMB\", 549, 549),\n (\"CA_CLEFT\", 550, 550),\n (\"CA_CLPAL\", 551, 551),\n (\"CA_DOWN\", 552, 552),\n (\"CA_DISOR\", 553, 553),\n (\"CA_HYPO\", 554, 554),\n (\"F_CA_LIMB\", 555, 555),\n (\"F_CA_CLEFTLP\", 556, 556),\n (\"F_CA_CLEFT\", 557, 557),\n (\"F_CA_DOWNS\", 558, 558),\n (\"F_CA_CHROM\", 559, 559),\n (\"F_CA_HYPOS\", 560, 560),\n (\"NO_CONGEN\", 561, 561),\n (\"ITRAN\", 567, 567),\n (\"ILIVE\", 568, 568),\n (\"BFED\", 569, 569),\n (\"F_BFED\", 570, 570),\n]\n\n# Column pruning: the raw file has 237 fields, but node-facts only reads the\n# ones below (derived from its field lists, alias table, and every direct\n# rec[...] access). Parsing and staging only these keeps the CSV and the\n# transform's input small. If node-facts starts reading a new raw column, it\n# must be added here.\nRAW_KEEP_COLUMNS = {\n \"AB_ANTI\", \"AB_AVEN1\", \"AB_AVEN6\", \"AB_NICU\", \"AB_SEIZ\", \"AB_SURF\",\n \"APGAR10\", \"APGAR5\", \"ATTEND\", \"BFACIL\", \"BFED\", \"BMI\",\n \"CA_ANEN\", \"CA_CCHD\", \"CA_CDH\", \"CA_CLEFT\", \"CA_CLPAL\", \"CA_DISOR\",\n \"CA_DOWN\", \"CA_GAST\", \"CA_HYPO\", \"CA_LIMB\", \"CA_MNSB\", \"CA_OMPH\",\n \"CIG0_R\", \"CIG1_R\", \"CIG2_R\", \"CIG3_R\", \"COMBGEST\", \"DBWT\",\n \"DLMP_MM\", \"DLMP_YY\", \"DMAR\", \"DMETH_REC\", \"DOB_MM\", \"DOB_TT\",\n \"DOB_WK\", \"DOB_YY\", \"DPLURAL\", \"FAGECOMB\", \"FEDUC\", \"FHISPX\",\n \"FRACE15\", \"FRACE6\", \"FRACEHISP\", \"ILIVE\", \"ILLB_R\", \"ILOP_R\",\n \"IP_CHLAM\", \"IP_GON\", \"IP_HEPB\", \"IP_HEPC\", \"IP_SYPH\", \"ITRAN\",\n \"LD_ANES\", \"LD_ANTB\", \"LD_AUGM\", \"LD_CHOR\", \"LD_INDL\", \"LD_STER\",\n \"MAGER\", \"MAR_P\", \"MEDUC\", \"ME_PRES\", \"ME_ROUT\", \"ME_TRIAL\",\n \"MHISPX\", \"MM_AICU\", \"MM_MTR\", \"MM_PLAC\", \"MM_RUPT\", \"MM_UHYST\",\n \"MRACE15\", \"MRACE6\", \"MRACEHISP\", \"MTRAN\", \"M_HT_IN\", \"NO_ABNORM\",\n \"NO_CONGEN\", \"NO_LBRDLV\", \"NO_MMORB\", \"NO_RISKS\", \"OB_ECVF\", \"OB_ECVS\",\n \"OEGEST_COMB\", \"PAY\", \"PRECARE\", \"PREVIS\", \"PRIORDEAD\", \"PRIORLIVE\",\n \"PRIORTERM\", \"PWGT_R\", \"RDMETH_REC\", \"RF_ARTEC\", \"RF_CESAR\", \"RF_CESARN\",\n \"RF_EHYPE\", \"RF_FEDRG\", \"RF_GDIAB\", \"RF_GHYPE\", \"RF_INFTR\", \"RF_PDIAB\",\n \"RF_PHYPE\", \"RF_PPTERM\", \"SETORDER_R\", \"SEX\", \"WIC\", \"WTGAIN\",\n}\n\nFIELDS = [f for f in FIELDS if f[0] in RAW_KEEP_COLUMNS]\n\nCOLSPECS = [(start - 1, end) for (_, start, end) in FIELDS]\nNAMES = [name for (name, _, _) in FIELDS]\n\n\ndef load_nat2022(filepath, chunksize=None):\n read_kwargs = dict(colspecs=COLSPECS, names=NAMES, dtype=str, encoding=\"utf-8\")\n if chunksize:\n return pd.read_fwf(filepath, chunksize=chunksize, **read_kwargs)\n return pd.read_fwf(filepath, **read_kwargs)\n\n\ndef _sanitize_cache_id(raw_id: str) -> str:\n \"\"\"\n Mirrors the platform's own Dataset.cacheId derivation\n (portal/src/dataset/entity/dataset.entity.ts: sanitizeIdForCacheId) --\n hyphens aren't valid in a bare SQL/DuckDB identifier, so the Dataset's\n UUID gets converted the same way here before use as a trex cache_id.\n \"\"\"\n cleaned = raw_id.replace(\"-\", \"_\")\n return f\"_{cleaned}\" if cleaned[:1].isdigit() else cleaned\n\n\ndef exec(myinput):\n \"\"\"\n Bronze: parse the fixed-width source file (columns pruned to\n RAW_KEEP_COLUMNS) into one staging CSV on the shared trex-volume staging\n dir, plus a `.complete` marker holding the row count. This node needs no\n database access -- node-facts reads the CSV directly, and node-cleanup\n deletes it after a successful transform.\n\n Crash-safety: the marker is deleted BEFORE writing and recreated only\n after the last row -- an interrupted run leaves csv-without-marker, which\n node-facts refuses to consume.\n\n ROW_IDX is assigned here, once, directly from the file's read order (which\n pandas read_fwf guarantees is sequential) -- downstream uses this stored\n value as record_idx rather than re-deriving positions.\n \"\"\"\n import time\n import uuid\n\n logger = get_run_logger()\n nat2022_file_path = os.path.join(NAT2022_DATA_DIR, nat2022_filename)\n cache_catalog = _sanitize_cache_id(dataset_id)\n fname = f\"{cache_catalog}_nat2022_raw.csv\"\n csv_path = os.path.join(STAGING_DIR_FLOW, fname)\n complete_path = csv_path + \".complete\"\n\n try:\n probe = os.path.join(STAGING_DIR_FLOW, f\".probe_{uuid.uuid4().hex}\")\n with open(probe, \"w\") as f:\n f.write(\"x\")\n os.remove(probe)\n except Exception as e:\n raise RuntimeError(\n f\"Shared staging dir {STAGING_DIR_FLOW} is not writable ({e}). It is required \"\n f\"infrastructure for this pipeline: on the trex side run once \"\n f\"`mkdir -p /usr/src/data/flow_staging && chmod 1777 /usr/src/data/flow_staging` \"\n f\"(new trex images and the helm chart provision it automatically).\"\n ) from e\n\n # The marker must be removed before writing begins: a crash mid-write can\n # then never leave a fresh marker next to a stale or partial CSV.\n try:\n os.remove(complete_path)\n except OSError:\n pass\n\n total = 0\n t_parse = time.time()\n for chunk_idx, chunk in enumerate(load_nat2022(nat2022_file_path, chunksize=int(chunk_size))):\n chunk = chunk.copy()\n chunk.insert(0, \"ROW_IDX\", range(total, total + len(chunk)))\n chunk.to_csv(csv_path, mode=\"w\" if chunk_idx == 0 else \"a\", header=(chunk_idx == 0), index=False, na_rep=\"\")\n total += len(chunk)\n logger.info(f\"Parsed {total:,} records -> staging CSV ({time.time() - t_parse:.0f}s elapsed)\")\n\n with open(complete_path, \"w\") as f:\n f.write(str(total))\n logger.info(f\"Staged {total:,} records at {csv_path} (+ .complete marker)\")\n return f\"Parsed {total} records into {csv_path}\""},"type":"python_node","width":350,"height":210,"position":{"x":130,"y":110},"selected":false,"dragHandle":"","sourcePosition":"right","targetPosition":"left"},{"id":"node-facts","data":{"name":"transform_facts","description":"Gold: build PERSON, VISIT_OCCURRENCE, OBSERVATION, MEASUREMENT, CONDITION_OCCURRENCE, PROCEDURE_OCCURRENCE, and PAYER_PLAN_PERIOD in a single per-record pass (merged from 7 originally separate nodes; see node_backups/ for the originals).","python_code":"# Deployment-topology constants (deliberately NOT pipeline variables) -- see\n# node-load-bronze for the full rationale: fixed by the platform's container\n# mounts; the two paths are the flow container's and trex's views of the same\n# shared staging directory on the trex data volume.\nNAT2022_DATA_DIR = \"/app/data_load\"\nSTAGING_DIR_FLOW = \"/app/duckdb_data/flow_staging\"\nSTAGING_DIR_TREX = \"/usr/src/data/flow_staging\"\n\nMAPPING_CSV_NODE = \"csv_node_0\"\n\n_PREFIX_GROUP_RE = re.compile(r'^(all )?variables beginning with\\s+(\\w+?)_?$', re.I)\n\n_REAL_CDM_COLUMNS = {\n \"PERSON\": {\"person_id\", \"gender_concept_id\", \"year_of_birth\", \"month_of_birth\", \"day_of_birth\",\n \"birth_datetime\", \"race_concept_id\", \"ethnicity_concept_id\", \"location_id\", \"provider_id\",\n \"care_site_id\", \"person_source_value\", \"gender_source_value\", \"gender_source_concept_id\",\n \"race_source_value\", \"race_source_concept_id\", \"ethnicity_source_value\",\n \"ethnicity_source_concept_id\"},\n \"VISIT_OCCURRENCE\": {\"visit_occurrence_id\", \"person_id\", \"visit_concept_id\", \"visit_start_date\",\n \"visit_start_datetime\", \"visit_end_date\", \"visit_end_datetime\",\n \"visit_type_concept_id\", \"provider_id\", \"care_site_id\", \"visit_source_value\",\n \"visit_source_concept_id\", \"admitted_from_concept_id\", \"admitted_from_source_value\",\n \"discharged_to_concept_id\", \"discharged_to_source_value\",\n \"preceding_visit_occurrence_id\"},\n \"OBSERVATION\": {\"observation_id\", \"person_id\", \"observation_concept_id\", \"observation_date\",\n \"observation_datetime\", \"observation_type_concept_id\", \"value_as_number\",\n \"value_as_string\", \"value_as_concept_id\", \"qualifier_concept_id\", \"unit_concept_id\",\n \"provider_id\", \"visit_occurrence_id\", \"visit_detail_id\", \"observation_source_value\",\n \"observation_source_concept_id\", \"unit_source_value\", \"qualifier_source_value\",\n \"value_source_value\", \"observation_event_id\", \"obs_event_field_concept_id\"},\n \"MEASUREMENT\": {\"measurement_id\", \"person_id\", \"measurement_concept_id\", \"measurement_date\",\n \"measurement_datetime\", \"measurement_time\", \"measurement_type_concept_id\",\n \"operator_concept_id\", \"value_as_number\", \"value_as_concept_id\", \"unit_concept_id\",\n \"range_low\", \"range_high\", \"provider_id\", \"visit_occurrence_id\", \"visit_detail_id\",\n \"measurement_source_value\", \"measurement_source_concept_id\", \"unit_source_value\",\n \"unit_source_concept_id\", \"value_source_value\", \"measurement_event_id\",\n \"meas_event_field_concept_id\"},\n \"CONDITION\": {\"condition_occurrence_id\", \"person_id\", \"condition_concept_id\", \"condition_start_date\",\n \"condition_start_datetime\", \"condition_end_date\", \"condition_end_datetime\",\n \"condition_type_concept_id\", \"condition_status_concept_id\", \"stop_reason\", \"provider_id\",\n \"visit_occurrence_id\", \"visit_detail_id\", \"condition_source_value\",\n \"condition_source_concept_id\", \"condition_status_source_value\"},\n \"PROCEDURE\": {\"procedure_occurrence_id\", \"person_id\", \"procedure_concept_id\", \"procedure_date\",\n \"procedure_datetime\", \"procedure_end_date\", \"procedure_end_datetime\",\n \"procedure_type_concept_id\", \"modifier_concept_id\", \"quantity\", \"provider_id\",\n \"visit_occurrence_id\", \"visit_detail_id\", \"procedure_source_value\",\n \"procedure_source_concept_id\", \"modifier_source_value\"},\n \"PAYER_PLAN_PERIOD\": {\"payer_plan_period_id\", \"person_id\", \"payer_plan_period_start_date\",\n \"payer_plan_period_end_date\", \"payer_concept_id\", \"payer_source_value\",\n \"payer_source_concept_id\", \"plan_concept_id\", \"plan_source_value\",\n \"plan_source_concept_id\", \"sponsor_concept_id\", \"sponsor_source_value\",\n \"sponsor_source_concept_id\", \"family_source_value\", \"stop_reason_concept_id\",\n \"stop_reason_source_value\", \"stop_reason_source_concept_id\"},\n \"PROVIDER\": {\"provider_id\", \"provider_name\", \"npi\", \"dea\", \"specialty_concept_id\", \"care_site_id\",\n \"year_of_birth\", \"gender_concept_id\", \"provider_source_value\", \"specialty_source_value\",\n \"specialty_source_concept_id\", \"gender_source_value\", \"gender_source_concept_id\"},\n}\n\ndef _validate_mapping_against_cdm(rows: \"tuple[MappingRow, ...]\", logger) -> None:\n \"\"\"Reject mapping rows whose target table or field is not part of OMOP CDM 5.4.\"\"\"\n unexpected = []\n for row in rows:\n valid_columns = _REAL_CDM_COLUMNS.get(row.target_table)\n if valid_columns is None:\n unexpected.append(\n f\"{row.source_field}/{row.person_role}: unrecognized Target_Table {row.target_table!r}\"\n )\n continue\n if row.field_name.lower() not in valid_columns:\n unexpected.append(\n f\"{row.source_field}/{row.person_role}: Field_Name {row.field_name!r} is not a real \"\n f\"column on {row.target_table} (spreadsheet concept: {row.concept_name!r})\"\n )\n if unexpected:\n raise ValueError(\n \"Mapping spreadsheet has invalid OMOP CDM 5.4 Target_Table/Field_Name combinations:\\n\"\n + \"\\n\".join(f\" - {e}\" for e in unexpected)\n )\n\n\n\n@dataclass(frozen=True)\nclass MappingRow:\n source_field: str # lowercase NBER field name, e.g. \"rf_pdiab\"\n person_role: str # \"Child\", \"Mother\", or \"Father\"\n target_table: str # normalized OMOP table name, e.g. \"OBSERVATION\"\n field_name: str # OMOP column this row supplies, e.g. \"value_as_concept_id\"\n concept_id: int | None\n source_value_type: str | None # \"integer\"/\"float\" means copy the matched source value\n concept_name: str\n matcher: \"ValueMatcher\"\n\n\ndef _is_number(s: str) -> bool:\n \"\"\"True for anything float() accepts, e.g. \"9\", \"-5\", \"69.9\" -- used\n instead of str.isdigit() so decimal Values (e.g. bmi's \"13-69.9\") parse\n as ranges/exact values instead of falling through to literal-string\n matching, which no real decimal value would ever hit.\"\"\"\n try:\n float(s)\n return True\n except ValueError:\n return False\n\n\nclass ValueMatcher:\n \"\"\"\n Parses the spreadsheet's free-text Values column into something\n callable against an actual field value from the data.\n\n Recognized forms: blank/\"any\" (always matches -- used for field-level\n \"header\" rows that apply regardless of the specific code), exact\n tokens separated by \"or\"/\",\", and numeric ranges (\"0-30\", \"1 to 98\",\n \"13-69.9\" -- bounds/values may be decimal, e.g. bmi).\n \"\"\"\n\n def __init__(self, raw: str):\n raw = (raw or \"\").strip()\n self.raw = raw\n self.kind: str\n if raw == \"\" or raw.lower() == \"any\":\n self.kind = \"always\"\n return\n if raw.startswith(\">\") or raw.startswith(\"<\"):\n bound = raw[1:].strip()\n if _is_number(bound):\n self.kind = \"inequality\"\n self.op = raw[0]\n self.bound = float(bound)\n return\n low = raw.lower().replace(\" to \", \"-\")\n parts = [p.strip() for p in low.split(\"-\")]\n if len(parts) == 2 and all(_is_number(p) for p in parts):\n self.kind = \"range\"\n self.lo = float(parts[0])\n self.hi = float(parts[1])\n return\n self.kind = \"exact\"\n self.tokens = {t.strip().lower() for t in raw.replace(\",\", \" or \").split(\" or \")}\n\n def matches(self, value) -> bool:\n if value is None:\n return False\n value_s = str(value).strip()\n if value_s == \"\":\n return False\n if self.kind == \"always\":\n return True\n if self.kind == \"range\":\n try:\n v = float(value_s)\n except ValueError:\n return False\n return self.lo <= v <= self.hi\n if self.kind == \"inequality\":\n try:\n v = float(value_s)\n except ValueError:\n return False\n return v > self.bound if self.op == \">\" else v < self.bound\n if value_s.lower() in self.tokens:\n return True\n # Raw fields are read with dtype=str (pd.read_fwf), so numeric codes\n # keep their fixed-width zero-padding (e.g. \"09\") -- compare\n # numerically too so an exact token like \"9\" still matches \"09\",\n # and a decimal token like \"99.9\" still matches a decimal value.\n if _is_number(value_s):\n return any(_is_number(t) and float(t) == float(value_s) for t in self.tokens)\n return False\n\n\ndef _normalize_table(raw: str) -> str:\n return raw.strip().upper().replace(\" \", \"_\")\n\n\n_MAPPING_ROWS: tuple[MappingRow, ...] | None = None\n_PREFIX_GROUP_DEFAULTS: dict[tuple[str, str], int] | None = None\n_FIELD_INDEX: dict[tuple[str, str], list[MappingRow]] | None = None\n_MAPPING_DF_FROM_NODE: \"pd.DataFrame | None\" = None\n\n\ndef _load_mapping_df() -> \"pd.DataFrame\":\n if _MAPPING_DF_FROM_NODE is None:\n raise RuntimeError(\n f\"Expected mapping spreadsheet input from upstream csv node {MAPPING_CSV_NODE!r}. \"\n \"Connect that node to transform_facts (node-facts).\"\n )\n return _MAPPING_DF_FROM_NODE.copy()\n\n\ndef load_mapping_rows() -> tuple[MappingRow, ...]:\n global _MAPPING_ROWS\n if _MAPPING_ROWS is not None:\n return _MAPPING_ROWS\n\n out = []\n for r in _load_mapping_df().itertuples(index=False, name=None):\n if not r or not r[0]:\n continue\n nber_field, values, _nber_name, field_name, target_table, vocab_id, concept_name = r[:7]\n person_role = (r[11] or \"\").strip()\n if not person_role or not target_table or not field_name:\n continue\n if _PREFIX_GROUP_RE.match(str(nber_field).strip()):\n continue # prefix-group default row, handled by _prefix_group_defaults below\n field_name_s = str(field_name).strip()\n vocab_id_s = str(vocab_id).strip() if vocab_id is not None else \"\"\n concept_id = None\n source_value_type = None\n if vocab_id_s.lstrip(\"-\").isdigit():\n concept_id = int(vocab_id_s)\n elif field_name_s == \"value_as_number\" and vocab_id_s.lower() == \"integer\":\n source_value_type = \"integer\"\n elif field_name_s == \"value_as_number\" and vocab_id_s.lower() in {\"float\", \"number\"}:\n source_value_type = \"float\"\n else:\n continue # unsupported computed/imputed row\n source_field_s = str(nber_field).strip().lower()\n values_s = \"\" if values is None else str(values)\n out.append(MappingRow(\n source_field=source_field_s,\n person_role=person_role,\n target_table=_normalize_table(str(target_table)),\n field_name=field_name_s,\n concept_id=concept_id,\n source_value_type=source_value_type,\n concept_name=str(concept_name).strip() if concept_name else \"\",\n matcher=ValueMatcher(values_s),\n ))\n _MAPPING_ROWS = tuple(out)\n return _MAPPING_ROWS\n\n\ndef _prefix_group_defaults() -> dict[tuple[str, str], int]:\n \"\"\"(person_role, prefix) -> default observation_concept_id for that field family.\"\"\"\n global _PREFIX_GROUP_DEFAULTS\n if _PREFIX_GROUP_DEFAULTS is not None:\n return _PREFIX_GROUP_DEFAULTS\n\n out = {}\n for r in _load_mapping_df().itertuples(index=False, name=None):\n if not r or not r[0]:\n continue\n m = _PREFIX_GROUP_RE.match(str(r[0]).strip())\n if not m:\n continue\n vocab_id_s = str(r[5]).strip() if r[5] is not None else \"\"\n if not vocab_id_s.lstrip(\"-\").isdigit():\n continue\n person_role = (r[11] or \"\").strip()\n prefix = m.group(2).lower()\n out[(person_role, prefix)] = int(vocab_id_s)\n _PREFIX_GROUP_DEFAULTS = out\n return _PREFIX_GROUP_DEFAULTS\n\n\ndef _index_by_field() -> dict[tuple[str, str], list[MappingRow]]:\n global _FIELD_INDEX\n if _FIELD_INDEX is not None:\n return _FIELD_INDEX\n\n index: dict[tuple[str, str], list[MappingRow]] = {}\n for row in load_mapping_rows():\n index.setdefault((row.person_role, row.source_field), []).append(row)\n _FIELD_INDEX = index\n return _FIELD_INDEX\n\n\n# Fields whose \"none of the above\" answer belongs to a question family\n# with a different source-field prefix.\n_OBSERVATION_PREFIX_ALIAS = {\n (\"Mother\", \"no_mmorb\"): \"mm\",\n (\"Child\", \"no_abnorm\"): \"ab\",\n (\"Child\", \"no_congen\"): \"ca\",\n (\"Mother\", \"no_lbrdlv\"): \"ld\",\n}\n\ndef get_records(person_role: str, source_field: str, value):\n \"\"\"\n Returns {target_table: {field_name: concept_id, ...}, ...} for every\n target table that has at least one matching mapping row for this\n (person_role, source_field, value). Rows with no value match (e.g. the\n field is blank/not reported) contribute nothing.\n \"\"\"\n source_field = source_field.lower()\n candidates = _index_by_field().get((person_role, source_field), [])\n out: dict[str, dict[str, int | float]] = {}\n for row in candidates:\n if row.matcher.matches(value):\n if row.source_value_type == \"integer\":\n try:\n mapped_value = int(str(value).strip())\n except ValueError:\n continue\n out.setdefault(row.target_table, {})[row.field_name] = mapped_value\n continue\n if row.source_value_type == \"float\":\n try:\n mapped_value = float(str(value).strip())\n except ValueError:\n continue\n out.setdefault(row.target_table, {})[row.field_name] = mapped_value\n continue\n out.setdefault(row.target_table, {})[row.field_name] = row.concept_id\n\n if \"OBSERVATION\" in out and \"observation_concept_id\" not in out[\"OBSERVATION\"]:\n prefix = _OBSERVATION_PREFIX_ALIAS.get(\n (person_role, source_field), source_field.split(\"_\")[0]\n )\n default = _prefix_group_defaults().get((person_role, prefix))\n if default is not None:\n out[\"OBSERVATION\"][\"observation_concept_id\"] = default\n\n return out\n\n\n\n\nDEFAULT_TYPE_CONCEPT_ID = 32879 # Registry\n\nWEEKDAY_CODE_TO_PY = {1: 6, 2: 0, 3: 1, 4: 2, 5: 3, 6: 4, 7: 5} # 1=Sunday..7=Saturday -> Python Monday=0..Sunday=6\n\n\ndef _int_or_none(value):\n s = \"\" if value is None else str(value).strip()\n if s == \"\":\n return None\n try:\n return int(s)\n except ValueError:\n return None\n\n\ndef _float_or_none(value):\n s = \"\" if value is None else str(value).strip()\n if s == \"\":\n return None\n try:\n return float(s)\n except ValueError:\n return None\n\n\ndef _impute_child_dob(dob_yy, dob_mm, dob_wk):\n year, month, wk = _int_or_none(dob_yy), _int_or_none(dob_mm), _int_or_none(dob_wk)\n if year is None or month is None:\n return None, None, None\n if wk is None or wk not in WEEKDAY_CODE_TO_PY:\n return year, month, None\n target_py_weekday = WEEKDAY_CODE_TO_PY[wk]\n first_of_month = dt.date(year, month, 1)\n offset = (target_py_weekday - first_of_month.weekday()) % 7\n return year, month, 1 + offset\n\n\ndef _parse_dob_time(dob_tt) -> tuple[int, int] | None:\n \"\"\"Parse DOB_TT as zero-padded HHMM; 9999 means not stated.\"\"\"\n raw = \"\" if dob_tt is None else str(dob_tt).strip()\n if not raw.isdigit() or len(raw) > 4:\n return None\n s = raw.zfill(4)\n if s == \"9999\":\n return None\n hour, minute = int(s[:2]), int(s[2:])\n if not (0 <= hour <= 23 and 0 <= minute <= 59):\n return None\n return hour, minute\n\n\ndef dob_date_for(rec):\n year, month, day = _impute_child_dob(rec.get(\"DOB_YY\"), rec.get(\"DOB_MM\"), rec.get(\"DOB_WK\"))\n if year and month and day:\n return dt.date(year, month, day)\n return None\n\n\ndef _subtract_months(base_date, months):\n total = base_date.year * 12 + (base_date.month - 1) - months\n year, month = divmod(total, 12)\n day = min(base_date.day, 28)\n return dt.date(year, month + 1, day)\n\n\nclass IdGenerator:\n # Dense, chunk-safe id allocation: exactly 3 roles per record, so\n # record_idx*3 + offset + 1 produces 1,2,3,4,5,6,... with no gaps,\n # while still being computable independently per record (no shared\n # counter needed across chunks/workers).\n PERSON_ROLE_OFFSET = {\"Child\": 0, \"Mother\": 1, \"Father\": 2}\n\n @staticmethod\n def person_id(record_idx, role):\n return record_idx * 3 + IdGenerator.PERSON_ROLE_OFFSET[role] + 1\n\n @staticmethod\n def visit_occurrence_id(record_idx, role):\n return record_idx * 3 + IdGenerator.PERSON_ROLE_OFFSET[role] + 1\n\n @staticmethod\n def fact_id(record_idx, local_counter):\n # 500-id block per record: the destination CDM's fact id columns\n # (observation_id etc.) are 32-bit INTEGER (max 2,147,483,647) and the\n # full Nat2022 file has 3,676,029 records, so the block size must keep\n # the largest id under 2^31 (500 tops out at ~1.84e9) while exceeding\n # the most facts one record produces in a table (~60 observations).\n return (record_idx + 1) * 500 + local_counter\n\n\nPROVIDER_DIMENSION = [\n {\"specialty_concept_id\": 38004446, \"provider_source_value\": \"Physician\"},\n {\"specialty_concept_id\": 38003822, \"provider_source_value\": \"Osteopathic Practitioner\"},\n {\"specialty_concept_id\": 38004482, \"provider_source_value\": \"CNM/CM\"},\n {\"specialty_concept_id\": 38003807, \"provider_source_value\": \"Other midwife\"},\n {\"specialty_concept_id\": 0, \"provider_source_value\": \"Other/Unknown\"},\n]\nPROVIDER_ID_BY_CONCEPT = {row[\"specialty_concept_id\"]: i + 1 for i, row in enumerate(PROVIDER_DIMENSION)}\n\n\ndef provider_id_for(rec: dict) -> int | None:\n provider = get_records(\"Mother\", \"attend\", rec.get(\"ATTEND\")).get(\"PROVIDER\", {})\n return PROVIDER_ID_BY_CONCEPT.get(provider.get(\"specialty_concept_id\"))\n\n\n\n_CHILD_FIELDS = [\n # dmeth_rec deliberately excluded -- spreadsheet owner confirmed it's a collapsed\n # duplicate of rdmeth_rec (dmeth_rec=1 combines rdmeth_rec 1/2/5, dmeth_rec=2\n # combines rdmeth_rec 3/4/6) and to prefer rdmeth_rec, which has more granularity.\n # See questions_for_spreadsheet_owner.md #9.\n # mtran deliberately excluded -- spreadsheet owner confirmed mtran (\"Mother\n # Transferred\") belongs on the mother's own record only, not the child's; itran\n # (\"Infant Transferred\") is the child's own, separate fact. See new_findings.md.\n \"ab_anti\", \"ab_aven1\", \"ab_aven6\", \"ab_nicu\", \"ab_seiz\", \"ab_surf\", \"attend\",\n \"bfed\", \"ca_anen\", \"ca_cchd\", \"ca_cdh\", \"ca_cleft\", \"ca_clpal\",\n \"ca_disor\", \"ca_downs\", \"ca_gast\", \"ca_hypo\", \"ca_limb\", \"ca_mnsb\", \"ca_omph\",\n \"dmar\", \"dob_tt\", \"dplural\", \"fagecomb\", \"ilive\", \"itran\", \"ld_indl\",\n \"mar_p\", \"me_pres\", \"me_rout\", \"meduc\", \"no_abnorm\",\n \"no_congen\", \"ob_ecvs\", \"oegest_comb\", \"pay\", \"precare\",\n \"previs\", \"rdmeth_rec\", \"setorder_r\", \"apgar5\", \"apgar10\",\n \"combgest\", \"dbwt\",\n]\n_MOTHER_FIELDS = [\n # dlmp_mm/dlmp_yy deliberately excluded -- combined into one hand-computed\n # OBSERVATION row below instead of two independent per-field rows.\n # dmeth_rec deliberately excluded -- see _CHILD_FIELDS comment above; same\n # rdmeth_rec-preferred resolution applies to Mother's own copy.\n \"attend\", \"cig0_r\", \"cig1_r\", \"cig2_r\", \"cig3_r\",\n \"dmar\", \"illb_r\", \"ilop_r\", \"ip_chlam\", \"ip_gon\", \"ip_hepatb\", \"ip_hepatc\",\n \"ip_syph\", \"ld_anes\", \"ld_antb\", \"ld_augm\", \"ld_chor\", \"ld_indl\", \"ld_ster\",\n \"m_ht_in\", \"mager\", \"mar_p\", \"me_pres\", \"me_rout\", \"me_trial\", \"meduc\",\n \"mm_aicu\", \"mm_mtr\", \"mm_plac\", \"mm_rupt\", \"mm_uhyst\", \"mrace15\", \"mtran\",\n \"no_lbrdlv\", \"no_mmorb\", \"no_risks\", \"ob_ecvs\", \"oegest_comb\",\n \"pay\", \"precare\", \"previs\", \"rdmeth_rec\", \"rf_artec\", \"rf_cesar\",\n \"rf_ehype\", \"rf_fedrg\", \"rf_gdiab\", \"rf_ghype\", \"rf_inftr\", \"rf_pdiab\",\n \"rf_phype\", \"rf_ppterm\", \"sex\", \"setorder_r\", \"wic\", \"wtgain\", \"bmi\",\n \"combgest\", \"dbwt\", \"pwgt_r\", \"dplural\",\n \"priorlive\", \"priordead\", \"rf_cesarn\", \"priorterm\",\n]\n_FATHER_FIELDS = [\"fagecomb\", \"feduc\", \"frace15\"]\n\n\ndef _observation_date_for(field: str, value, dob_date: dt.date | None) -> dt.date | None:\n # OBSERVATION.observation_date is when the information was collected.\n # For this birth-certificate ETL, that is the child's birth date.\n return dob_date\n\n\n# Spreadsheet NBER Field names that don't match the raw bronze column name for the\n# same field (source_field.upper() -> real raw column). Confirmed against both\n# node-load-bronze's FIELDS list and UserGuide2022.pdf: the real fields are\n# CA_DOWN/IP_HEPB/IP_HEPC, not CA_DOWNS/IP_HEPATB/IP_HEPATC. Without this, rec.get()\n# always misses and these 3 fields silently never produce any record, on any real\n# data -- verified via _iter_field_records() returning [] for all three. See\n# questions_for_spreadsheet_owner.md.\n_RAW_FIELD_ALIAS = {\n \"ca_downs\": \"CA_DOWN\",\n \"ip_hepatb\": \"IP_HEPB\",\n \"ip_hepatc\": \"IP_HEPC\",\n}\n\n\ndef _iter_field_records(rec: dict, dob_date: dt.date | None):\n \"\"\"\n Yields (target_table, role, fact_dict) for every field/value/role match\n found via the spreadsheet-driven mapping, across all of OBSERVATION,\n MEASUREMENT, CONDITION, PROCEDURE, PAYER_PLAN_PERIOD, and PROVIDER.\n\n Each of the 8 build_