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/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)