mrfkit 0.1.0__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.
- mrfkit/__init__.py +68 -0
- mrfkit/__main__.py +3 -0
- mrfkit/cli.py +79 -0
- mrfkit/codes.py +1607 -0
- mrfkit/csv_reader.py +324 -0
- mrfkit/files.py +651 -0
- mrfkit/headers.py +737 -0
- mrfkit/json_reader.py +603 -0
- mrfkit/payers.py +2755 -0
- mrfkit/records.py +260 -0
- mrfkit/reference.py +136 -0
- mrfkit/sinks.py +126 -0
- mrfkit/tabular.py +651 -0
- mrfkit/tic.py +660 -0
- mrfkit/values.py +627 -0
- mrfkit-0.1.0.dist-info/METADATA +136 -0
- mrfkit-0.1.0.dist-info/RECORD +21 -0
- mrfkit-0.1.0.dist-info/WHEEL +4 -0
- mrfkit-0.1.0.dist-info/entry_points.txt +2 -0
- mrfkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- mrfkit-0.1.0.dist-info/licenses/NOTICE +4 -0
mrfkit/tabular.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Turn table rows into records.
|
|
2
|
+
|
|
3
|
+
A "table" is a CSV file, or the flat objects of a JSON file that does not use
|
|
4
|
+
the CMS nested layout. Both have a header (column names or object keys) and
|
|
5
|
+
rows of values, and hospitals use the same layouts in both: tall (one payer
|
|
6
|
+
per row), CMS wide (one column group per payer and plan), simple wide (one
|
|
7
|
+
column per payer), and a few vendor-specific shapes.
|
|
8
|
+
|
|
9
|
+
:class:`TableLayout` reads the header once and decides how to read every row.
|
|
10
|
+
:meth:`TableLayout.rows_to_records` then turns each row into records.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
from .codes import (
|
|
20
|
+
apply_baked_modifier_split,
|
|
21
|
+
apply_code_extraction,
|
|
22
|
+
infer_billing_class,
|
|
23
|
+
is_rejected_code,
|
|
24
|
+
merge_modifier_into_field,
|
|
25
|
+
normalize_code,
|
|
26
|
+
)
|
|
27
|
+
from .headers import (
|
|
28
|
+
ATRIUM_SETTING_COLUMNS,
|
|
29
|
+
CANONICAL_FIELDS,
|
|
30
|
+
HAWAII_SETTING_COLUMNS,
|
|
31
|
+
_ATRIUM_DETECT_COLUMNS,
|
|
32
|
+
_HAWAII_DETECT_COLUMNS,
|
|
33
|
+
_is_unmapped_header_suppressed,
|
|
34
|
+
map_header,
|
|
35
|
+
normalize_header,
|
|
36
|
+
parse_hawaii_payer_header,
|
|
37
|
+
parse_wide_payer_header,
|
|
38
|
+
)
|
|
39
|
+
from .payers import is_self_pay_payer, is_valid_payer_name, normalize_payer_name, normalize_plan_name
|
|
40
|
+
from .records import ChargeItem, HeaderMapping, PayerRate, StandardCharge, UnmappedCell
|
|
41
|
+
from .reference import ParseStats, ReferenceData
|
|
42
|
+
from .values import (
|
|
43
|
+
_SIMPLE_WIDE_PEEK_ROWS,
|
|
44
|
+
_clean_methodology,
|
|
45
|
+
_column_looks_like_payer_rates,
|
|
46
|
+
_is_null_rate_token,
|
|
47
|
+
_safe_count,
|
|
48
|
+
_safe_float,
|
|
49
|
+
hoist_numeric_methodology,
|
|
50
|
+
normalize_methodology,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
log = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
# At most this many unmapped cells are kept per column. A mis-detected column
|
|
56
|
+
# would otherwise repeat on every row of a multi-million-row file.
|
|
57
|
+
UNMAPPED_CELLS_PER_COLUMN_CAP = 2000
|
|
58
|
+
|
|
59
|
+
# Simple wide format needs at least this many payer-looking columns.
|
|
60
|
+
SIMPLE_WIDE_MIN_PAYER_COLS = 3
|
|
61
|
+
PEEK_ROWS = _SIMPLE_WIDE_PEEK_ROWS
|
|
62
|
+
|
|
63
|
+
# Price-bearing fields. A row with any of them is data, never a section header.
|
|
64
|
+
_PRICE_FIELDS = frozenset({
|
|
65
|
+
'gross_charge', 'discounted_cash_price', 'negotiated_rate',
|
|
66
|
+
'min_negotiated_rate', 'max_negotiated_rate', 'estimated_amount',
|
|
67
|
+
'median_amount',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
# Partners Healthcare style files: ip_* columns map to fields, op_* columns
|
|
71
|
+
# fill in when the ip_* value is empty or zero.
|
|
72
|
+
_OP_FALLBACK_MAP = {
|
|
73
|
+
'negotiated_rate': ['op_price'],
|
|
74
|
+
'min_negotiated_rate': ['min_op_reimb'],
|
|
75
|
+
'max_negotiated_rate': ['max_op_reimb'],
|
|
76
|
+
'estimated_amount': ['op_expected_reimbursement'],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_ZERO = ('', '0', '0.00')
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SectionBoundary(Exception):
|
|
83
|
+
"""A later section of a stacked multi-table CSV starts at this row."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cell(row: Mapping[str, Any], key: Optional[str]) -> str:
|
|
87
|
+
"""Return a cell as stripped text: '' for missing, JSON for nested values."""
|
|
88
|
+
if key is None:
|
|
89
|
+
return ''
|
|
90
|
+
value = row.get(key)
|
|
91
|
+
if value is None:
|
|
92
|
+
return ''
|
|
93
|
+
if isinstance(value, str):
|
|
94
|
+
return value.strip()
|
|
95
|
+
if isinstance(value, (dict, list)):
|
|
96
|
+
return json.dumps(value, default=str)
|
|
97
|
+
return str(value).strip()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _number(text: str) -> Optional[float]:
|
|
101
|
+
"""Parse a price cell, treating empty and N/A as missing."""
|
|
102
|
+
if not text or text.upper() == 'N/A':
|
|
103
|
+
return None
|
|
104
|
+
return _safe_float(text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _strip_nuls(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
108
|
+
"""Drop NUL characters: they are never meaningful and break most consumers."""
|
|
109
|
+
if not any(isinstance(v, str) and '\x00' in v for v in row.values()):
|
|
110
|
+
return row
|
|
111
|
+
return {k: v.replace('\x00', '') if isinstance(v, str) else v for k, v in row.items()}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _normalize_billing_class(value: Optional[str]) -> str:
|
|
115
|
+
value = (value or '').strip().lower()
|
|
116
|
+
return value if value in ('facility', 'professional') else ''
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RecordBuilder:
|
|
120
|
+
"""Shared record building: item dedup, code cleanup, payer rate cleanup.
|
|
121
|
+
|
|
122
|
+
One instance per file, so a charge item is emitted once per file.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, *, code_extraction: Optional[Mapping[str, Any]] = None,
|
|
126
|
+
ref: Optional[ReferenceData] = None, stats: Optional[ParseStats] = None):
|
|
127
|
+
self.ref = ref
|
|
128
|
+
self.stats = stats
|
|
129
|
+
self._code_config = {'code_extraction': dict(code_extraction)} if code_extraction else None
|
|
130
|
+
self._seen_items: set = set()
|
|
131
|
+
|
|
132
|
+
def clean_code(self, code, code_type, description):
|
|
133
|
+
"""Run one (code, code_type) through extraction, normalization and the
|
|
134
|
+
baked-modifier split. Return ``(code, code_type, baked_modifier)``, or
|
|
135
|
+
None when the pair is too corrupt to keep."""
|
|
136
|
+
code, code_type = apply_code_extraction(code or None, code_type or None, self._code_config)
|
|
137
|
+
code, code_type = normalize_code(code or None, code_type or None)
|
|
138
|
+
code, code_type, baked = apply_baked_modifier_split(
|
|
139
|
+
code, code_type, description=description, ref=self.ref, stats=self.stats)
|
|
140
|
+
if is_rejected_code(code, code_type, stats=self.stats):
|
|
141
|
+
return None
|
|
142
|
+
return code, code_type, baked
|
|
143
|
+
|
|
144
|
+
def effective(self, code, code_type, description, billing_class):
|
|
145
|
+
"""Apply billing-class inference, which can also fix the code and type."""
|
|
146
|
+
inferred_bc, normalized_code, inferred_ct = infer_billing_class(
|
|
147
|
+
code, code_type, description, billing_class)
|
|
148
|
+
return (normalized_code if normalized_code != code else code,
|
|
149
|
+
inferred_ct if inferred_ct != code_type else code_type,
|
|
150
|
+
billing_class or inferred_bc or '')
|
|
151
|
+
|
|
152
|
+
def item(self, code, code_type, description, billing_class, setting, modifiers,
|
|
153
|
+
data) -> Iterator[ChargeItem]:
|
|
154
|
+
"""Yield the charge item unless this file already produced its key."""
|
|
155
|
+
key = (code, code_type, billing_class or '', setting or '', modifiers or '')
|
|
156
|
+
if key in self._seen_items:
|
|
157
|
+
return
|
|
158
|
+
self._seen_items.add(key)
|
|
159
|
+
yield ChargeItem(
|
|
160
|
+
code, code_type, description, billing_class or '', setting or '', modifiers,
|
|
161
|
+
drug_unit_of_measurement=data.get('drug_unit_of_measurement'),
|
|
162
|
+
drug_type_of_measurement=data.get('drug_type_of_measurement'),
|
|
163
|
+
additional_generic_notes=data.get('additional_generic_notes'),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def rate(self, code, code_type, description, billing_class, setting, modifiers, *,
|
|
167
|
+
payer, plan, rate_billing_class, rate_setting, methodology=None,
|
|
168
|
+
negotiated_rate=None, negotiated_percentage=None, negotiated_algorithm=None,
|
|
169
|
+
estimated_amount=None, additional_notes=None, footnote=None,
|
|
170
|
+
median_amount=None, pct_10=None, pct_90=None, claim_count=None,
|
|
171
|
+
) -> Iterator[PayerRate]:
|
|
172
|
+
"""Yield a payer rate with normalized payer, plan and methodology."""
|
|
173
|
+
payer_name, plan_name = normalize_payer_name(payer, plan, ref=self.ref)
|
|
174
|
+
if not payer_name:
|
|
175
|
+
return
|
|
176
|
+
plan_category, plan_network, plan_name = normalize_plan_name(plan_name, payer_name)
|
|
177
|
+
methodology_type = normalize_methodology(methodology, ref=self.ref)
|
|
178
|
+
methodology = _clean_methodology(methodology)
|
|
179
|
+
methodology, methodology_type, negotiated_percentage = hoist_numeric_methodology(
|
|
180
|
+
methodology, methodology_type, negotiated_rate, negotiated_percentage,
|
|
181
|
+
negotiated_algorithm,
|
|
182
|
+
)
|
|
183
|
+
yield PayerRate(
|
|
184
|
+
code, code_type, description, billing_class or '', setting or '', modifiers,
|
|
185
|
+
payer_name=payer_name, raw_payer_name=payer, plan_name=plan_name,
|
|
186
|
+
plan_category=plan_category or 'Other', plan_network=plan_network,
|
|
187
|
+
negotiated_rate=negotiated_rate, negotiated_percentage=negotiated_percentage,
|
|
188
|
+
negotiated_algorithm=negotiated_algorithm, methodology=methodology,
|
|
189
|
+
methodology_type=methodology_type, estimated_amount=estimated_amount,
|
|
190
|
+
rate_billing_class=rate_billing_class or '', rate_setting=rate_setting or '',
|
|
191
|
+
additional_notes=additional_notes, footnote=footnote,
|
|
192
|
+
median_amount=median_amount, pct_10=pct_10, pct_90=pct_90, claim_count=claim_count,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TableLayout(RecordBuilder):
|
|
197
|
+
"""How to read the rows of one table, decided from its header.
|
|
198
|
+
|
|
199
|
+
*peek_rows* are the first data rows. They are only used to spot the
|
|
200
|
+
simple wide format; the caller still passes them to
|
|
201
|
+
:meth:`rows_to_records`.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
headers: Sequence[str],
|
|
207
|
+
peek_rows: Sequence[Mapping[str, Any]] = (),
|
|
208
|
+
*,
|
|
209
|
+
extra_synonyms: Optional[Mapping[str, str]] = None,
|
|
210
|
+
header_overrides: Optional[Mapping[str, str]] = None,
|
|
211
|
+
code_extraction: Optional[Mapping[str, Any]] = None,
|
|
212
|
+
ref: Optional[ReferenceData] = None,
|
|
213
|
+
stats: Optional[ParseStats] = None,
|
|
214
|
+
):
|
|
215
|
+
super().__init__(code_extraction=code_extraction, ref=ref, stats=stats)
|
|
216
|
+
self.headers = list(headers)
|
|
217
|
+
self.mappings: Dict[str, HeaderMapping] = {}
|
|
218
|
+
|
|
219
|
+
canonical_map: Dict[str, str] = {}
|
|
220
|
+
unmapped: List[str] = []
|
|
221
|
+
self.wide_payer_groups: Dict[Tuple[str, str], Dict[str, str]] = {}
|
|
222
|
+
|
|
223
|
+
for header in self.headers:
|
|
224
|
+
wide = parse_wide_payer_header(header)
|
|
225
|
+
if wide:
|
|
226
|
+
payer, plan, rate_field = wide
|
|
227
|
+
self.wide_payer_groups.setdefault((payer, plan), {})[rate_field] = header
|
|
228
|
+
self._map(header, f'wide_payer:{rate_field}')
|
|
229
|
+
continue
|
|
230
|
+
_normalized, field = map_header(header)
|
|
231
|
+
self._map(header, field)
|
|
232
|
+
if field:
|
|
233
|
+
canonical_map[header] = field
|
|
234
|
+
else:
|
|
235
|
+
unmapped.append(header)
|
|
236
|
+
|
|
237
|
+
# Caller overrides beat synonyms: exact header first, then normalized.
|
|
238
|
+
for source, lookup in (('override', header_overrides or {}),
|
|
239
|
+
('synonym', extra_synonyms or {})):
|
|
240
|
+
if not lookup or not unmapped:
|
|
241
|
+
continue
|
|
242
|
+
still, applied = [], []
|
|
243
|
+
for header in unmapped:
|
|
244
|
+
key = header if source == 'override' else normalize_header(header)
|
|
245
|
+
target = lookup.get(key)
|
|
246
|
+
if target in CANONICAL_FIELDS:
|
|
247
|
+
canonical_map[header] = target
|
|
248
|
+
self._map(header, target)
|
|
249
|
+
applied.append(f"{header} -> {target}")
|
|
250
|
+
else:
|
|
251
|
+
if target:
|
|
252
|
+
log.warning("Ignoring %s for %r: %r is not a canonical field",
|
|
253
|
+
source, header, target)
|
|
254
|
+
still.append(header)
|
|
255
|
+
unmapped = still
|
|
256
|
+
if applied:
|
|
257
|
+
log.info("Header %ss applied: %s", source, applied)
|
|
258
|
+
|
|
259
|
+
self.has_payer_column = 'payer_name' in canonical_map.values()
|
|
260
|
+
|
|
261
|
+
# ---- code columns: one pair per code|N column, or a single pair ----
|
|
262
|
+
code_headers = [h for h, f in canonical_map.items() if f == 'code']
|
|
263
|
+
code_type_headers = [h for h, f in canonical_map.items() if f == 'code_type']
|
|
264
|
+
self.fields = {h: f for h, f in canonical_map.items() if f not in ('code', 'code_type')}
|
|
265
|
+
self.code_pairs: List[Tuple[Optional[str], Optional[str]]] = []
|
|
266
|
+
if len(code_headers) > 1:
|
|
267
|
+
log.info("Multi-code detected: %d code columns", len(code_headers))
|
|
268
|
+
type_by_index = {}
|
|
269
|
+
for h in code_type_headers:
|
|
270
|
+
parts = [p.strip() for p in h.split('|')]
|
|
271
|
+
if len(parts) >= 3:
|
|
272
|
+
type_by_index[parts[1]] = h
|
|
273
|
+
for h in code_headers:
|
|
274
|
+
parts = [p.strip() for p in h.split('|')]
|
|
275
|
+
if len(parts) >= 2:
|
|
276
|
+
self.code_pairs.append((h, type_by_index.get(parts[1])))
|
|
277
|
+
else:
|
|
278
|
+
self.code_pairs.append((h, code_type_headers[0] if code_type_headers else None))
|
|
279
|
+
elif code_headers or code_type_headers:
|
|
280
|
+
self.code_pairs.append((code_headers[0] if code_headers else None,
|
|
281
|
+
code_type_headers[0] if code_type_headers else None))
|
|
282
|
+
|
|
283
|
+
# ---- outpatient fallback columns ----
|
|
284
|
+
mapped_fields = set(canonical_map.values())
|
|
285
|
+
unmapped_by_norm = {normalize_header(h): h for h in unmapped}
|
|
286
|
+
self.op_fallbacks: Dict[str, str] = {}
|
|
287
|
+
for field, candidates in _OP_FALLBACK_MAP.items():
|
|
288
|
+
if field in mapped_fields:
|
|
289
|
+
for candidate in candidates:
|
|
290
|
+
if candidate in unmapped_by_norm:
|
|
291
|
+
self.op_fallbacks[field] = unmapped_by_norm[candidate]
|
|
292
|
+
break
|
|
293
|
+
|
|
294
|
+
# ---- drop low-value columns from unmapped tracking ----
|
|
295
|
+
suppressed = sorted(normalize_header(h) for h in unmapped
|
|
296
|
+
if _is_unmapped_header_suppressed(normalize_header(h)))
|
|
297
|
+
if suppressed:
|
|
298
|
+
unmapped = [h for h in unmapped if not _is_unmapped_header_suppressed(normalize_header(h))]
|
|
299
|
+
log.info("Suppressed %d low-value unmapped columns from cell tracking: %s",
|
|
300
|
+
len(suppressed), suppressed)
|
|
301
|
+
|
|
302
|
+
# ---- Hawaii: setting-specific charge columns, payer_setting_method columns ----
|
|
303
|
+
norm_set = {normalize_header(h) for h in unmapped}
|
|
304
|
+
self.hawaii = _HAWAII_DETECT_COLUMNS.issubset(norm_set)
|
|
305
|
+
self.hawaii_settings: Dict[str, Dict[str, str]] = {}
|
|
306
|
+
self.hawaii_payers: List[Tuple[str, str, str, str]] = []
|
|
307
|
+
if self.hawaii:
|
|
308
|
+
by_norm = {normalize_header(h): h for h in unmapped}
|
|
309
|
+
consumed = set()
|
|
310
|
+
for norm, (field, setting) in HAWAII_SETTING_COLUMNS.items():
|
|
311
|
+
if norm in by_norm:
|
|
312
|
+
self.hawaii_settings.setdefault(setting, {})[field] = by_norm[norm]
|
|
313
|
+
consumed.add(by_norm[norm])
|
|
314
|
+
self._map(by_norm[norm], f'hawaii_wide:{field}:{setting}')
|
|
315
|
+
for h in unmapped:
|
|
316
|
+
if h in consumed:
|
|
317
|
+
continue
|
|
318
|
+
parsed = parse_hawaii_payer_header(h)
|
|
319
|
+
if parsed:
|
|
320
|
+
payer, setting, methodology = parsed
|
|
321
|
+
self.hawaii_payers.append((h, payer, setting, methodology))
|
|
322
|
+
consumed.add(h)
|
|
323
|
+
self._map(h, f'hawaii_payer:{setting}:{methodology}')
|
|
324
|
+
unmapped = [h for h in unmapped if h not in consumed]
|
|
325
|
+
log.info("Hawaii wide format detected: %d settings, %d payer columns",
|
|
326
|
+
len(self.hawaii_settings), len(self.hawaii_payers))
|
|
327
|
+
|
|
328
|
+
# ---- Atrium: setting-specific charge columns plus a Min/Max flag ----
|
|
329
|
+
self.atrium = not self.hawaii and _ATRIUM_DETECT_COLUMNS.issubset(norm_set)
|
|
330
|
+
self.atrium_settings: Dict[str, Dict[str, str]] = {}
|
|
331
|
+
self.atrium_minmax: Optional[str] = None
|
|
332
|
+
if self.atrium:
|
|
333
|
+
by_norm = {normalize_header(h): h for h in unmapped}
|
|
334
|
+
consumed = set()
|
|
335
|
+
for norm, (field, setting) in ATRIUM_SETTING_COLUMNS.items():
|
|
336
|
+
if norm in by_norm:
|
|
337
|
+
self.atrium_settings.setdefault(setting, {})[field] = by_norm[norm]
|
|
338
|
+
consumed.add(by_norm[norm])
|
|
339
|
+
self._map(by_norm[norm], f'atrium_wide:{field}:{setting}')
|
|
340
|
+
if 'min_max' in by_norm:
|
|
341
|
+
self.atrium_minmax = by_norm['min_max']
|
|
342
|
+
consumed.add(self.atrium_minmax)
|
|
343
|
+
self._map(self.atrium_minmax, 'atrium_wide:min_max_indicator')
|
|
344
|
+
unmapped = [h for h in unmapped if h not in consumed]
|
|
345
|
+
norm_set = {normalize_header(h) for h in unmapped}
|
|
346
|
+
log.info("Atrium wide format detected: %d settings, min/max=%s",
|
|
347
|
+
len(self.atrium_settings), 'yes' if self.atrium_minmax else 'no')
|
|
348
|
+
|
|
349
|
+
# ---- Paris style: an extra outpatient gross price column ----
|
|
350
|
+
self.outpatient_price: Optional[str] = None
|
|
351
|
+
if not self.hawaii and not self.atrium and 'outpatient_price' in norm_set:
|
|
352
|
+
self.outpatient_price = {normalize_header(h): h for h in unmapped}['outpatient_price']
|
|
353
|
+
unmapped = [h for h in unmapped if h != self.outpatient_price]
|
|
354
|
+
self._map(self.outpatient_price, 'outpatient_gross:gross_charge:outpatient')
|
|
355
|
+
log.info("Outpatient price column detected: %r", self.outpatient_price)
|
|
356
|
+
|
|
357
|
+
# ---- simple wide: one plain column per payer, values are dollars ----
|
|
358
|
+
if (not self.wide_payer_groups and not self.has_payer_column
|
|
359
|
+
and len(unmapped) >= SIMPLE_WIDE_MIN_PAYER_COLS and peek_rows):
|
|
360
|
+
payer_cols = [h for h in unmapped
|
|
361
|
+
if _column_looks_like_payer_rates(cell(r, h) for r in peek_rows)]
|
|
362
|
+
if len(payer_cols) >= SIMPLE_WIDE_MIN_PAYER_COLS:
|
|
363
|
+
for h in payer_cols:
|
|
364
|
+
if is_valid_payer_name(h.strip()):
|
|
365
|
+
self.wide_payer_groups[(h.strip(), '')] = {'negotiated_rate': h}
|
|
366
|
+
self._map(h, 'wide_payer:negotiated_rate')
|
|
367
|
+
taken = set(payer_cols)
|
|
368
|
+
unmapped = [h for h in unmapped if h not in taken]
|
|
369
|
+
log.info("Simple wide format: %d payer columns auto-detected", len(payer_cols))
|
|
370
|
+
|
|
371
|
+
self.wide_self_pay = frozenset(k for k in self.wide_payer_groups if is_self_pay_payer(k[0]))
|
|
372
|
+
if self.wide_payer_groups:
|
|
373
|
+
log.info("CMS wide format: %d payer-plan combinations detected",
|
|
374
|
+
len(self.wide_payer_groups))
|
|
375
|
+
self.unmapped = unmapped
|
|
376
|
+
self._unmapped_counts: Dict[str, int] = {}
|
|
377
|
+
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
def _map(self, header: str, mapped_to: Optional[str]) -> None:
|
|
381
|
+
self.mappings[header] = HeaderMapping(header, normalize_header(header), mapped_to)
|
|
382
|
+
|
|
383
|
+
def header_mappings(self) -> List[HeaderMapping]:
|
|
384
|
+
"""One record per source column, saying what it was read as."""
|
|
385
|
+
return [self.mappings[h] for h in self.headers if h in self.mappings]
|
|
386
|
+
|
|
387
|
+
def _unmapped_cells(self, row, code, code_type, description, billing_class, setting,
|
|
388
|
+
modifiers) -> Iterator[UnmappedCell]:
|
|
389
|
+
for header in self.unmapped:
|
|
390
|
+
value = cell(row, header)
|
|
391
|
+
if not value or _is_null_rate_token(value):
|
|
392
|
+
continue
|
|
393
|
+
n = self._unmapped_counts.get(header, 0)
|
|
394
|
+
if n >= UNMAPPED_CELLS_PER_COLUMN_CAP:
|
|
395
|
+
continue
|
|
396
|
+
self._unmapped_counts[header] = n + 1
|
|
397
|
+
yield UnmappedCell(code, code_type, description, billing_class or '', setting or '',
|
|
398
|
+
modifiers, source_column=header, value=value)
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _code_pairs(self, row, description) -> Optional[List[Tuple[Any, Any, Any]]]:
|
|
403
|
+
"""Return the row's (code, code_type, baked_modifier) pairs, or None to drop the row."""
|
|
404
|
+
pairs = []
|
|
405
|
+
had_input = False
|
|
406
|
+
for code_src, type_src in self.code_pairs:
|
|
407
|
+
code, code_type = cell(row, code_src), cell(row, type_src)
|
|
408
|
+
if not code and not code_type:
|
|
409
|
+
continue
|
|
410
|
+
had_input = True
|
|
411
|
+
cleaned = self.clean_code(code, code_type, description)
|
|
412
|
+
if cleaned is not None:
|
|
413
|
+
pairs.append(cleaned)
|
|
414
|
+
if pairs:
|
|
415
|
+
return pairs
|
|
416
|
+
if had_input or not description:
|
|
417
|
+
# Every code was rejected (do not fall back to a code-less item),
|
|
418
|
+
# or there is nothing to identify the item by.
|
|
419
|
+
return None
|
|
420
|
+
return [(None, None, None)]
|
|
421
|
+
|
|
422
|
+
def row_to_records(self, row: Dict[str, Any], *, first_row: bool = False,
|
|
423
|
+
check_sections: bool = False) -> Iterator[Any]:
|
|
424
|
+
"""Yield the records for one data row.
|
|
425
|
+
|
|
426
|
+
Raises :class:`SectionBoundary` when *check_sections* is set and the
|
|
427
|
+
row turns out to be the header of a later stacked section.
|
|
428
|
+
"""
|
|
429
|
+
row = _strip_nuls(row)
|
|
430
|
+
data: Dict[str, str] = {}
|
|
431
|
+
for header, field in self.fields.items():
|
|
432
|
+
value = cell(row, header)
|
|
433
|
+
if value:
|
|
434
|
+
data[field] = value
|
|
435
|
+
for field, op_header in self.op_fallbacks.items():
|
|
436
|
+
if data.get(field, '') in _ZERO:
|
|
437
|
+
fallback = cell(row, op_header)
|
|
438
|
+
if fallback not in _ZERO:
|
|
439
|
+
data[field] = fallback
|
|
440
|
+
description = data.get('description')
|
|
441
|
+
|
|
442
|
+
if check_sections and not first_row and self._is_section_header(row, data):
|
|
443
|
+
raise SectionBoundary(row)
|
|
444
|
+
|
|
445
|
+
pairs = self._code_pairs(row, description)
|
|
446
|
+
if pairs is None:
|
|
447
|
+
return
|
|
448
|
+
billing_class = _normalize_billing_class(data.get('billing_class'))
|
|
449
|
+
setting = (data.get('setting') or '').strip().lower()
|
|
450
|
+
|
|
451
|
+
if self.hawaii:
|
|
452
|
+
yield from self._hawaii_row(row, data, pairs, description, billing_class)
|
|
453
|
+
elif self.atrium:
|
|
454
|
+
yield from self._atrium_row(row, data, pairs, description, billing_class)
|
|
455
|
+
else:
|
|
456
|
+
yield from self._tall_or_wide_row(row, data, pairs, description, billing_class, setting)
|
|
457
|
+
|
|
458
|
+
def _is_section_header(self, row, data) -> bool:
|
|
459
|
+
"""A narrower row with no price whose cell re-declares the description column."""
|
|
460
|
+
values = list(row.values())
|
|
461
|
+
if not any(v is None for v in values):
|
|
462
|
+
return False
|
|
463
|
+
if any(data.get(f) for f in _PRICE_FIELDS):
|
|
464
|
+
return False
|
|
465
|
+
return any(isinstance(v, str) and v and map_header(v)[1] == 'description' for v in values)
|
|
466
|
+
|
|
467
|
+
def _tall_or_wide_row(self, row, data, pairs, description, billing_class, setting):
|
|
468
|
+
gross = _safe_float(data.get('gross_charge'))
|
|
469
|
+
cash = _safe_float(data.get('discounted_cash_price'))
|
|
470
|
+
min_rate = _safe_float(data.get('min_negotiated_rate'))
|
|
471
|
+
max_rate = _safe_float(data.get('max_negotiated_rate'))
|
|
472
|
+
raw_payer = data.get('payer_name')
|
|
473
|
+
raw_plan = data.get('plan_name')
|
|
474
|
+
|
|
475
|
+
# Self-pay is the cash price, not a payer rate.
|
|
476
|
+
is_self_pay = bool(self.has_payer_column and raw_payer and is_self_pay_payer(raw_payer))
|
|
477
|
+
if is_self_pay and cash is None:
|
|
478
|
+
cash = _safe_float(data.get('negotiated_rate')) or _safe_float(data.get('estimated_amount'))
|
|
479
|
+
if self.wide_self_pay and cash is None:
|
|
480
|
+
for key in self.wide_self_pay:
|
|
481
|
+
cols = self.wide_payer_groups[key]
|
|
482
|
+
rate = _safe_float(cell(row, cols.get('negotiated_rate')) or None)
|
|
483
|
+
if rate is None:
|
|
484
|
+
rate = _safe_float(cell(row, cols.get('estimated_amount')) or None)
|
|
485
|
+
if rate is not None:
|
|
486
|
+
cash = rate
|
|
487
|
+
break
|
|
488
|
+
has_charge = any(v is not None for v in (gross, cash, min_rate, max_rate))
|
|
489
|
+
|
|
490
|
+
first = None
|
|
491
|
+
for code, code_type, baked in pairs:
|
|
492
|
+
if not any([code, code_type, description]):
|
|
493
|
+
continue
|
|
494
|
+
modifiers = merge_modifier_into_field(data.get('modifiers'), baked)
|
|
495
|
+
code, code_type, bc = self.effective(code, code_type, description, billing_class)
|
|
496
|
+
key = (code, code_type, description, bc, setting, modifiers)
|
|
497
|
+
if first is None:
|
|
498
|
+
first = key
|
|
499
|
+
yield from self.item(code, code_type, description, bc, setting, modifiers, data)
|
|
500
|
+
if has_charge:
|
|
501
|
+
yield StandardCharge(code, code_type, description, bc, setting, modifiers,
|
|
502
|
+
gross, cash, min_rate, max_rate)
|
|
503
|
+
|
|
504
|
+
if self.outpatient_price:
|
|
505
|
+
op_gross = _safe_float(cell(row, self.outpatient_price) or None)
|
|
506
|
+
if op_gross is not None:
|
|
507
|
+
yield from self.item(code, code_type, description, bc, 'outpatient',
|
|
508
|
+
modifiers, data)
|
|
509
|
+
yield StandardCharge(code, code_type, description, bc, 'outpatient',
|
|
510
|
+
modifiers, op_gross)
|
|
511
|
+
|
|
512
|
+
if self.has_payer_column and raw_payer and not is_self_pay:
|
|
513
|
+
yield from self.rate(
|
|
514
|
+
code, code_type, description, bc, setting, modifiers,
|
|
515
|
+
payer=raw_payer, plan=raw_plan,
|
|
516
|
+
rate_billing_class=data.get('billing_class'),
|
|
517
|
+
rate_setting=data.get('setting'),
|
|
518
|
+
negotiated_rate=_safe_float(data.get('negotiated_rate')),
|
|
519
|
+
negotiated_percentage=_safe_float(data.get('negotiated_percentage')),
|
|
520
|
+
negotiated_algorithm=data.get('negotiated_algorithm'),
|
|
521
|
+
methodology=data.get('methodology'),
|
|
522
|
+
estimated_amount=_safe_float(data.get('estimated_amount')),
|
|
523
|
+
additional_notes=data.get('additional_notes'),
|
|
524
|
+
footnote=data.get('footnote'),
|
|
525
|
+
median_amount=_safe_float(data.get('median_amount')),
|
|
526
|
+
pct_10=_safe_float(data.get('pct_10')),
|
|
527
|
+
pct_90=_safe_float(data.get('pct_90')),
|
|
528
|
+
claim_count=_safe_count(data.get('claim_count')),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
for (payer, plan), cols in self.wide_payer_groups.items():
|
|
532
|
+
if (payer, plan) in self.wide_self_pay:
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
def text(field, cols=cols):
|
|
536
|
+
return cell(row, cols.get(field)) or None
|
|
537
|
+
|
|
538
|
+
values = dict(
|
|
539
|
+
negotiated_rate=_safe_float(text('negotiated_rate')),
|
|
540
|
+
negotiated_percentage=_safe_float(text('negotiated_percentage')),
|
|
541
|
+
negotiated_algorithm=text('negotiated_algorithm'),
|
|
542
|
+
methodology=text('methodology'),
|
|
543
|
+
estimated_amount=_safe_float(text('estimated_amount')),
|
|
544
|
+
median_amount=_safe_float(text('median_amount')),
|
|
545
|
+
pct_10=_safe_float(text('pct_10')),
|
|
546
|
+
pct_90=_safe_float(text('pct_90')),
|
|
547
|
+
claim_count=_safe_count(text('claim_count')),
|
|
548
|
+
)
|
|
549
|
+
if not any(values.values()):
|
|
550
|
+
continue
|
|
551
|
+
yield from self.rate(
|
|
552
|
+
code, code_type, description, bc, setting, modifiers,
|
|
553
|
+
payer=payer, plan=plan, rate_billing_class=bc, rate_setting=setting,
|
|
554
|
+
additional_notes=text('additional_notes'),
|
|
555
|
+
footnote=text('footnote') or data.get('footnote'),
|
|
556
|
+
**values,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
if first is not None:
|
|
560
|
+
yield from self._unmapped_cells(row, *first)
|
|
561
|
+
|
|
562
|
+
def _hawaii_row(self, row, data, pairs, description, billing_class):
|
|
563
|
+
min_rate = _safe_float(data.get('min_negotiated_rate'))
|
|
564
|
+
max_rate = _safe_float(data.get('max_negotiated_rate'))
|
|
565
|
+
first = None
|
|
566
|
+
first_setting = None
|
|
567
|
+
for code, code_type, baked in pairs:
|
|
568
|
+
if not any([code, code_type, description]):
|
|
569
|
+
continue
|
|
570
|
+
modifiers = merge_modifier_into_field(data.get('modifiers'), baked)
|
|
571
|
+
code, code_type, bc = self.effective(code, code_type, description, billing_class)
|
|
572
|
+
if first is None:
|
|
573
|
+
first = (code, code_type, description, bc, modifiers)
|
|
574
|
+
emitted = set()
|
|
575
|
+
for setting, cols in self.hawaii_settings.items():
|
|
576
|
+
gross = _number(cell(row, cols.get('gross_charge')))
|
|
577
|
+
cash = _number(cell(row, cols.get('discounted_cash_price')))
|
|
578
|
+
# min/max are shared across settings and do not keep an
|
|
579
|
+
# otherwise empty setting alive.
|
|
580
|
+
if gross is None and cash is None:
|
|
581
|
+
continue
|
|
582
|
+
emitted.add(setting)
|
|
583
|
+
if first_setting is None:
|
|
584
|
+
first_setting = setting
|
|
585
|
+
yield from self.item(code, code_type, description, bc, setting, modifiers, data)
|
|
586
|
+
yield StandardCharge(code, code_type, description, bc, setting, modifiers,
|
|
587
|
+
gross, cash, min_rate, max_rate)
|
|
588
|
+
for header, payer, setting, methodology in self.hawaii_payers:
|
|
589
|
+
if setting not in emitted:
|
|
590
|
+
continue
|
|
591
|
+
rate = _number(cell(row, header))
|
|
592
|
+
if rate is None:
|
|
593
|
+
continue
|
|
594
|
+
percentage = None
|
|
595
|
+
if methodology == 'percent_of_charge':
|
|
596
|
+
# Some files write 0.85 for 85%.
|
|
597
|
+
percentage, rate = (rate * 100.0 if 0 < rate <= 1 else rate), None
|
|
598
|
+
yield from self.rate(
|
|
599
|
+
code, code_type, description, bc, setting, modifiers,
|
|
600
|
+
payer=payer, plan=None, rate_billing_class=bc, rate_setting=setting,
|
|
601
|
+
methodology=methodology, negotiated_rate=rate,
|
|
602
|
+
negotiated_percentage=percentage,
|
|
603
|
+
)
|
|
604
|
+
if first is not None and first_setting is not None:
|
|
605
|
+
code, code_type, description, bc, modifiers = first
|
|
606
|
+
yield from self._unmapped_cells(row, code, code_type, description, bc,
|
|
607
|
+
first_setting, modifiers)
|
|
608
|
+
|
|
609
|
+
def _atrium_row(self, row, data, pairs, description, billing_class):
|
|
610
|
+
flag = cell(row, self.atrium_minmax).upper()
|
|
611
|
+
raw_payer = data.get('payer_name')
|
|
612
|
+
first = None
|
|
613
|
+
first_setting = None
|
|
614
|
+
for code, code_type, baked in pairs:
|
|
615
|
+
if not any([code, code_type, description]):
|
|
616
|
+
continue
|
|
617
|
+
modifiers = merge_modifier_into_field(data.get('modifiers'), baked)
|
|
618
|
+
code, code_type, bc = self.effective(code, code_type, description, billing_class)
|
|
619
|
+
if first is None:
|
|
620
|
+
first = (code, code_type, description, bc, modifiers)
|
|
621
|
+
for setting, cols in self.atrium_settings.items():
|
|
622
|
+
gross = _number(cell(row, cols.get('gross_charge')))
|
|
623
|
+
cash = _number(cell(row, cols.get('discounted_cash_price')))
|
|
624
|
+
negotiated = _number(cell(row, cols.get('negotiated_charge')))
|
|
625
|
+
# The Min/Max column says whether the negotiated charge is a
|
|
626
|
+
# min or a max. Without it, the value is a payer rate.
|
|
627
|
+
min_rate = negotiated if flag == 'MIN' else None
|
|
628
|
+
max_rate = negotiated if flag == 'MAX' else None
|
|
629
|
+
is_self_pay = bool(raw_payer and is_self_pay_payer(raw_payer))
|
|
630
|
+
if is_self_pay and cash is None and negotiated is not None:
|
|
631
|
+
cash, negotiated = negotiated, None
|
|
632
|
+
if all(v is None for v in (gross, cash, min_rate, max_rate, negotiated)):
|
|
633
|
+
continue
|
|
634
|
+
if first_setting is None:
|
|
635
|
+
first_setting = setting
|
|
636
|
+
yield from self.item(code, code_type, description, bc, setting, modifiers, data)
|
|
637
|
+
yield StandardCharge(code, code_type, description, bc, setting, modifiers,
|
|
638
|
+
gross, cash, min_rate, max_rate)
|
|
639
|
+
if raw_payer and not is_self_pay and negotiated is not None and flag not in ('MIN', 'MAX'):
|
|
640
|
+
yield from self.rate(
|
|
641
|
+
code, code_type, description, bc, setting, modifiers,
|
|
642
|
+
payer=raw_payer, plan=data.get('plan_name'),
|
|
643
|
+
rate_billing_class=bc, rate_setting=setting,
|
|
644
|
+
methodology=data.get('methodology'), negotiated_rate=negotiated,
|
|
645
|
+
additional_notes=data.get('additional_notes'),
|
|
646
|
+
footnote=data.get('footnote'),
|
|
647
|
+
)
|
|
648
|
+
if first is not None and first_setting is not None:
|
|
649
|
+
code, code_type, description, bc, modifiers = first
|
|
650
|
+
yield from self._unmapped_cells(row, code, code_type, description, bc,
|
|
651
|
+
first_setting, modifiers)
|