hccinfhir 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hccinfhir/__init__.py +1 -13
- hccinfhir/data/__init__.py +1 -1
- hccinfhir/data/ra_coefficients_2025.csv +6352 -0
- hccinfhir/data/ra_dx_to_cc_2025.csv +53952 -0
- hccinfhir/data/ra_hierarchies_2025.csv +487 -0
- hccinfhir/datamodels.py +84 -0
- hccinfhir/extractor.py +3 -3
- hccinfhir/extractor_837.py +1 -2
- hccinfhir/extractor_fhir.py +1 -1
- hccinfhir/filter.py +1 -1
- hccinfhir/hccinfhir.py +142 -0
- hccinfhir/model_calculate.py +50 -0
- hccinfhir/model_coefficients.py +143 -0
- hccinfhir/model_demographics.py +191 -0
- hccinfhir/model_dx_to_cc.py +70 -0
- hccinfhir/model_hierarchies.py +70 -0
- hccinfhir/model_interactions.py +342 -0
- hccinfhir/samples/__init__.py +2 -0
- {hccinfhir-0.0.2.dist-info → hccinfhir-0.0.4.dist-info}/METADATA +65 -7
- hccinfhir-0.0.4.dist-info/RECORD +39 -0
- hccinfhir/models.py +0 -44
- hccinfhir-0.0.2.dist-info/RECORD +0 -28
- /hccinfhir/{data → samples}/sample_837_0.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_1.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_10.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_11.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_2.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_3.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_4.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_5.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_6.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_7.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_8.txt +0 -0
- /hccinfhir/{data → samples}/sample_837_9.txt +0 -0
- /hccinfhir/{data → samples}/sample_eob_1.json +0 -0
- /hccinfhir/{data → samples}/sample_eob_2.json +0 -0
- /hccinfhir/{data → samples}/sample_eob_200.ndjson +0 -0
- /hccinfhir/{data → samples}/sample_eob_3.json +0 -0
- {hccinfhir-0.0.2.dist-info → hccinfhir-0.0.4.dist-info}/WHEEL +0 -0
- {hccinfhir-0.0.2.dist-info → hccinfhir-0.0.4.dist-info}/licenses/LICENSE +0 -0
hccinfhir/hccinfhir.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Union
|
|
2
|
+
from hccinfhir.extractor import extract_sld_list
|
|
3
|
+
from hccinfhir.filter import apply_filter
|
|
4
|
+
from hccinfhir.model_calculate import calculate_raf
|
|
5
|
+
from hccinfhir.datamodels import Demographics, ServiceLevelData, RAFResult, ModelName
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HCCInFHIR:
|
|
9
|
+
"""
|
|
10
|
+
Main class for processing FHIR EOB resources into HCC risk scores.
|
|
11
|
+
|
|
12
|
+
This class integrates the extraction, filtering, and calculation components
|
|
13
|
+
of the hccinfhir library.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self,
|
|
17
|
+
filter_claims: bool = True,
|
|
18
|
+
model_name: ModelName = "CMS-HCC Model V28"):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the HCCInFHIR processor.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
filter_claims: Whether to apply filtering rules to claims. Default is True.
|
|
24
|
+
model_name: The name of the model to use for the calculation. Default is "CMS-HCC Model V28".
|
|
25
|
+
"""
|
|
26
|
+
self.filter_claims = filter_claims
|
|
27
|
+
self.model_name = model_name
|
|
28
|
+
|
|
29
|
+
def _ensure_demographics(self, demographics: Union[Demographics, Dict[str, Any]]) -> Demographics:
|
|
30
|
+
"""Convert demographics dict to Demographics object if needed."""
|
|
31
|
+
if not isinstance(demographics, Demographics):
|
|
32
|
+
return Demographics(**demographics)
|
|
33
|
+
return demographics
|
|
34
|
+
|
|
35
|
+
def _calculate_raf_from_demographics(self, diagnosis_codes: List[str],
|
|
36
|
+
demographics: Demographics) -> Dict[str, Any]:
|
|
37
|
+
"""Calculate RAF score using demographics data."""
|
|
38
|
+
return calculate_raf(
|
|
39
|
+
diagnosis_codes=diagnosis_codes,
|
|
40
|
+
model_name=self.model_name,
|
|
41
|
+
age=demographics.age,
|
|
42
|
+
sex=demographics.sex,
|
|
43
|
+
dual_elgbl_cd=demographics.dual_elgbl_cd,
|
|
44
|
+
orec=demographics.orec,
|
|
45
|
+
crec=demographics.crec,
|
|
46
|
+
new_enrollee=demographics.new_enrollee,
|
|
47
|
+
snp=demographics.snp,
|
|
48
|
+
low_income=demographics.low_income,
|
|
49
|
+
graft_months=demographics.graft_months
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _get_unique_diagnosis_codes(self, service_data: List[ServiceLevelData]) -> List[str]:
|
|
53
|
+
"""Extract unique diagnosis codes from service level data."""
|
|
54
|
+
all_dx_codes = []
|
|
55
|
+
for sld in service_data:
|
|
56
|
+
all_dx_codes.extend(sld.claim_diagnosis_codes)
|
|
57
|
+
return list(set(all_dx_codes))
|
|
58
|
+
|
|
59
|
+
def _format_result(self,
|
|
60
|
+
raf_result: Union[Dict[str, Any], RAFResult],
|
|
61
|
+
service_data: List[ServiceLevelData]) -> RAFResult:
|
|
62
|
+
"""
|
|
63
|
+
Format RAF calculation results into a standardized RAFResult format.
|
|
64
|
+
|
|
65
|
+
Returns a dictionary conforming to the RAFResult TypedDict structure.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# Check if raf_result already has the expected RAFResult structure
|
|
69
|
+
if all(key in raf_result for key in ['risk_score', 'hcc_list', 'details']):
|
|
70
|
+
# Already in RAFResult format, just ensure service data is set
|
|
71
|
+
result = dict(raf_result) # Create a copy to avoid modifying the original
|
|
72
|
+
result['service_level_data'] = service_data
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
# Handle result from calculate_raf function
|
|
76
|
+
if 'raf' in raf_result and 'coefficients' in raf_result:
|
|
77
|
+
return {
|
|
78
|
+
'risk_score': raf_result['raf'],
|
|
79
|
+
'hcc_list': list(raf_result['coefficients'].keys()),
|
|
80
|
+
'details': raf_result['coefficients'],
|
|
81
|
+
'service_level_data': service_data
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Unrecognized format
|
|
85
|
+
raise ValueError(f"Unrecognized RAF result format: {list(raf_result.keys())}")
|
|
86
|
+
|
|
87
|
+
def run(self, eob_list: List[Dict[str, Any]],
|
|
88
|
+
demographics: Union[Demographics, Dict[str, Any]]) -> RAFResult:
|
|
89
|
+
demographics = self._ensure_demographics(demographics)
|
|
90
|
+
|
|
91
|
+
# Extract and filter service level data
|
|
92
|
+
sld_list = extract_sld_list(eob_list)
|
|
93
|
+
if self.filter_claims:
|
|
94
|
+
sld_list = apply_filter(sld_list)
|
|
95
|
+
|
|
96
|
+
# Calculate RAF score
|
|
97
|
+
unique_dx_codes = self._get_unique_diagnosis_codes(sld_list)
|
|
98
|
+
raf_result = self._calculate_raf_from_demographics(unique_dx_codes, demographics)
|
|
99
|
+
|
|
100
|
+
return self._format_result(raf_result, sld_list)
|
|
101
|
+
|
|
102
|
+
def run_from_service_data(self, service_data: List[Union[ServiceLevelData, Dict[str, Any]]],
|
|
103
|
+
demographics: Union[Demographics, Dict[str, Any]]) -> RAFResult:
|
|
104
|
+
demographics = self._ensure_demographics(demographics)
|
|
105
|
+
|
|
106
|
+
if not isinstance(service_data, list):
|
|
107
|
+
raise ValueError("Service data must be a list of service records")
|
|
108
|
+
|
|
109
|
+
if not service_data:
|
|
110
|
+
raise ValueError("Service data list cannot be empty")
|
|
111
|
+
|
|
112
|
+
# Standardize service data with better error handling
|
|
113
|
+
standardized_data = []
|
|
114
|
+
for idx, item in enumerate(service_data):
|
|
115
|
+
try:
|
|
116
|
+
if isinstance(item, dict):
|
|
117
|
+
standardized_data.append(ServiceLevelData(**item))
|
|
118
|
+
elif isinstance(item, ServiceLevelData):
|
|
119
|
+
standardized_data.append(item)
|
|
120
|
+
else:
|
|
121
|
+
raise TypeError(f"Service data item must be a dictionary or ServiceLevelData object")
|
|
122
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Invalid service data at index {idx}: {str(e)}. "
|
|
125
|
+
"Required fields: claim_type, claim_diagnosis_codes, procedure_code, service_date"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if self.filter_claims:
|
|
129
|
+
standardized_data = apply_filter(standardized_data)
|
|
130
|
+
|
|
131
|
+
# Calculate RAF score
|
|
132
|
+
unique_dx_codes = self._get_unique_diagnosis_codes(standardized_data)
|
|
133
|
+
raf_result = self._calculate_raf_from_demographics(unique_dx_codes, demographics)
|
|
134
|
+
|
|
135
|
+
return self._format_result(raf_result, standardized_data)
|
|
136
|
+
|
|
137
|
+
def calculate_from_diagnosis(self, diagnosis_codes: List[str],
|
|
138
|
+
demographics: Union[Demographics, Dict[str, Any]]) -> RAFResult:
|
|
139
|
+
demographics = self._ensure_demographics(demographics)
|
|
140
|
+
raf_result = self._calculate_raf_from_demographics(diagnosis_codes, demographics)
|
|
141
|
+
# Create an empty service level data list since we're calculating directly from diagnosis codes
|
|
142
|
+
return self._format_result(raf_result, [])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import List, Union
|
|
2
|
+
from hccinfhir.datamodels import ModelName
|
|
3
|
+
from hccinfhir.model_demographics import categorize_demographics
|
|
4
|
+
from hccinfhir.model_dx_to_cc import apply_mapping
|
|
5
|
+
from hccinfhir.model_hierarchies import apply_hierarchies
|
|
6
|
+
from hccinfhir.model_coefficients import apply_coefficients
|
|
7
|
+
from hccinfhir.model_interactions import apply_interactions
|
|
8
|
+
|
|
9
|
+
def calculate_raf(diagnosis_codes: List[str],
|
|
10
|
+
model_name: ModelName = "CMS-HCC Model V28",
|
|
11
|
+
age: Union[int, float] = 65,
|
|
12
|
+
sex: str = 'F',
|
|
13
|
+
dual_elgbl_cd: str = 'NA',
|
|
14
|
+
orec: str = '0',
|
|
15
|
+
crec: str = '0',
|
|
16
|
+
new_enrollee: bool = False,
|
|
17
|
+
snp: bool = False,
|
|
18
|
+
low_income: bool = False,
|
|
19
|
+
graft_months: int = None):
|
|
20
|
+
|
|
21
|
+
version = 'V2'
|
|
22
|
+
if 'RxHCC' in model_name:
|
|
23
|
+
version = 'V4'
|
|
24
|
+
elif 'HHS-HCC' in model_name: # not implemented yet
|
|
25
|
+
version = 'V6'
|
|
26
|
+
|
|
27
|
+
demographics = categorize_demographics(age,
|
|
28
|
+
sex,
|
|
29
|
+
dual_elgbl_cd,
|
|
30
|
+
orec,
|
|
31
|
+
crec,
|
|
32
|
+
version,
|
|
33
|
+
new_enrollee,
|
|
34
|
+
snp,
|
|
35
|
+
low_income,
|
|
36
|
+
graft_months)
|
|
37
|
+
|
|
38
|
+
cc_to_dx = apply_mapping(diagnosis_codes, model_name)
|
|
39
|
+
hcc_set = set(cc_to_dx.keys())
|
|
40
|
+
hcc_set = apply_hierarchies(hcc_set, model_name)
|
|
41
|
+
interactions = apply_interactions(demographics, hcc_set, model_name)
|
|
42
|
+
coefficients = apply_coefficients(demographics, hcc_set, interactions, model_name)
|
|
43
|
+
|
|
44
|
+
raf = sum(coefficients.values())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
return {'raf': raf, 'coefficients': coefficients}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from typing import Dict, Tuple
|
|
2
|
+
import importlib.resources
|
|
3
|
+
from hccinfhir.datamodels import ModelName, Demographics
|
|
4
|
+
|
|
5
|
+
# Load default mappings from csv file
|
|
6
|
+
coefficients_file_default = 'ra_coefficients_2025.csv'
|
|
7
|
+
coefficients_default: Dict[Tuple[str, ModelName], float] = {} # (diagnosis_code, model_name) -> value
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
with importlib.resources.open_text('hccinfhir.data', coefficients_file_default) as f:
|
|
11
|
+
for line in f.readlines()[1:]: # Skip header
|
|
12
|
+
try:
|
|
13
|
+
coefficient, value, model_domain, model_version = line.strip().split(',')
|
|
14
|
+
if model_domain == 'ESRD':
|
|
15
|
+
model_name = f"CMS-HCC {model_domain} Model V{model_version[-2:]}"
|
|
16
|
+
else:
|
|
17
|
+
model_name = f"{model_domain} Model V{model_version[-2:]}"
|
|
18
|
+
|
|
19
|
+
key = (coefficient.lower(), model_name)
|
|
20
|
+
if key not in coefficients_default:
|
|
21
|
+
coefficients_default[key] = float(value)
|
|
22
|
+
else:
|
|
23
|
+
coefficients_default[key] = float(value)
|
|
24
|
+
except ValueError:
|
|
25
|
+
continue # Skip malformed lines
|
|
26
|
+
except Exception as e:
|
|
27
|
+
print(f"Error loading mapping file: {e}")
|
|
28
|
+
coefficients_default = {}
|
|
29
|
+
|
|
30
|
+
def get_coefficent_prefix(demographics: Demographics,
|
|
31
|
+
model_name: ModelName = "CMS-HCC Model V28") -> str:
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
Get the coefficient prefix based on beneficiary demographics.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
demographics: Demographics object containing beneficiary information
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
String prefix used to look up coefficients for this beneficiary type
|
|
41
|
+
"""
|
|
42
|
+
# Get base prefix based on model type
|
|
43
|
+
if 'ESRD' in model_name:
|
|
44
|
+
if demographics.esrd:
|
|
45
|
+
if demographics.graft_months is not None:
|
|
46
|
+
# Functioning graft case
|
|
47
|
+
if demographics.lti:
|
|
48
|
+
return 'GI_'
|
|
49
|
+
if demographics.new_enrollee:
|
|
50
|
+
return 'GNE_'
|
|
51
|
+
|
|
52
|
+
# Community functioning graft
|
|
53
|
+
prefix = 'G'
|
|
54
|
+
prefix += 'F' if demographics.fbd else 'NP'
|
|
55
|
+
prefix += 'A' if demographics.age >= 65 else 'N'
|
|
56
|
+
return prefix + '_'
|
|
57
|
+
|
|
58
|
+
# Dialysis case
|
|
59
|
+
return 'DNE_' if demographics.new_enrollee else 'DI_'
|
|
60
|
+
|
|
61
|
+
# Transplant case
|
|
62
|
+
if demographics.graft_months in [1, 2, 3]:
|
|
63
|
+
return f'TRANSPLANT_KIDNEY_ONLY_{demographics.graft_months}M'
|
|
64
|
+
|
|
65
|
+
elif 'RxHCC' in model_name:
|
|
66
|
+
if demographics.lti:
|
|
67
|
+
return 'Rx_NE_LTI_' if demographics.new_enrollee else 'Rx_CE_LTI_'
|
|
68
|
+
|
|
69
|
+
if demographics.new_enrollee:
|
|
70
|
+
return 'Rx_NE_Lo_' if demographics.low_income else 'Rx_NE_NoLo_'
|
|
71
|
+
|
|
72
|
+
# Community case
|
|
73
|
+
prefix = 'Rx_CE_'
|
|
74
|
+
prefix += 'Low' if demographics.low_income else 'NoLow'
|
|
75
|
+
prefix += 'Aged' if demographics.age >= 65 else 'NoAged'
|
|
76
|
+
return prefix + '_'
|
|
77
|
+
|
|
78
|
+
# Default CMS-HCC Model
|
|
79
|
+
if demographics.lti:
|
|
80
|
+
return 'INS_'
|
|
81
|
+
|
|
82
|
+
if demographics.new_enrollee:
|
|
83
|
+
return 'SNPNE_' if demographics.snp else 'NE_'
|
|
84
|
+
|
|
85
|
+
# Community case
|
|
86
|
+
prefix = 'C'
|
|
87
|
+
prefix += 'F' if demographics.fbd else ('P' if demographics.pbd else 'N')
|
|
88
|
+
prefix += 'A' if demographics.age >= 65 else 'D'
|
|
89
|
+
return prefix + '_'
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def apply_coefficients(demographics: Demographics,
|
|
93
|
+
hcc_set: set[str],
|
|
94
|
+
interactions: dict,
|
|
95
|
+
model_name: ModelName = "CMS-HCC Model V28",
|
|
96
|
+
coefficients: Dict[Tuple[str, ModelName], float] = coefficients_default) -> dict:
|
|
97
|
+
"""Apply risk adjustment coefficients to HCCs and interactions.
|
|
98
|
+
|
|
99
|
+
This function takes demographic information, HCC codes, and interaction variables and returns
|
|
100
|
+
a dictionary mapping each variable to its corresponding coefficient value based on the
|
|
101
|
+
specified model.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
demographics: Demographics object containing patient characteristics
|
|
105
|
+
hcc_set: Set of HCC codes present for the patient
|
|
106
|
+
interactions: Dictionary of interaction variables and their values (0 or 1)
|
|
107
|
+
model_name: Name of the risk adjustment model to use (default: "CMS-HCC Model V28")
|
|
108
|
+
coefficients: Dictionary mapping (variable, model) tuples to coefficient values
|
|
109
|
+
(default: coefficients_default)
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Dictionary mapping HCC codes and interaction variables to their coefficient values
|
|
113
|
+
for variables that are present (HCC in hcc_set or interaction value = 1)
|
|
114
|
+
"""
|
|
115
|
+
# Get the coefficient prefix
|
|
116
|
+
prefix = get_coefficent_prefix(demographics, model_name)
|
|
117
|
+
|
|
118
|
+
output = {}
|
|
119
|
+
|
|
120
|
+
demographics_key = (f"{prefix}{demographics.category}".lower(), model_name)
|
|
121
|
+
if demographics_key in coefficients:
|
|
122
|
+
output[demographics.category] = coefficients[demographics_key]
|
|
123
|
+
|
|
124
|
+
# Apply the coefficients
|
|
125
|
+
for hcc in hcc_set:
|
|
126
|
+
key = (f"{prefix}HCC{hcc}".lower(), model_name)
|
|
127
|
+
|
|
128
|
+
if key in coefficients:
|
|
129
|
+
value = coefficients[key]
|
|
130
|
+
output[hcc] = value
|
|
131
|
+
|
|
132
|
+
# Add interactions
|
|
133
|
+
for interaction_key, interaction_value in interactions.items():
|
|
134
|
+
if interaction_value < 1:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
key = (f"{prefix}{interaction_key}".lower(), model_name)
|
|
138
|
+
if key in coefficients:
|
|
139
|
+
value = coefficients[key]
|
|
140
|
+
output[interaction_key] = value
|
|
141
|
+
|
|
142
|
+
return output
|
|
143
|
+
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
from hccinfhir.datamodels import Demographics
|
|
3
|
+
|
|
4
|
+
def categorize_demographics(age: Union[int, float],
|
|
5
|
+
sex: str,
|
|
6
|
+
dual_elgbl_cd: str = None,
|
|
7
|
+
orec: str = None,
|
|
8
|
+
crec: str = None,
|
|
9
|
+
version: str = 'V2',
|
|
10
|
+
new_enrollee: bool = False,
|
|
11
|
+
snp: bool = False,
|
|
12
|
+
low_income: bool = False,
|
|
13
|
+
graft_months: int = None
|
|
14
|
+
) -> Demographics:
|
|
15
|
+
"""
|
|
16
|
+
Categorize a beneficiary's demographics into risk adjustment categories.
|
|
17
|
+
|
|
18
|
+
This function takes demographic information about a beneficiary and returns a Demographics
|
|
19
|
+
object containing derived fields used in risk adjustment models.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
age: Beneficiary age (integer or float, will be floored to integer)
|
|
23
|
+
sex: Beneficiary sex ('M'/'F' or '1'/'2')
|
|
24
|
+
dual_elgbl_cd: Dual eligibility code ('00'-'10')
|
|
25
|
+
orec: Original reason for entitlement code ('0'-'3')
|
|
26
|
+
crec: Current reason for entitlement code ('0'-'3')
|
|
27
|
+
version: Version of categorization to use ('V2', 'V4', 'V6')
|
|
28
|
+
new_enrollee: Whether beneficiary is a new enrollee
|
|
29
|
+
snp: Whether beneficiary is in a Special Needs Plan
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Demographics object containing derived fields like age/sex category,
|
|
33
|
+
disability status, dual status flags, etc.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: If age is negative or non-numeric, or if sex is invalid
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
if not isinstance(age, (int, float)):
|
|
40
|
+
raise ValueError("Age must be a number")
|
|
41
|
+
|
|
42
|
+
if age < 0:
|
|
43
|
+
raise ValueError("Age must be non-negative")
|
|
44
|
+
|
|
45
|
+
# Convert to integer using floor
|
|
46
|
+
age = int(age)
|
|
47
|
+
non_aged = age <= 64
|
|
48
|
+
|
|
49
|
+
# Standardize sex input
|
|
50
|
+
if sex in ('M', '1'):
|
|
51
|
+
std_sex = '1' # For V2/V4
|
|
52
|
+
v6_sex = 'M' # For V6
|
|
53
|
+
elif sex in ('F', '2'):
|
|
54
|
+
std_sex = '2' # For V2/V4
|
|
55
|
+
v6_sex = 'F' # For V6
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError("Sex must be 'M', 'F', '1', or '2'")
|
|
58
|
+
|
|
59
|
+
# Determine if person is disabled or originally disabled
|
|
60
|
+
disabled = age < 65 and (orec is not None and orec != "0")
|
|
61
|
+
orig_disabled = (orec is not None and orec == '1') and not disabled
|
|
62
|
+
|
|
63
|
+
# Reference: https://resdac.org/cms-data/variables/medicare-medicaid-dual-eligibility-code-january
|
|
64
|
+
# Full benefit dual codes
|
|
65
|
+
fbd_codes = {'02', '04', '08'}
|
|
66
|
+
|
|
67
|
+
# Partial benefit dual codes
|
|
68
|
+
pbd_codes = {'01', '03', '05', '06'}
|
|
69
|
+
|
|
70
|
+
is_fbd = dual_elgbl_cd in fbd_codes
|
|
71
|
+
is_pbd = dual_elgbl_cd in pbd_codes
|
|
72
|
+
|
|
73
|
+
esrd_orec = orec in {'2', '3', '6'}
|
|
74
|
+
esrd_crec = crec in {'2', '3'} if crec else False
|
|
75
|
+
esrd = esrd_orec or esrd_crec
|
|
76
|
+
|
|
77
|
+
result_dict = {
|
|
78
|
+
'version': version,
|
|
79
|
+
'non_aged': non_aged,
|
|
80
|
+
'orig_disabled': orig_disabled,
|
|
81
|
+
'disabled': disabled,
|
|
82
|
+
'age': age,
|
|
83
|
+
'sex': std_sex if version in ('V2', 'V4') else v6_sex,
|
|
84
|
+
'dual_elgbl_cd': dual_elgbl_cd,
|
|
85
|
+
'orec': orec,
|
|
86
|
+
'crec': crec,
|
|
87
|
+
'new_enrollee': new_enrollee,
|
|
88
|
+
'snp': snp,
|
|
89
|
+
'fbd': is_fbd,
|
|
90
|
+
'pbd': is_pbd,
|
|
91
|
+
'esrd': esrd,
|
|
92
|
+
'graft_months': graft_months,
|
|
93
|
+
'low_income': low_income
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# V6 Logic (ACA Population)
|
|
97
|
+
if version == 'V6':
|
|
98
|
+
age_ranges = [
|
|
99
|
+
(0, 0, '0_0'),
|
|
100
|
+
(1, 1, '1_1'),
|
|
101
|
+
(2, 4, '2_4'),
|
|
102
|
+
(5, 9, '5_9'),
|
|
103
|
+
(10, 14, '10_14'),
|
|
104
|
+
(15, 20, '15_20'),
|
|
105
|
+
(21, 24, '21_24'),
|
|
106
|
+
(25, 29, '25_29'),
|
|
107
|
+
(30, 34, '30_34'),
|
|
108
|
+
(35, 39, '35_39'),
|
|
109
|
+
(40, 44, '40_44'),
|
|
110
|
+
(45, 49, '45_49'),
|
|
111
|
+
(50, 54, '50_54'),
|
|
112
|
+
(55, 59, '55_59'),
|
|
113
|
+
(60, float('inf'), '60_GT')
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
for low, high, label in age_ranges:
|
|
117
|
+
if low <= age <= high:
|
|
118
|
+
result_dict['category'] = f"{v6_sex}AGE_LAST_{label}"
|
|
119
|
+
return Demographics(**result_dict)
|
|
120
|
+
|
|
121
|
+
# V2/V4 Logic (Medicare Population)
|
|
122
|
+
elif version in ('V2', 'V4'):
|
|
123
|
+
if orec is None:
|
|
124
|
+
raise ValueError("OREC is required for V2/V4 categorization")
|
|
125
|
+
|
|
126
|
+
# New enrollee logic
|
|
127
|
+
if new_enrollee:
|
|
128
|
+
prefix = 'NEF' if std_sex == '2' else 'NEM'
|
|
129
|
+
|
|
130
|
+
if age <= 34:
|
|
131
|
+
category = f'{prefix}0_34'
|
|
132
|
+
elif 34 < age <= 44:
|
|
133
|
+
category = f'{prefix}35_44'
|
|
134
|
+
elif 44 < age <= 54:
|
|
135
|
+
category = f'{prefix}45_54'
|
|
136
|
+
elif 54 < age <= 59:
|
|
137
|
+
category = f'{prefix}55_59'
|
|
138
|
+
elif (59 < age <= 63) or (age == 64 and orec != '0'):
|
|
139
|
+
category = f'{prefix}60_64'
|
|
140
|
+
elif (age == 64 and orec == '0') or age == 65:
|
|
141
|
+
category = f'{prefix}65'
|
|
142
|
+
elif age == 66:
|
|
143
|
+
category = f'{prefix}66'
|
|
144
|
+
elif age == 67:
|
|
145
|
+
category = f'{prefix}67'
|
|
146
|
+
elif age == 68:
|
|
147
|
+
category = f'{prefix}68'
|
|
148
|
+
elif age == 69:
|
|
149
|
+
category = f'{prefix}69'
|
|
150
|
+
elif 69 < age <= 74:
|
|
151
|
+
category = f'{prefix}70_74'
|
|
152
|
+
elif 74 < age <= 79:
|
|
153
|
+
category = f'{prefix}75_79'
|
|
154
|
+
elif 79 < age <= 84:
|
|
155
|
+
category = f'{prefix}80_84'
|
|
156
|
+
elif 84 < age <= 89:
|
|
157
|
+
category = f'{prefix}85_89'
|
|
158
|
+
elif 89 < age <= 94:
|
|
159
|
+
category = f'{prefix}90_94'
|
|
160
|
+
else:
|
|
161
|
+
category = f'{prefix}95_GT'
|
|
162
|
+
|
|
163
|
+
else:
|
|
164
|
+
prefix = 'F' if std_sex == '2' else 'M'
|
|
165
|
+
age_ranges = [
|
|
166
|
+
(0, 34, '0_34'),
|
|
167
|
+
(34, 44, '35_44'),
|
|
168
|
+
(44, 54, '45_54'),
|
|
169
|
+
(54, 59, '55_59'),
|
|
170
|
+
(59, 64, '60_64'),
|
|
171
|
+
(64, 69, '65_69'),
|
|
172
|
+
(69, 74, '70_74'),
|
|
173
|
+
(74, 79, '75_79'),
|
|
174
|
+
(79, 84, '80_84'),
|
|
175
|
+
(84, 89, '85_89'),
|
|
176
|
+
(89, 94, '90_94'),
|
|
177
|
+
(94, float('inf'), '95_GT')
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
for low, high, suffix in age_ranges:
|
|
181
|
+
if low < age <= high:
|
|
182
|
+
category = f'{prefix}{suffix}'
|
|
183
|
+
break
|
|
184
|
+
else:
|
|
185
|
+
raise ValueError(f"Unable to categorize age: {age}")
|
|
186
|
+
|
|
187
|
+
result_dict['category'] = category
|
|
188
|
+
return Demographics(**result_dict)
|
|
189
|
+
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError("Version must be 'V2', 'V4', or 'V6'")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import List, Dict, Set, Tuple, Optional
|
|
2
|
+
import importlib.resources
|
|
3
|
+
from hccinfhir.datamodels import ModelName
|
|
4
|
+
|
|
5
|
+
# Load default mappings from csv file
|
|
6
|
+
mapping_file_default = 'ra_dx_to_cc_2025.csv'
|
|
7
|
+
dx_to_cc_default: Dict[Tuple[str, ModelName], Set[str]] = {} # (diagnosis_code, model_name) -> cc
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
with importlib.resources.open_text('hccinfhir.data', mapping_file_default) as f:
|
|
11
|
+
for line in f.readlines()[1:]: # Skip header
|
|
12
|
+
try:
|
|
13
|
+
diagnosis_code, cc, model_name = line.strip().split(',')
|
|
14
|
+
key = (diagnosis_code, model_name)
|
|
15
|
+
if key not in dx_to_cc_default:
|
|
16
|
+
dx_to_cc_default[key] = {cc}
|
|
17
|
+
else:
|
|
18
|
+
dx_to_cc_default[key].add(cc)
|
|
19
|
+
except ValueError:
|
|
20
|
+
continue # Skip malformed lines
|
|
21
|
+
except Exception as e:
|
|
22
|
+
print(f"Error loading mapping file: {e}")
|
|
23
|
+
dx_to_cc_default = {}
|
|
24
|
+
|
|
25
|
+
def get_cc(
|
|
26
|
+
diagnosis_code: str,
|
|
27
|
+
model_name: ModelName = "CMS-HCC Model V28",
|
|
28
|
+
dx_to_cc: Dict[Tuple[str, str], Set[str]] = dx_to_cc_default
|
|
29
|
+
) -> Optional[Set[str]]:
|
|
30
|
+
"""
|
|
31
|
+
Get CC for a single diagnosis code.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
diagnosis_code: ICD-10 diagnosis code
|
|
35
|
+
model_name: HCC model name to use for mapping
|
|
36
|
+
dx_to_cc: Optional custom mapping dictionary
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
CC code if found, None otherwise
|
|
40
|
+
"""
|
|
41
|
+
return dx_to_cc.get((diagnosis_code, model_name))
|
|
42
|
+
|
|
43
|
+
def apply_mapping(
|
|
44
|
+
diagnoses: List[str],
|
|
45
|
+
model_name: ModelName = "CMS-HCC Model V28",
|
|
46
|
+
dx_to_cc: Dict[Tuple[str, str], Set[str]] = dx_to_cc_default
|
|
47
|
+
) -> Dict[str, Set[str]]:
|
|
48
|
+
"""
|
|
49
|
+
Apply ICD-10 to CC mapping for a list of diagnosis codes.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
diagnoses: List of ICD-10 diagnosis codes
|
|
53
|
+
model_name: HCC model name to use for mapping
|
|
54
|
+
dx_to_cc: Optional custom mapping dictionary
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dictionary mapping CCs to lists of diagnosis codes that map to them
|
|
58
|
+
"""
|
|
59
|
+
cc_to_dx: Dict[str, Set[str]] = {}
|
|
60
|
+
|
|
61
|
+
for dx in set(diagnoses):
|
|
62
|
+
dx = dx.upper().replace('.', '')
|
|
63
|
+
ccs = get_cc(dx, model_name, dx_to_cc)
|
|
64
|
+
if ccs is not None:
|
|
65
|
+
for cc in ccs:
|
|
66
|
+
if cc not in cc_to_dx:
|
|
67
|
+
cc_to_dx[cc] = set()
|
|
68
|
+
cc_to_dx[cc].add(dx)
|
|
69
|
+
|
|
70
|
+
return cc_to_dx
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Dict, Set, Tuple
|
|
2
|
+
import importlib.resources
|
|
3
|
+
from hccinfhir.datamodels import ModelName
|
|
4
|
+
|
|
5
|
+
# Load default mappings from csv file
|
|
6
|
+
hierarchies_file_default = 'ra_hierarchies_2025.csv'
|
|
7
|
+
hierarchies_default: Dict[Tuple[str, ModelName], Set[str]] = {} # (diagnosis_code, model_name) -> {cc}
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
with importlib.resources.open_text('hccinfhir.data', hierarchies_file_default) as f:
|
|
11
|
+
for line in f.readlines()[1:]: # Skip header
|
|
12
|
+
try:
|
|
13
|
+
cc_parent, cc_child, model_domain, model_version, _ = line.strip().split(',')
|
|
14
|
+
if model_domain == 'ESRD':
|
|
15
|
+
model_name = f"CMS-HCC {model_domain} Model {model_version}"
|
|
16
|
+
else:
|
|
17
|
+
model_name = f"{model_domain} Model {model_version}"
|
|
18
|
+
key = (cc_parent, model_name)
|
|
19
|
+
if key not in hierarchies_default:
|
|
20
|
+
hierarchies_default[key] = {cc_child}
|
|
21
|
+
else:
|
|
22
|
+
hierarchies_default[key].add(cc_child)
|
|
23
|
+
except ValueError:
|
|
24
|
+
continue # Skip malformed lines
|
|
25
|
+
except Exception as e:
|
|
26
|
+
print(f"Error loading mapping file: {e}")
|
|
27
|
+
hierarchies_default = {}
|
|
28
|
+
|
|
29
|
+
def apply_hierarchies(
|
|
30
|
+
cc_set: Set[str], # Set of active CCs
|
|
31
|
+
model_name: ModelName = "CMS-HCC Model V28",
|
|
32
|
+
hierarchies: Dict[Tuple[str, ModelName], Set[str]] = hierarchies_default
|
|
33
|
+
) -> Set[str]:
|
|
34
|
+
"""
|
|
35
|
+
Apply hierarchical rules to a set of CCs based on model version.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
ccs: Set of current active CCs
|
|
39
|
+
model_name: HCC model name to use for hierarchy rules
|
|
40
|
+
hierarchies: Optional custom hierarchy dictionary
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Set of CCs after applying hierarchies
|
|
44
|
+
"""
|
|
45
|
+
# Track CCs that should be zeroed out
|
|
46
|
+
to_remove = set()
|
|
47
|
+
|
|
48
|
+
# For V28, if none of 221, 222, 224, 225, 226 are present, remove 223
|
|
49
|
+
if model_name == "CMS-HCC Model V28":
|
|
50
|
+
if ("223" in cc_set and
|
|
51
|
+
not any(cc in cc_set for cc in ["221", "222", "224", "225", "226"])):
|
|
52
|
+
cc_set.remove("223")
|
|
53
|
+
elif model_name == "CMS-HCC ESRD Model V21":
|
|
54
|
+
if "134" in cc_set:
|
|
55
|
+
cc_set.remove("134")
|
|
56
|
+
elif model_name == "CMS-HCC ESRD Model V24":
|
|
57
|
+
for cc in ["134", "135", "136", "137"]:
|
|
58
|
+
if cc in cc_set:
|
|
59
|
+
cc_set.remove(cc)
|
|
60
|
+
|
|
61
|
+
# Apply hierarchies
|
|
62
|
+
for cc in cc_set:
|
|
63
|
+
hierarchy_key = (cc, model_name)
|
|
64
|
+
if hierarchy_key in hierarchies:
|
|
65
|
+
# If parent CC exists, remove all child CCs
|
|
66
|
+
child_ccs = hierarchies[hierarchy_key]
|
|
67
|
+
to_remove.update(child_ccs & cc_set)
|
|
68
|
+
|
|
69
|
+
# Return CCs with hierarchical exclusions removed
|
|
70
|
+
return cc_set - to_remove
|