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/tic.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Read insurer Transparency in Coverage (TiC) files.
|
|
2
|
+
|
|
3
|
+
Two kinds of file are read:
|
|
4
|
+
|
|
5
|
+
- An in-network rates file: ``provider_references`` lists who (provider
|
|
6
|
+
groups: a TIN and its NPIs) and ``in_network`` lists what each billing code
|
|
7
|
+
pays them. :func:`iter_tic_in_network` reads it.
|
|
8
|
+
- A table of contents: ``reporting_structure`` lists plans and the URLs of
|
|
9
|
+
their in-network files. :func:`iter_tic_index` reads it.
|
|
10
|
+
|
|
11
|
+
Allowed-amount and prescription-drug files are not supported.
|
|
12
|
+
|
|
13
|
+
Licensed text: an in-network item's ``name``, ``description`` and
|
|
14
|
+
``covered_services``, and a bundled code's ``description``, are payer or AMA
|
|
15
|
+
CPT descriptor text. No record ever carries them. An unknown key is counted in
|
|
16
|
+
:attr:`ParseStats.unmapped_paths` by its JSON path only, never with its
|
|
17
|
+
value, so a payer's own free-text field cannot leak descriptor text through
|
|
18
|
+
that door either.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import re
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, Iterable, Iterator, Optional, Set, Union
|
|
30
|
+
|
|
31
|
+
import ijson
|
|
32
|
+
|
|
33
|
+
from .codes import normalize_code
|
|
34
|
+
from .files import detect_file_format, open_json
|
|
35
|
+
from .records import TicFileMetadata, TicIndexEntry, TicProviderGroup, TicRate
|
|
36
|
+
from .reference import ParseStats
|
|
37
|
+
|
|
38
|
+
# TiC's own "does not expire" date, also used when a price gives none.
|
|
39
|
+
NEVER_EXPIRES = "9999-12-31"
|
|
40
|
+
NPI_MIN = 1_000_000_000
|
|
41
|
+
NPI_MAX = 9_999_999_999
|
|
42
|
+
# Values at or past these are data errors, not prices.
|
|
43
|
+
MAX_ABS_RATE = 1e10
|
|
44
|
+
MAX_ABS_PERCENTAGE = 1e5
|
|
45
|
+
# Distinct prices held for one billing code before they are emitted. One
|
|
46
|
+
# item can carry tens of thousands of negotiated_rates entries, so memory is
|
|
47
|
+
# bounded by this rather than by the largest item in the file. Emitting
|
|
48
|
+
# early only means a price can appear in two records, each with its own
|
|
49
|
+
# provider groups.
|
|
50
|
+
MAX_HELD_RATES = 50_000
|
|
51
|
+
|
|
52
|
+
KNOWN_KEYS = {
|
|
53
|
+
"$": {"reporting_entity_name", "reporting_entity_type", "last_updated_on",
|
|
54
|
+
"version", "provider_references", "in_network"},
|
|
55
|
+
"$.provider_references[]": {"provider_group_id", "provider_groups",
|
|
56
|
+
"network_name", "location"},
|
|
57
|
+
"$.provider_references[].provider_groups[]": {"npi", "tin"},
|
|
58
|
+
"$.provider_references[].provider_groups[].tin": {"type", "value", "business_name"},
|
|
59
|
+
"$.in_network[]": {"negotiation_arrangement", "name", "billing_code_type",
|
|
60
|
+
"billing_code_type_version", "billing_code", "description",
|
|
61
|
+
"negotiated_rates", "bundled_codes",
|
|
62
|
+
# TiC 2.0, for DRG codes. Read past, not emitted.
|
|
63
|
+
"severity_of_illness"},
|
|
64
|
+
"$.in_network[].negotiated_rates[]": {"provider_references", "negotiated_prices",
|
|
65
|
+
"provider_groups"},
|
|
66
|
+
"$.in_network[].negotiated_rates[].provider_groups[]": {"npi", "tin"},
|
|
67
|
+
"$.in_network[].negotiated_rates[].provider_groups[].tin": {"type", "value",
|
|
68
|
+
"business_name"},
|
|
69
|
+
"$.in_network[].negotiated_rates[].negotiated_prices[]": {
|
|
70
|
+
"negotiated_type", "negotiated_rate", "expiration_date", "service_code",
|
|
71
|
+
"billing_class", "billing_code_modifier", "setting", "additional_information",
|
|
72
|
+
},
|
|
73
|
+
"$.in_network[].bundled_codes[]": {"billing_code_type", "billing_code_type_version",
|
|
74
|
+
"billing_code", "description"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Descriptor text, dropped on purpose: never emitted and never reported.
|
|
78
|
+
IGNORED_ITEM_KEYS = frozenset({"name", "description", "covered_services"})
|
|
79
|
+
IGNORED_BUNDLED_KEYS = frozenset({"description"})
|
|
80
|
+
|
|
81
|
+
NEGOTIATED_TYPES = frozenset({"negotiated", "fee schedule", "percentage", "per diem", "derived"})
|
|
82
|
+
# Per diem and derived prices may carry no value at all.
|
|
83
|
+
_TYPES_REQUIRING_A_VALUE = frozenset({"negotiated", "fee schedule", "percentage"})
|
|
84
|
+
# "both": the rate applies to the professional and the institutional claim.
|
|
85
|
+
BILLING_CLASSES = frozenset({"professional", "institutional", "both"})
|
|
86
|
+
SETTINGS = frozenset({"both", "outpatient", "inpatient"})
|
|
87
|
+
|
|
88
|
+
_PRICES = "$.in_network[].negotiated_rates[].negotiated_prices[]"
|
|
89
|
+
_POS_CODE_RE = re.compile(r"^\d{2}$")
|
|
90
|
+
_NON_DIGIT_RE = re.compile(r"[^0-9]")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Value helpers
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def _normalize_tin(raw: Any) -> Optional[str]:
|
|
98
|
+
"""Digits only. Some plans write an EIN as ``30-0318970`` and others as
|
|
99
|
+
``300318970``; one form must win or one provider group splits in two."""
|
|
100
|
+
digits = _NON_DIGIT_RE.sub("", str(raw)) if raw is not None else ""
|
|
101
|
+
return digits or None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _split_modifiers(raw: Any) -> list:
|
|
105
|
+
"""Sorted, distinct modifiers. Some plans pack several into one string ("52,53")."""
|
|
106
|
+
out = set()
|
|
107
|
+
for m in raw or []:
|
|
108
|
+
if m is None:
|
|
109
|
+
continue
|
|
110
|
+
for part in str(m).split(","):
|
|
111
|
+
part = part.strip()
|
|
112
|
+
if part:
|
|
113
|
+
out.add(part)
|
|
114
|
+
return sorted(out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _valid_npi(raw: Any) -> Optional[int]:
|
|
118
|
+
"""A 10-digit NPI, or None for anything non-numeric or out of NPI range."""
|
|
119
|
+
try:
|
|
120
|
+
n = int(raw)
|
|
121
|
+
except (TypeError, ValueError):
|
|
122
|
+
return None
|
|
123
|
+
return n if NPI_MIN <= n <= NPI_MAX else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_pos_code(value: Any) -> bool:
|
|
127
|
+
return isinstance(value, str) and bool(_POS_CODE_RE.match(value))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_expiration(raw: Any, stats: ParseStats) -> str:
|
|
131
|
+
"""ISO expiration date. Missing means "does not expire" and is not
|
|
132
|
+
reported; a present but unparseable date is reported, then defaulted,
|
|
133
|
+
and the price is kept."""
|
|
134
|
+
if not raw:
|
|
135
|
+
return NEVER_EXPIRES
|
|
136
|
+
try:
|
|
137
|
+
return datetime.strptime(raw, "%Y-%m-%d").date().isoformat()
|
|
138
|
+
except (ValueError, TypeError):
|
|
139
|
+
stats.record_unmapped_path(f"{_PRICES}.expiration_date=unparseable")
|
|
140
|
+
return NEVER_EXPIRES
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _normalize_tic_code(code: Any, code_type: Any):
|
|
144
|
+
"""CPT and HCPCS go through :func:`normalize_code`. Every other type (RC,
|
|
145
|
+
MS-DRG, payer-custom ``CSTM-*`` ...) is kept exactly as the file declares
|
|
146
|
+
it: those are not the standard-shape codes that normalizer gates."""
|
|
147
|
+
if isinstance(code_type, str) and code_type.upper() in ("CPT", "HCPCS"):
|
|
148
|
+
return normalize_code(code, code_type)
|
|
149
|
+
return code, code_type
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _text(value: Any) -> Optional[str]:
|
|
153
|
+
return None if value is None else str(value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _check_unknown_keys(stats: ParseStats, path: str, obj: Any,
|
|
157
|
+
ignored: frozenset = frozenset()) -> None:
|
|
158
|
+
"""Count every key of *obj* not known at *path*. Path only, never the value."""
|
|
159
|
+
if not isinstance(obj, dict):
|
|
160
|
+
return
|
|
161
|
+
known = KNOWN_KEYS.get(path, ())
|
|
162
|
+
for key in obj:
|
|
163
|
+
if key not in known and key not in ignored:
|
|
164
|
+
stats.record_unmapped_path(f"{path}.{key}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Reading the file
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
# Some payer files carry bare backslashes inside strings ("\WAKE SPECIALTY
|
|
172
|
+
# PHYSICIANS LLC"), which strict JSON rejects. A valid escape is \" \\ \/ \b
|
|
173
|
+
# \f \n \r \t \uXXXX; anything else gets its backslash doubled so the string
|
|
174
|
+
# keeps the backslash as a literal character.
|
|
175
|
+
_BAD_ESCAPE_RE = re.compile(rb'\\\\|\\(?=[^"\\/bfnrtu])')
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _repair_escapes(data: bytes) -> tuple:
|
|
179
|
+
"""Return (repaired bytes, number of bad escapes doubled)."""
|
|
180
|
+
repairs = 0
|
|
181
|
+
|
|
182
|
+
def fix(m):
|
|
183
|
+
nonlocal repairs
|
|
184
|
+
if m.group(0) == b"\\\\":
|
|
185
|
+
return m.group(0)
|
|
186
|
+
repairs += 1
|
|
187
|
+
return b"\\\\"
|
|
188
|
+
return _BAD_ESCAPE_RE.sub(fix, data), repairs
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _EscapeRepairingStream:
|
|
192
|
+
"""Wrap a binary stream and repair invalid JSON escapes on the fly.
|
|
193
|
+
|
|
194
|
+
A trailing backslash that could start an escape or a pair is carried into
|
|
195
|
+
the next read, and a read never returns empty bytes before the underlying
|
|
196
|
+
stream is exhausted (empty means EOF to the parser). ``repairs`` counts
|
|
197
|
+
the substitutions.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self, fh):
|
|
201
|
+
self._fh = fh
|
|
202
|
+
self._carry = b""
|
|
203
|
+
self.repairs = 0
|
|
204
|
+
|
|
205
|
+
def read(self, size=-1):
|
|
206
|
+
while True:
|
|
207
|
+
chunk = self._fh.read(size)
|
|
208
|
+
data = self._carry + chunk
|
|
209
|
+
self._carry = b""
|
|
210
|
+
if not chunk:
|
|
211
|
+
out, n = _repair_escapes(data)
|
|
212
|
+
self.repairs += n
|
|
213
|
+
return out
|
|
214
|
+
trailing = len(data) - len(data.rstrip(b"\\"))
|
|
215
|
+
if trailing % 2 == 1:
|
|
216
|
+
self._carry = b"\\"
|
|
217
|
+
data = data[:-1]
|
|
218
|
+
out, n = _repair_escapes(data)
|
|
219
|
+
self.repairs += n
|
|
220
|
+
if out:
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@contextmanager
|
|
225
|
+
def _open(path: Path, compression: Optional[str]) -> Iterator[_EscapeRepairingStream]:
|
|
226
|
+
with open_json(path, compression) as fh:
|
|
227
|
+
yield _EscapeRepairingStream(fh)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _bounded_array_events(fh, array_key: str):
|
|
231
|
+
"""Parse events up to and including *array_key*'s closing bracket, then
|
|
232
|
+
stop, so streaming that one top-level array never reads the rest of a
|
|
233
|
+
multi-gigabyte file. ``ijson.items`` accepts these events in place of a
|
|
234
|
+
file."""
|
|
235
|
+
for prefix, event, value in ijson.parse(fh):
|
|
236
|
+
yield prefix, event, value
|
|
237
|
+
if prefix == array_key and event == "end_array":
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def detect_tic_file(path: Union[str, Path], compression: Optional[str] = None) -> Optional[str]:
|
|
242
|
+
"""``'in_network'`` or ``'index'`` for a TiC file, None for anything else.
|
|
243
|
+
|
|
244
|
+
Looks at the root keys in the first events of the file only, so a
|
|
245
|
+
hospital file costs a few kilobytes of parsing.
|
|
246
|
+
"""
|
|
247
|
+
path = Path(path)
|
|
248
|
+
if compression is None:
|
|
249
|
+
_, compression = detect_file_format(path)
|
|
250
|
+
try:
|
|
251
|
+
with _open(path, compression) as fh:
|
|
252
|
+
for prefix, event, value in itertools.islice(ijson.parse(fh), 1000):
|
|
253
|
+
if event != "map_key":
|
|
254
|
+
continue
|
|
255
|
+
if prefix == "" and value in ("in_network", "provider_references"):
|
|
256
|
+
return "in_network"
|
|
257
|
+
# A table of contents is sometimes wrapped in a one-element list.
|
|
258
|
+
if prefix in ("", "item") and value == "reporting_structure":
|
|
259
|
+
return "index"
|
|
260
|
+
except ijson.JSONError:
|
|
261
|
+
pass
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# In-network rates files
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def iter_tic_in_network(
|
|
270
|
+
path: Union[str, Path],
|
|
271
|
+
compression: Optional[str] = None,
|
|
272
|
+
*,
|
|
273
|
+
stats: Optional[ParseStats] = None,
|
|
274
|
+
) -> Iterator[Any]:
|
|
275
|
+
"""Yield the records in a TiC in-network rates file.
|
|
276
|
+
|
|
277
|
+
Order: :class:`TicFileMetadata`, every :class:`TicProviderGroup` from
|
|
278
|
+
``provider_references``, then :class:`TicRate` records (with a
|
|
279
|
+
:class:`TicProviderGroup` for a group listed inline on a rate just
|
|
280
|
+
before the first rate that uses it).
|
|
281
|
+
|
|
282
|
+
Two passes over separate handles, so *path* must be a file: the first
|
|
283
|
+
reads ``provider_references`` and stops at its closing bracket, the
|
|
284
|
+
second streams ``in_network``. Memory holds the reference ids, not the
|
|
285
|
+
file.
|
|
286
|
+
|
|
287
|
+
*stats*: ``rows_read`` counts negotiated prices read, ``rows_skipped``
|
|
288
|
+
the provider groups, items and prices dropped as invalid or unmappable,
|
|
289
|
+
and ``unmapped_paths`` names each problem by JSON path.
|
|
290
|
+
"""
|
|
291
|
+
path = Path(path)
|
|
292
|
+
stats = stats if stats is not None else ParseStats()
|
|
293
|
+
if compression is None:
|
|
294
|
+
_, compression = detect_file_format(path)
|
|
295
|
+
|
|
296
|
+
metadata = _read_metadata(path, compression, stats)
|
|
297
|
+
if any((metadata.reporting_entity_name, metadata.reporting_entity_type,
|
|
298
|
+
metadata.last_updated_on, metadata.version, metadata.network_name)):
|
|
299
|
+
yield metadata
|
|
300
|
+
resolved: Set[Any] = set()
|
|
301
|
+
yield from _provider_reference_groups(path, compression, stats, resolved)
|
|
302
|
+
yield from _rates(path, compression, stats, resolved)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _read_metadata(path: Path, compression: Optional[str], stats: ParseStats) -> TicFileMetadata:
|
|
306
|
+
"""Root fields and the first reference's network name, then stop.
|
|
307
|
+
|
|
308
|
+
Payers write the root scalars before the two big arrays and repeat one
|
|
309
|
+
network label on every reference, so this reads at most one reference.
|
|
310
|
+
"""
|
|
311
|
+
fields: Dict[str, str] = {}
|
|
312
|
+
root_keys: Dict[str, None] = {}
|
|
313
|
+
with _open(path, compression) as fh:
|
|
314
|
+
for prefix, event, value in ijson.parse(fh):
|
|
315
|
+
if prefix == "" and event == "map_key":
|
|
316
|
+
root_keys[value] = None
|
|
317
|
+
if value == "in_network":
|
|
318
|
+
break
|
|
319
|
+
elif prefix in ("reporting_entity_name", "reporting_entity_type",
|
|
320
|
+
"last_updated_on", "version") and event in ("string", "number"):
|
|
321
|
+
fields[prefix] = str(value)
|
|
322
|
+
elif prefix == "provider_references.item.network_name.item" and event == "string":
|
|
323
|
+
fields["network_name"] = value
|
|
324
|
+
break
|
|
325
|
+
elif ((prefix == "provider_references.item" and event == "end_map")
|
|
326
|
+
or (prefix == "provider_references" and event == "end_array")):
|
|
327
|
+
break
|
|
328
|
+
_check_unknown_keys(stats, "$", root_keys)
|
|
329
|
+
return TicFileMetadata(**fields)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _provider_group(pg: Any, path: str, stats: ParseStats) -> Optional[tuple]:
|
|
333
|
+
"""(tin_type, tin, npis, business_name) for a valid provider group, else None.
|
|
334
|
+
|
|
335
|
+
A group needs a TIN and at least one valid NPI. Invalid NPIs are dropped
|
|
336
|
+
one by one and reported; the group keeps its valid ones.
|
|
337
|
+
"""
|
|
338
|
+
_check_unknown_keys(stats, path, pg)
|
|
339
|
+
tin_obj = pg.get("tin") or {}
|
|
340
|
+
_check_unknown_keys(stats, f"{path}.tin", tin_obj)
|
|
341
|
+
tin_type = tin_obj.get("type")
|
|
342
|
+
if tin_type != "ein":
|
|
343
|
+
stats.record_unmapped_path(f"{path}.tin.type={tin_type}")
|
|
344
|
+
|
|
345
|
+
tin = _normalize_tin(tin_obj.get("value"))
|
|
346
|
+
if not tin:
|
|
347
|
+
stats.record_unmapped_path(f"{path}.tin.value=missing")
|
|
348
|
+
stats.rows_skipped += 1
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
npis = set()
|
|
352
|
+
any_invalid = False
|
|
353
|
+
for raw in pg.get("npi") or []:
|
|
354
|
+
npi = _valid_npi(raw)
|
|
355
|
+
if npi is None:
|
|
356
|
+
any_invalid = True
|
|
357
|
+
kind = "out_of_range" if str(raw).lstrip("-").isdigit() else "non_numeric"
|
|
358
|
+
stats.record_unmapped_path(f"{path}.npi={kind}")
|
|
359
|
+
stats.rows_skipped += 1
|
|
360
|
+
continue
|
|
361
|
+
npis.add(npi)
|
|
362
|
+
if not npis:
|
|
363
|
+
if not any_invalid:
|
|
364
|
+
stats.record_unmapped_path(f"{path}.npi=empty")
|
|
365
|
+
return None
|
|
366
|
+
return (_text(tin_type), tin, "|".join(str(n) for n in sorted(npis)),
|
|
367
|
+
_text(tin_obj.get("business_name")))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _provider_reference_groups(path: Path, compression: Optional[str], stats: ParseStats,
|
|
371
|
+
resolved: Set[Any]) -> Iterator[TicProviderGroup]:
|
|
372
|
+
"""Pass 1: one record per (reference id, provider group).
|
|
373
|
+
|
|
374
|
+
Adds every reference id with at least one valid group to *resolved*; a
|
|
375
|
+
rate pointing only at other ids has nobody to pay and is skipped.
|
|
376
|
+
"""
|
|
377
|
+
# ponytail: one key per distinct group row stays in memory to drop a
|
|
378
|
+
# reference repeated in the file; spill to disk if a file ever has
|
|
379
|
+
# tens of millions of groups.
|
|
380
|
+
seen = set()
|
|
381
|
+
path_groups = "$.provider_references[].provider_groups[]"
|
|
382
|
+
with _open(path, compression) as fh:
|
|
383
|
+
events = _bounded_array_events(fh, "provider_references")
|
|
384
|
+
for ref in ijson.items(events, "provider_references.item"):
|
|
385
|
+
_check_unknown_keys(stats, "$.provider_references[]", ref)
|
|
386
|
+
ref_id = ref.get("provider_group_id")
|
|
387
|
+
if ref.get("location") and not ref.get("provider_groups"):
|
|
388
|
+
# The groups live in a separate file this reader does not fetch.
|
|
389
|
+
stats.record_unmapped_path("$.provider_references[].location=unsupported")
|
|
390
|
+
for pg in ref.get("provider_groups") or []:
|
|
391
|
+
group = _provider_group(pg, path_groups, stats)
|
|
392
|
+
if group is None:
|
|
393
|
+
continue
|
|
394
|
+
resolved.add(ref_id)
|
|
395
|
+
key = (ref_id, group[1], group[2])
|
|
396
|
+
if key not in seen:
|
|
397
|
+
seen.add(key)
|
|
398
|
+
yield TicProviderGroup(str(ref_id), *group)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _id_order(group_id: Any) -> tuple:
|
|
402
|
+
# Reference ids are numbers; inline ids are strings and sort after them.
|
|
403
|
+
return (isinstance(group_id, str), group_id)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _rates(path: Path, compression: Optional[str], stats: ParseStats,
|
|
407
|
+
resolved: Set[Any]) -> Iterator[Any]:
|
|
408
|
+
"""Pass 2: stream ``in_network`` into rate records."""
|
|
409
|
+
inline_ids: Dict[tuple, str] = {}
|
|
410
|
+
path_inline = "$.in_network[].negotiated_rates[].provider_groups[]"
|
|
411
|
+
with _open(path, compression) as fh:
|
|
412
|
+
for item in ijson.items(fh, "in_network.item"):
|
|
413
|
+
_check_unknown_keys(stats, "$.in_network[]", item, IGNORED_ITEM_KEYS)
|
|
414
|
+
for bc in item.get("bundled_codes") or []:
|
|
415
|
+
_check_unknown_keys(stats, "$.in_network[].bundled_codes[]", bc,
|
|
416
|
+
IGNORED_BUNDLED_KEYS)
|
|
417
|
+
|
|
418
|
+
raw_code = item.get("billing_code")
|
|
419
|
+
raw_code_type = item.get("billing_code_type")
|
|
420
|
+
if not raw_code or not raw_code_type:
|
|
421
|
+
stats.record_unmapped_path(
|
|
422
|
+
"$.in_network[].billing_code=missing" if not raw_code
|
|
423
|
+
else "$.in_network[].billing_code_type=missing")
|
|
424
|
+
stats.rows_skipped += max(1, sum(
|
|
425
|
+
len(nr.get("negotiated_prices") or [])
|
|
426
|
+
for nr in item.get("negotiated_rates") or []))
|
|
427
|
+
continue
|
|
428
|
+
code, code_type = _normalize_tic_code(raw_code, raw_code_type)
|
|
429
|
+
|
|
430
|
+
# Distinct price -> every provider group that has it, for this
|
|
431
|
+
# item. Some payers list one provider per negotiated_rates entry,
|
|
432
|
+
# so identical prices repeat many times per code; one record per
|
|
433
|
+
# distinct price says the same thing in far fewer rows.
|
|
434
|
+
held: Dict[tuple, set] = {}
|
|
435
|
+
for nr in item.get("negotiated_rates") or []:
|
|
436
|
+
_check_unknown_keys(stats, "$.in_network[].negotiated_rates[]", nr)
|
|
437
|
+
group_ids = set()
|
|
438
|
+
unknown_ref = False
|
|
439
|
+
for ref_id in nr.get("provider_references") or []:
|
|
440
|
+
if ref_id in resolved:
|
|
441
|
+
group_ids.add(ref_id)
|
|
442
|
+
else:
|
|
443
|
+
unknown_ref = True
|
|
444
|
+
if unknown_ref:
|
|
445
|
+
stats.record_unmapped_path(
|
|
446
|
+
"$.in_network[].negotiated_rates[].provider_references=unknown")
|
|
447
|
+
for pg in nr.get("provider_groups") or []:
|
|
448
|
+
group = _provider_group(pg, path_inline, stats)
|
|
449
|
+
if group is None:
|
|
450
|
+
continue
|
|
451
|
+
group_id = inline_ids.get(group[1:3])
|
|
452
|
+
if group_id is None:
|
|
453
|
+
group_id = inline_ids[group[1:3]] = f"inline-{len(inline_ids) + 1}"
|
|
454
|
+
yield TicProviderGroup(group_id, *group)
|
|
455
|
+
group_ids.add(group_id)
|
|
456
|
+
if not group_ids:
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
for price in nr.get("negotiated_prices") or []:
|
|
460
|
+
key = _price_key(price, stats)
|
|
461
|
+
if key is None:
|
|
462
|
+
continue
|
|
463
|
+
merged = held.get(key)
|
|
464
|
+
if merged is None:
|
|
465
|
+
held[key] = set(group_ids)
|
|
466
|
+
else:
|
|
467
|
+
merged |= group_ids
|
|
468
|
+
if len(held) >= MAX_HELD_RATES:
|
|
469
|
+
yield from _rate_records(item, code, code_type, held)
|
|
470
|
+
yield from _rate_records(item, code, code_type, held)
|
|
471
|
+
if fh.repairs:
|
|
472
|
+
stats.warn(f"Repaired {fh.repairs:,} invalid JSON escape(s)")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _price_key(price: Any, stats: ParseStats) -> Optional[tuple]:
|
|
476
|
+
"""The merge key for one negotiated price, or None when it is skipped."""
|
|
477
|
+
_check_unknown_keys(stats, _PRICES, price)
|
|
478
|
+
stats.rows_read += 1
|
|
479
|
+
|
|
480
|
+
negotiated_type = price.get("negotiated_type")
|
|
481
|
+
if negotiated_type not in NEGOTIATED_TYPES:
|
|
482
|
+
stats.record_unmapped_path(f"{_PRICES}.negotiated_type={negotiated_type}")
|
|
483
|
+
stats.rows_skipped += 1
|
|
484
|
+
return None
|
|
485
|
+
billing_class = price.get("billing_class")
|
|
486
|
+
if billing_class not in BILLING_CLASSES:
|
|
487
|
+
stats.record_unmapped_path(f"{_PRICES}.billing_class={billing_class}")
|
|
488
|
+
stats.rows_skipped += 1
|
|
489
|
+
return None
|
|
490
|
+
setting = price.get("setting")
|
|
491
|
+
if setting is not None and setting not in SETTINGS:
|
|
492
|
+
stats.record_unmapped_path(f"{_PRICES}.setting={setting}")
|
|
493
|
+
|
|
494
|
+
value = price.get("negotiated_rate")
|
|
495
|
+
if value is None and negotiated_type in _TYPES_REQUIRING_A_VALUE:
|
|
496
|
+
stats.record_unmapped_path(f"{_PRICES}.negotiated_rate=missing")
|
|
497
|
+
stats.rows_skipped += 1
|
|
498
|
+
return None
|
|
499
|
+
if value is not None:
|
|
500
|
+
limit = MAX_ABS_PERCENTAGE if negotiated_type == "percentage" else MAX_ABS_RATE
|
|
501
|
+
try:
|
|
502
|
+
out_of_range = abs(float(value)) >= limit
|
|
503
|
+
except (TypeError, ValueError):
|
|
504
|
+
out_of_range = True
|
|
505
|
+
if out_of_range:
|
|
506
|
+
stats.record_unmapped_path(f"{_PRICES}.negotiated_rate=out_of_range")
|
|
507
|
+
stats.rows_skipped += 1
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
service_codes = sorted(price.get("service_code") or [])
|
|
511
|
+
for sc in service_codes:
|
|
512
|
+
if not _is_pos_code(sc):
|
|
513
|
+
stats.record_unmapped_path(f"{_PRICES}.service_code={sc}")
|
|
514
|
+
return (billing_class, setting if setting in SETTINGS else "", negotiated_type, value,
|
|
515
|
+
tuple(service_codes), tuple(_split_modifiers(price.get("billing_code_modifier"))),
|
|
516
|
+
_parse_expiration(price.get("expiration_date"), stats))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _rate_records(item: dict, code: Any, code_type: Any, held: Dict[tuple, set]) -> Iterator[TicRate]:
|
|
520
|
+
"""One record per distinct price held for *item*, then clear *held*."""
|
|
521
|
+
for (billing_class, setting, negotiated_type, value, service_codes, modifiers,
|
|
522
|
+
expiration), group_ids in held.items():
|
|
523
|
+
value = None if value is None else float(value)
|
|
524
|
+
is_percentage = negotiated_type == "percentage"
|
|
525
|
+
yield TicRate(
|
|
526
|
+
billing_code=_text(code),
|
|
527
|
+
billing_code_type=_text(code_type),
|
|
528
|
+
billing_code_type_version=_text(item.get("billing_code_type_version")),
|
|
529
|
+
negotiation_arrangement=_text(item.get("negotiation_arrangement")),
|
|
530
|
+
negotiated_type=negotiated_type,
|
|
531
|
+
negotiated_rate=None if is_percentage else value,
|
|
532
|
+
negotiated_percentage=value if is_percentage else None,
|
|
533
|
+
expiration_date=expiration,
|
|
534
|
+
billing_class=billing_class,
|
|
535
|
+
setting=setting,
|
|
536
|
+
service_codes="|".join(str(s) for s in service_codes) or None,
|
|
537
|
+
modifiers="|".join(modifiers) or None,
|
|
538
|
+
provider_group_ids="|".join(str(g) for g in sorted(group_ids, key=_id_order)),
|
|
539
|
+
)
|
|
540
|
+
held.clear()
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# Table of contents (index) files
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
def iter_tic_index(
|
|
548
|
+
path: Union[str, Path],
|
|
549
|
+
compression: Optional[str] = None,
|
|
550
|
+
*,
|
|
551
|
+
stats: Optional[ParseStats] = None,
|
|
552
|
+
) -> Iterator[TicIndexEntry]:
|
|
553
|
+
"""Yield one :class:`TicIndexEntry` per (reporting plan, in-network file)
|
|
554
|
+
pair of every ``reporting_structure`` in a table-of-contents file.
|
|
555
|
+
|
|
556
|
+
Streamed: one structure is in memory at a time, so a 10 GB index is
|
|
557
|
+
fine. A root wrapped in a list is accepted. An in-network file with no
|
|
558
|
+
``location`` is skipped and counted in *stats*.
|
|
559
|
+
|
|
560
|
+
Raises ``ValueError`` when the root is neither an object nor a list.
|
|
561
|
+
"""
|
|
562
|
+
path = Path(path)
|
|
563
|
+
stats = stats if stats is not None else ParseStats()
|
|
564
|
+
if compression is None:
|
|
565
|
+
_, compression = detect_file_format(path)
|
|
566
|
+
|
|
567
|
+
root: Dict[str, str] = {}
|
|
568
|
+
with _open(path, compression) as fh:
|
|
569
|
+
events = ijson.parse(fh)
|
|
570
|
+
first = next(events, None)
|
|
571
|
+
if first is None or first[1] not in ("start_map", "start_array"):
|
|
572
|
+
raise ValueError(f"{path.name}: a table of contents must be a JSON object "
|
|
573
|
+
f"(or a list holding one)")
|
|
574
|
+
base = "" if first[1] == "start_map" else "item."
|
|
575
|
+
|
|
576
|
+
def capture_root():
|
|
577
|
+
# Root fields stream past before reporting_structure; keep them.
|
|
578
|
+
yield first
|
|
579
|
+
for prefix, event, value in events:
|
|
580
|
+
if (prefix in (f"{base}reporting_entity_name", f"{base}reporting_entity_type")
|
|
581
|
+
and event == "string"):
|
|
582
|
+
root[prefix[len(base):]] = value
|
|
583
|
+
yield prefix, event, value
|
|
584
|
+
|
|
585
|
+
for structure in ijson.items(capture_root(), f"{base}reporting_structure.item"):
|
|
586
|
+
plans = structure.get("reporting_plans") or [{}]
|
|
587
|
+
allowed = (structure.get("allowed_amount_file") or {}).get("location")
|
|
588
|
+
for entry in structure.get("in_network_files") or []:
|
|
589
|
+
location = entry.get("location")
|
|
590
|
+
if not location:
|
|
591
|
+
stats.record_unmapped_path(
|
|
592
|
+
"$.reporting_structure[].in_network_files[].location=missing")
|
|
593
|
+
stats.rows_skipped += 1
|
|
594
|
+
continue
|
|
595
|
+
stats.rows_read += 1
|
|
596
|
+
for plan in plans:
|
|
597
|
+
yield TicIndexEntry(
|
|
598
|
+
reporting_entity_name=root.get("reporting_entity_name"),
|
|
599
|
+
reporting_entity_type=root.get("reporting_entity_type"),
|
|
600
|
+
plan_name=_text(plan.get("plan_name")),
|
|
601
|
+
plan_id=_text(plan.get("plan_id")),
|
|
602
|
+
plan_id_type=_text(plan.get("plan_id_type")),
|
|
603
|
+
plan_market_type=_text(plan.get("plan_market_type")),
|
|
604
|
+
in_network_location=str(location),
|
|
605
|
+
in_network_description=_text(entry.get("description")),
|
|
606
|
+
allowed_amount_location=_text(allowed),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# "2026-09-01_KPIC-MA_in-network-rates" -> "KPIC-MA_in-network-rates". File
|
|
611
|
+
# names usually lead with their publication date, which would make every
|
|
612
|
+
# monthly index a new set of networks.
|
|
613
|
+
_DATE_PREFIX_RE = re.compile(r"^\d{4}-\d{2}(?:-\d{2})?_")
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _file_name(location: str) -> str:
|
|
617
|
+
return location.split("?", 1)[0].rsplit("/", 1)[-1]
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def tic_network_key(location: str) -> str:
|
|
621
|
+
"""A stable key for the network an in-network file belongs to.
|
|
622
|
+
|
|
623
|
+
The file's base name without query string, extension or a leading
|
|
624
|
+
publication date, so signed URLs and next month's copy keep the key.
|
|
625
|
+
"""
|
|
626
|
+
return _DATE_PREFIX_RE.sub("", _file_name(location).split(".", 1)[0])
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def group_tic_networks(entries: Iterable[TicIndexEntry]) -> Dict[str, Dict[str, Any]]:
|
|
630
|
+
"""Group index entries into networks, keyed by :func:`tic_network_key`.
|
|
631
|
+
|
|
632
|
+
Capitation (``cap-rates``) and allowed-amount files are left out. Each
|
|
633
|
+
group gives ``file_name`` and ``location`` (the lexicographically first
|
|
634
|
+
file name), ``market_type`` (the most common ``plan_market_type`` over
|
|
635
|
+
the plans that reference it) and ``file_count`` (distinct file names).
|
|
636
|
+
Memory is per distinct file, not per entry: an index can repeat one file
|
|
637
|
+
across millions of plans.
|
|
638
|
+
"""
|
|
639
|
+
groups: Dict[str, Dict[str, Any]] = {}
|
|
640
|
+
for entry in entries:
|
|
641
|
+
file_name = _file_name(entry.in_network_location)
|
|
642
|
+
lower = file_name.lower()
|
|
643
|
+
if "cap-rates" in lower or "allowed" in lower:
|
|
644
|
+
continue
|
|
645
|
+
key = tic_network_key(file_name)
|
|
646
|
+
group = groups.get(key)
|
|
647
|
+
if group is None:
|
|
648
|
+
group = groups[key] = {"file_name": file_name, "location": entry.in_network_location,
|
|
649
|
+
"names": set(), "market_types": Counter()}
|
|
650
|
+
elif file_name < group["file_name"]:
|
|
651
|
+
group["file_name"], group["location"] = file_name, entry.in_network_location
|
|
652
|
+
group["names"].add(file_name)
|
|
653
|
+
if entry.plan_market_type:
|
|
654
|
+
group["market_types"][entry.plan_market_type] += 1
|
|
655
|
+
|
|
656
|
+
for group in groups.values():
|
|
657
|
+
modal = group.pop("market_types").most_common(1)
|
|
658
|
+
group["market_type"] = modal[0][0] if modal else None
|
|
659
|
+
group["file_count"] = len(group.pop("names"))
|
|
660
|
+
return groups
|