pdpcompare 1.0.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.
- pdpcompare/__init__.py +0 -0
- pdpcompare/bundle.py +136 -0
- pdpcompare/cli.py +273 -0
- pdpcompare/clientstore.py +47 -0
- pdpcompare/config.py +46 -0
- pdpcompare/costsim.py +55 -0
- pdpcompare/db.py +9 -0
- pdpcompare/deskupdate.py +149 -0
- pdpcompare/doctor.py +142 -0
- pdpcompare/engine.py +263 -0
- pdpcompare/ingest/__init__.py +0 -0
- pdpcompare/ingest/catalog.py +68 -0
- pdpcompare/ingest/geo_loader.py +36 -0
- pdpcompare/ingest/header_specs.json +51 -0
- pdpcompare/ingest/landscape_loader.py +53 -0
- pdpcompare/ingest/spuf_loader.py +322 -0
- pdpcompare/inputs.py +73 -0
- pdpcompare/models.py +89 -0
- pdpcompare/outputs/__init__.py +0 -0
- pdpcompare/outputs/letter.py +53 -0
- pdpcompare/outputs/worksheet.py +62 -0
- pdpcompare/plandata.py +210 -0
- pdpcompare/recommend.py +75 -0
- pdpcompare/rxnorm.py +175 -0
- pdpcompare/server/__init__.py +0 -0
- pdpcompare/server/__main__.py +25 -0
- pdpcompare/server/alerts.py +107 -0
- pdpcompare/server/app.py +100 -0
- pdpcompare/server/bundle_builder.py +99 -0
- pdpcompare/server/config.py +61 -0
- pdpcompare/server/state.py +53 -0
- pdpcompare/server/updater.py +140 -0
- pdpcompare/skilldoc/INSTRUCTIONS.md +224 -0
- pdpcompare/skilldoc/__init__.py +1 -0
- pdpcompare/sources.py +319 -0
- pdpcompare/updater.py +110 -0
- pdpcompare-1.0.0.dist-info/METADATA +11 -0
- pdpcompare-1.0.0.dist-info/RECORD +40 -0
- pdpcompare-1.0.0.dist-info/WHEEL +4 -0
- pdpcompare-1.0.0.dist-info/entry_points.txt +3 -0
pdpcompare/__init__.py
ADDED
|
File without changes
|
pdpcompare/bundle.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Wire contract for the remote plan-data API (spec §4). Shared by the
|
|
2
|
+
desk's RemoteSource and the server's bundle builder — this module is the
|
|
3
|
+
single source of truth for the request/response shape. Pure data: no I/O,
|
|
4
|
+
no SQL, no httpx. Data minimization: requests carry RXCUIs/NDCs only,
|
|
5
|
+
never drug names or quantities."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DrugQuery:
|
|
12
|
+
candidate_rxcuis: tuple[str, ...]
|
|
13
|
+
ndcs: tuple[str, ...]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class BundleRequest:
|
|
18
|
+
plan_year: int
|
|
19
|
+
zip: str
|
|
20
|
+
drugs: tuple[DrugQuery, ...]
|
|
21
|
+
pharmacy_npi: str | None
|
|
22
|
+
pharmacy_zip: str | None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
return {"plan_year": self.plan_year, "zip": self.zip,
|
|
26
|
+
"drugs": [{"candidate_rxcuis": list(d.candidate_rxcuis),
|
|
27
|
+
"ndcs": list(d.ndcs)} for d in self.drugs],
|
|
28
|
+
"pharmacy": {"npi": self.pharmacy_npi,
|
|
29
|
+
"zip": self.pharmacy_zip}}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, d: dict) -> "BundleRequest":
|
|
33
|
+
return cls(plan_year=int(d["plan_year"]), zip=str(d["zip"]),
|
|
34
|
+
drugs=tuple(DrugQuery(
|
|
35
|
+
candidate_rxcuis=tuple(x["candidate_rxcuis"]),
|
|
36
|
+
ndcs=tuple(x["ndcs"])) for x in d["drugs"]),
|
|
37
|
+
pharmacy_npi=d["pharmacy"].get("npi"),
|
|
38
|
+
pharmacy_zip=d["pharmacy"].get("zip"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class InsulinCostRow:
|
|
43
|
+
"""Retail insulin cost-sharing for one tier (30-day code '1'). Raw file
|
|
44
|
+
values: copay in dollars, coinsurance as a FRACTION — decoding to
|
|
45
|
+
costsim's percent semantics happens desk-side via
|
|
46
|
+
plandata.decode_insulin_share, the same code path local mode uses.
|
|
47
|
+
|
|
48
|
+
tier None = wildcard row (CMS '.') applying to every tier; an
|
|
49
|
+
exact-tier row takes precedence desk-side."""
|
|
50
|
+
tier: int | None
|
|
51
|
+
copay_pref: float | None
|
|
52
|
+
copay_nonpref: float | None
|
|
53
|
+
coin_pref: float | None
|
|
54
|
+
coin_nonpref: float | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class BundlePlan:
|
|
59
|
+
contract_id: str
|
|
60
|
+
plan_id: str
|
|
61
|
+
segment_id: str
|
|
62
|
+
plan_name: str
|
|
63
|
+
formulary_id: str
|
|
64
|
+
deductible: float | None
|
|
65
|
+
monthly_premium: float | None
|
|
66
|
+
# preferred | standard | out_of_network | preferred_out_of_area |
|
|
67
|
+
# standard_out_of_area
|
|
68
|
+
pharmacy_status: str
|
|
69
|
+
insulin_costs: tuple[InsulinCostRow, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class CoverageRow:
|
|
74
|
+
formulary_id: str
|
|
75
|
+
rxcui: str
|
|
76
|
+
tier: int
|
|
77
|
+
prior_auth: bool
|
|
78
|
+
quantity_limit: bool
|
|
79
|
+
step_therapy: bool
|
|
80
|
+
ndc: str | None # the formulary's CMS proxy NDC
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class UnitCostRow:
|
|
85
|
+
contract_id: str
|
|
86
|
+
plan_id: str
|
|
87
|
+
segment_id: str
|
|
88
|
+
ndc: str
|
|
89
|
+
unit_cost: float
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class CostShareRow:
|
|
94
|
+
contract_id: str
|
|
95
|
+
plan_id: str
|
|
96
|
+
segment_id: str
|
|
97
|
+
tier: int
|
|
98
|
+
preferred: bool
|
|
99
|
+
cost_type: str # "copay" | "coinsurance" (already decoded)
|
|
100
|
+
cost_amt: float # dollars, or percent 0-100 for coinsurance
|
|
101
|
+
ded_applies: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class Bundle:
|
|
106
|
+
plan_year: int
|
|
107
|
+
premiums_plan_year: int | None
|
|
108
|
+
loaded_at: str | None
|
|
109
|
+
plans: tuple[BundlePlan, ...]
|
|
110
|
+
coverage: tuple[CoverageRow, ...]
|
|
111
|
+
unit_costs: tuple[UnitCostRow, ...]
|
|
112
|
+
cost_shares: tuple[CostShareRow, ...]
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict:
|
|
115
|
+
return {"plan_year": self.plan_year,
|
|
116
|
+
"premiums_plan_year": self.premiums_plan_year,
|
|
117
|
+
"loaded_at": self.loaded_at,
|
|
118
|
+
"plans": [asdict(p) for p in self.plans],
|
|
119
|
+
"coverage": [asdict(c) for c in self.coverage],
|
|
120
|
+
"unit_costs": [asdict(u) for u in self.unit_costs],
|
|
121
|
+
"cost_shares": [asdict(s) for s in self.cost_shares]}
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, d: dict) -> "Bundle":
|
|
125
|
+
pp = d.get("premiums_plan_year")
|
|
126
|
+
return cls(plan_year=int(d["plan_year"]),
|
|
127
|
+
premiums_plan_year=int(pp) if pp is not None else None,
|
|
128
|
+
loaded_at=d.get("loaded_at"),
|
|
129
|
+
plans=tuple(BundlePlan(**{
|
|
130
|
+
**p, "insulin_costs": tuple(
|
|
131
|
+
InsulinCostRow(**r) for r in p["insulin_costs"])})
|
|
132
|
+
for p in d["plans"]),
|
|
133
|
+
coverage=tuple(CoverageRow(**c) for c in d["coverage"]),
|
|
134
|
+
unit_costs=tuple(UnitCostRow(**u) for u in d["unit_costs"]),
|
|
135
|
+
cost_shares=tuple(CostShareRow(**s)
|
|
136
|
+
for s in d["cost_shares"]))
|
pdpcompare/cli.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Command-line entry point. The Claude skill drives these commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse, json, os, sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import httpx
|
|
6
|
+
from . import (clientstore, config, db, deskupdate, doctor, engine, inputs,
|
|
7
|
+
plandata, recommend, sources)
|
|
8
|
+
from .ingest import catalog, geo_loader, landscape_loader, spuf_loader
|
|
9
|
+
from .outputs import letter, worksheet
|
|
10
|
+
from .rxnorm import RxNormClient
|
|
11
|
+
|
|
12
|
+
def _connect():
|
|
13
|
+
return db.connect()
|
|
14
|
+
|
|
15
|
+
def _rx_client():
|
|
16
|
+
return RxNormClient(cache_dir=config.CACHE_DIR)
|
|
17
|
+
|
|
18
|
+
def _table_exists(conn, name: str) -> bool:
|
|
19
|
+
return conn.execute(
|
|
20
|
+
"SELECT count(*) FROM information_schema.tables WHERE table_name = ?",
|
|
21
|
+
[name]).fetchone()[0] > 0
|
|
22
|
+
|
|
23
|
+
def _build_source(args):
|
|
24
|
+
"""Remote mode when --api-url (or PDPCOMPARE_API_URL) is set;
|
|
25
|
+
otherwise local DuckDB, exactly as before. Loud fail, no silent
|
|
26
|
+
fallback between modes (spec §7)."""
|
|
27
|
+
if args.api_url:
|
|
28
|
+
if not args.api_key:
|
|
29
|
+
print("ERROR: --api-key (or PDPCOMPARE_API_KEY) is required "
|
|
30
|
+
"with --api-url", file=sys.stderr)
|
|
31
|
+
return None
|
|
32
|
+
return sources.RemoteSource(args.api_url, args.api_key)
|
|
33
|
+
return sources.LocalSource(_connect())
|
|
34
|
+
|
|
35
|
+
def cmd_ingest(args) -> int:
|
|
36
|
+
if args.landscape and not Path(args.landscape).is_file():
|
|
37
|
+
# Fail fast — before any multi-GB SPUF/GeoNames download.
|
|
38
|
+
print(f"ERROR: Landscape CSV not found: {args.landscape}",
|
|
39
|
+
file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
conn = _connect()
|
|
42
|
+
if args.spuf_dir:
|
|
43
|
+
extract_dir = Path(args.spuf_dir)
|
|
44
|
+
else:
|
|
45
|
+
rel = catalog.latest_spuf()
|
|
46
|
+
print(f"Downloading {rel.title} ({rel.modified})…", file=sys.stderr)
|
|
47
|
+
extract_dir = catalog.download_and_extract(rel.url, config.CACHE_DIR / "spuf")
|
|
48
|
+
spuf_loader.load_spuf(
|
|
49
|
+
conn, extract_dir, plan_year=args.year,
|
|
50
|
+
overrides=spuf_loader.load_overrides(config.OVERRIDES_PATH))
|
|
51
|
+
txt = geo_loader.download_geonames(config.CACHE_DIR / "geo")
|
|
52
|
+
geo_loader.load_geonames(conn, txt)
|
|
53
|
+
if args.landscape:
|
|
54
|
+
# Optional override: premiums already came from the SPUF above —
|
|
55
|
+
# this replaces them with the annual Landscape file. (Deductibles
|
|
56
|
+
# always stay SPUF-derived; the override only touches premiums.)
|
|
57
|
+
landscape_loader.load_landscape(conn, Path(args.landscape),
|
|
58
|
+
plan_year=args.year)
|
|
59
|
+
print(f"Ingest complete for plan year {args.year}.")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
def cmd_compare(args) -> int:
|
|
63
|
+
source = _build_source(args)
|
|
64
|
+
if source is None:
|
|
65
|
+
return 1
|
|
66
|
+
try:
|
|
67
|
+
premiums_year = source.premiums_plan_year()
|
|
68
|
+
except sources.RemoteDataError as e:
|
|
69
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
if premiums_year is None:
|
|
72
|
+
# Guard before emitting any JSON so the skill sees a clean stream.
|
|
73
|
+
print("ERROR: premium data is missing or of unknown vintage — "
|
|
74
|
+
"run `pdpcompare ingest --year <year>` (local mode) or "
|
|
75
|
+
"check the plan-data server's /v1/health (remote mode)",
|
|
76
|
+
file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
if premiums_year != args.year:
|
|
79
|
+
print(f"ERROR: premiums are for plan year {premiums_year}, you "
|
|
80
|
+
f"requested {args.year} — re-run ingest for {args.year} "
|
|
81
|
+
"(local mode) or wait for the server to load the new year "
|
|
82
|
+
"(remote mode)", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
for w in source.data_warnings():
|
|
85
|
+
print(f"WARNING: {w}", file=sys.stderr)
|
|
86
|
+
vintage = source.loaded_at()
|
|
87
|
+
rx = _rx_client()
|
|
88
|
+
in_path = Path(args.input)
|
|
89
|
+
records = (inputs.read_clients_xlsx(in_path) if in_path.suffix == ".xlsx"
|
|
90
|
+
else inputs.read_clients_json(in_path))
|
|
91
|
+
out_dir = Path(args.out)
|
|
92
|
+
clients_dir = Path(args.clients_dir) if args.clients_dir else None
|
|
93
|
+
summary = {"stay": 0, "switch": 0, "review": 0, "error": 0}
|
|
94
|
+
for rec_in in records:
|
|
95
|
+
try:
|
|
96
|
+
comp = engine.compare_client(source, rec_in, rx,
|
|
97
|
+
plan_year=args.year)
|
|
98
|
+
r = recommend.recommend(comp, switch_threshold=args.threshold,
|
|
99
|
+
near_tie_band=args.near_tie_band)
|
|
100
|
+
ws = worksheet.write_worksheet(comp, r, out_dir, vintage=vintage)
|
|
101
|
+
lt = letter.write_letter(comp, r, out_dir)
|
|
102
|
+
clientstore.save_client(rec_in, args.year, base_dir=clients_dir)
|
|
103
|
+
summary[r.action] += 1
|
|
104
|
+
print(json.dumps({"client_id": rec_in.client_id, "action": r.action,
|
|
105
|
+
"savings": r.savings, "worksheet": str(ws),
|
|
106
|
+
"letter": str(lt)}))
|
|
107
|
+
except engine.DrugNormalizationError as e:
|
|
108
|
+
summary["error"] += 1
|
|
109
|
+
print(json.dumps({"client_id": rec_in.client_id, "error": str(e),
|
|
110
|
+
"candidates": e.candidates}))
|
|
111
|
+
except engine.StaleDataError as e:
|
|
112
|
+
# Stale data must stop the whole batch (spec §5). Mid-batch
|
|
113
|
+
# this can now happen in remote mode too: the server can
|
|
114
|
+
# blue-green swap plan years while a batch runs (409 →
|
|
115
|
+
# StaleDataError). Emit a clean machine-readable stop in both
|
|
116
|
+
# modes rather than a raw traceback.
|
|
117
|
+
summary["error"] += 1
|
|
118
|
+
print(json.dumps({"client_id": rec_in.client_id,
|
|
119
|
+
"error": str(e),
|
|
120
|
+
"batch": "stopped — plan-year mismatch; "
|
|
121
|
+
"re-run after re-ingest (local) or "
|
|
122
|
+
"when the server settles (remote)"}))
|
|
123
|
+
print(json.dumps({"summary": summary}))
|
|
124
|
+
return 2
|
|
125
|
+
except sources.RemoteDataError as e:
|
|
126
|
+
# Plan-data server outage: pause and report, same contract as
|
|
127
|
+
# the RxNav pause path (spec §7 — loud fail, no fallback).
|
|
128
|
+
summary["error"] += 1
|
|
129
|
+
print(json.dumps({"client_id": rec_in.client_id,
|
|
130
|
+
"error": f"plan-data server error: {e}",
|
|
131
|
+
"batch": "paused — re-run when the server "
|
|
132
|
+
"is back; completed clients "
|
|
133
|
+
"already have outputs"}))
|
|
134
|
+
print(json.dumps({"summary": summary}))
|
|
135
|
+
return 2
|
|
136
|
+
except httpx.HTTPError as e:
|
|
137
|
+
# RxNav outage: pause and report rather than degrading (spec §5).
|
|
138
|
+
summary["error"] += 1
|
|
139
|
+
print(json.dumps({"client_id": rec_in.client_id,
|
|
140
|
+
"error": f"RxNav unreachable: {e!r}",
|
|
141
|
+
"batch": "paused — re-run when RxNav is back; "
|
|
142
|
+
"completed clients are cached"}))
|
|
143
|
+
print(json.dumps({"summary": summary}))
|
|
144
|
+
return 2
|
|
145
|
+
except Exception as e: # any other per-client failure: continue batch
|
|
146
|
+
summary["error"] += 1
|
|
147
|
+
print(json.dumps({"client_id": rec_in.client_id, "error": repr(e)}))
|
|
148
|
+
print(json.dumps({"summary": summary}))
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
def cmd_status(args) -> int:
|
|
152
|
+
if args.api_url:
|
|
153
|
+
if not args.api_key:
|
|
154
|
+
print("ERROR: --api-key (or PDPCOMPARE_API_KEY) is required "
|
|
155
|
+
"with --api-url", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
src = sources.RemoteSource(args.api_url, args.api_key)
|
|
158
|
+
try:
|
|
159
|
+
h = src.health()
|
|
160
|
+
except sources.RemoteDataError as e:
|
|
161
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
for k in ("status", "plan_year", "premiums_plan_year",
|
|
164
|
+
"loaded_at", "package_version", "last_update_check",
|
|
165
|
+
"update_error"):
|
|
166
|
+
print(f"{k}: {h.get(k)}")
|
|
167
|
+
return 0 if h.get("status") == "ok" else 1
|
|
168
|
+
conn = _connect()
|
|
169
|
+
try:
|
|
170
|
+
year = plandata.db_plan_year(conn)
|
|
171
|
+
except Exception:
|
|
172
|
+
print("No plan data loaded. Run: pdpcompare ingest --year <year>")
|
|
173
|
+
return 1
|
|
174
|
+
print(f"Plan year: {year}")
|
|
175
|
+
try:
|
|
176
|
+
loaded_at, source_dir = conn.sql(
|
|
177
|
+
"SELECT loaded_at, source_dir FROM meta").fetchone()
|
|
178
|
+
print(f"Data vintage: loaded {loaded_at} from {source_dir}")
|
|
179
|
+
except Exception:
|
|
180
|
+
print("Data vintage: unknown (meta table missing loaded_at/source_dir)")
|
|
181
|
+
for t in ("plan_info", "formulary", "beneficiary_cost", "insulin_cost",
|
|
182
|
+
"pharmacy_network", "pricing", "premiums", "zip_state"):
|
|
183
|
+
try:
|
|
184
|
+
n = conn.sql(f"SELECT count(*) FROM {t}").fetchone()[0]
|
|
185
|
+
except Exception:
|
|
186
|
+
n = "MISSING"
|
|
187
|
+
print(f" {t}: {n}")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
def cmd_update(args) -> int:
|
|
191
|
+
out = deskupdate.update_once(plan_year_override=args.plan_year,
|
|
192
|
+
force=args.force)
|
|
193
|
+
print(out.message)
|
|
194
|
+
if out.remap_request:
|
|
195
|
+
print(f"REMAP_REQUEST {out.remap_request}")
|
|
196
|
+
return 0 if out.updated or out.error_class is None else 1
|
|
197
|
+
|
|
198
|
+
def cmd_instructions(args) -> int:
|
|
199
|
+
from importlib import resources
|
|
200
|
+
print(resources.files("pdpcompare.skilldoc")
|
|
201
|
+
.joinpath("INSTRUCTIONS.md").read_text(encoding="utf-8"))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
def cmd_doctor(args) -> int:
|
|
205
|
+
results = doctor.run_checks()
|
|
206
|
+
ready = True
|
|
207
|
+
for r in results:
|
|
208
|
+
line = f"{r.level.upper():<5} {r.name}: {r.detail}"
|
|
209
|
+
if r.remedy:
|
|
210
|
+
line += f" | remedy: {r.remedy}"
|
|
211
|
+
print(line)
|
|
212
|
+
ready = ready and r.level != "fail"
|
|
213
|
+
print("READY — run comparisons" if ready
|
|
214
|
+
else "NOT READY — fix the FAIL items above")
|
|
215
|
+
return 0 if ready else 1
|
|
216
|
+
|
|
217
|
+
def main(argv: list[str] | None = None) -> int:
|
|
218
|
+
p = argparse.ArgumentParser(prog="pdpcompare")
|
|
219
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
220
|
+
|
|
221
|
+
pi = sub.add_parser("ingest")
|
|
222
|
+
pi.add_argument("--year", type=int, required=True)
|
|
223
|
+
pi.add_argument("--landscape",
|
|
224
|
+
help="Optional: path to the CMS PDP Landscape CSV to "
|
|
225
|
+
"override the premiums already loaded from the "
|
|
226
|
+
"SPUF plan information file (deductibles stay "
|
|
227
|
+
"SPUF-derived)")
|
|
228
|
+
pi.add_argument("--spuf-dir", help="Use an already-extracted SPUF dir")
|
|
229
|
+
pi.set_defaults(fn=cmd_ingest)
|
|
230
|
+
|
|
231
|
+
pc = sub.add_parser("compare")
|
|
232
|
+
pc.add_argument("--input", required=True)
|
|
233
|
+
pc.add_argument("--year", type=int, required=True)
|
|
234
|
+
pc.add_argument("--out", default=str(config.OUTPUT_DIR))
|
|
235
|
+
pc.add_argument("--threshold", type=float, default=100.0)
|
|
236
|
+
pc.add_argument("--near-tie-band", type=float,
|
|
237
|
+
default=config.NEAR_TIE_BAND,
|
|
238
|
+
help="Dollar band within which the top two plans are "
|
|
239
|
+
"presented as effectively equivalent")
|
|
240
|
+
pc.add_argument("--clients-dir", default=None)
|
|
241
|
+
pc.add_argument("--api-url",
|
|
242
|
+
default=os.environ.get("PDPCOMPARE_API_URL"),
|
|
243
|
+
help="Remote plan-data server URL (remote mode)")
|
|
244
|
+
pc.add_argument("--api-key",
|
|
245
|
+
default=os.environ.get("PDPCOMPARE_API_KEY"))
|
|
246
|
+
pc.set_defaults(fn=cmd_compare)
|
|
247
|
+
|
|
248
|
+
ps = sub.add_parser("status")
|
|
249
|
+
ps.add_argument("--api-url",
|
|
250
|
+
default=os.environ.get("PDPCOMPARE_API_URL"))
|
|
251
|
+
ps.add_argument("--api-key",
|
|
252
|
+
default=os.environ.get("PDPCOMPARE_API_KEY"))
|
|
253
|
+
ps.set_defaults(fn=cmd_status)
|
|
254
|
+
|
|
255
|
+
pu = sub.add_parser("update")
|
|
256
|
+
pu.add_argument("--plan-year", type=int, default=None,
|
|
257
|
+
help="Override the plan year derived from the CMS "
|
|
258
|
+
"release filename (plan-year cutover only)")
|
|
259
|
+
pu.add_argument("--force", action="store_true",
|
|
260
|
+
help="Re-ingest even if the loaded release is current")
|
|
261
|
+
pu.set_defaults(fn=cmd_update)
|
|
262
|
+
|
|
263
|
+
pd = sub.add_parser("doctor")
|
|
264
|
+
pd.set_defaults(fn=cmd_doctor)
|
|
265
|
+
|
|
266
|
+
pin = sub.add_parser("instructions")
|
|
267
|
+
pin.set_defaults(fn=cmd_instructions)
|
|
268
|
+
|
|
269
|
+
args = p.parse_args(argv)
|
|
270
|
+
return args.fn(args)
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Per-client per-plan-year JSON records. PHI: never leaves this machine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from . import config
|
|
7
|
+
from .models import ClientRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_client_id(client_id: str) -> None:
|
|
11
|
+
"""Client ids become filenames; reject anything that could escape the
|
|
12
|
+
store directory or collide on '.json'."""
|
|
13
|
+
cid = client_id.strip()
|
|
14
|
+
if not cid:
|
|
15
|
+
raise ValueError(f"invalid client_id {client_id!r}: empty")
|
|
16
|
+
forbidden = {"/", "\\", os.sep}
|
|
17
|
+
if os.altsep:
|
|
18
|
+
forbidden.add(os.altsep)
|
|
19
|
+
# separators are rejected outright, so the only possible ".." path
|
|
20
|
+
# segment left is the id being exactly ".."
|
|
21
|
+
if any(s in cid for s in forbidden) or cid == "..":
|
|
22
|
+
raise ValueError(f"invalid client_id {client_id!r}: must not contain "
|
|
23
|
+
"path separators or '..' segments")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# backwards-compatible alias for pre-rename callers
|
|
27
|
+
_validate_client_id = validate_client_id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_client(record: ClientRecord, plan_year: int,
|
|
31
|
+
base_dir: Path | None = None) -> Path:
|
|
32
|
+
validate_client_id(record.client_id)
|
|
33
|
+
base = base_dir or config.CLIENT_DIR
|
|
34
|
+
year_dir = base / str(plan_year)
|
|
35
|
+
year_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
path = year_dir / f"{record.client_id}.json"
|
|
37
|
+
path.write_text(json.dumps(record.to_dict(), indent=2))
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_clients(plan_year: int, base_dir: Path | None = None) -> list[ClientRecord]:
|
|
42
|
+
base = base_dir or config.CLIENT_DIR
|
|
43
|
+
year_dir = base / str(plan_year)
|
|
44
|
+
if not year_dir.exists():
|
|
45
|
+
return []
|
|
46
|
+
return [ClientRecord.from_dict(json.loads(p.read_text()))
|
|
47
|
+
for p in sorted(year_dir.glob("*.json"))]
|
pdpcompare/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
_SRC_ROOT = Path(__file__).resolve().parent.parent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve_root(src_root: Path, env: str | None, cwd: Path) -> Path:
|
|
8
|
+
"""Anchor for data/ and output/. A source checkout (pyproject.toml
|
|
9
|
+
next to the package) anchors to the repo, exactly as before. A wheel
|
|
10
|
+
install must NOT anchor to site-packages — it uses the current
|
|
11
|
+
working directory (Cowork: the mounted working folder), overridable
|
|
12
|
+
via PDPCOMPARE_HOME."""
|
|
13
|
+
if env:
|
|
14
|
+
return Path(env)
|
|
15
|
+
return src_root if (src_root / "pyproject.toml").is_file() else cwd
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ROOT = resolve_root(_SRC_ROOT, os.environ.get("PDPCOMPARE_HOME"), Path.cwd())
|
|
19
|
+
DATA_DIR = ROOT / "data"
|
|
20
|
+
DB_PATH = DATA_DIR / "pdpcompare.duckdb"
|
|
21
|
+
CACHE_DIR = DATA_DIR / "cache"
|
|
22
|
+
OUTPUT_DIR = ROOT / "output"
|
|
23
|
+
CLIENT_DIR = DATA_DIR / "clients"
|
|
24
|
+
OVERRIDES_PATH = DATA_DIR / "header_overrides.json"
|
|
25
|
+
|
|
26
|
+
PLAN_YEAR = 2026
|
|
27
|
+
|
|
28
|
+
# Standard Part D benefit parameters. VERIFY EACH FALL when CMS publishes
|
|
29
|
+
# the next year's defined standard benefit (deductible + IRA OOP cap).
|
|
30
|
+
BENEFIT_YEARS: dict[int, dict] = {
|
|
31
|
+
2026: {"deductible_max": 615.0, "oop_cap": 2100.0},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Presentation-only: when the top two complete plans are within this many
|
|
35
|
+
# dollars/year, worksheets and letters say they are effectively equivalent.
|
|
36
|
+
NEAR_TIE_BAND = 50.0
|
|
37
|
+
|
|
38
|
+
# Desk staleness threshold — matches the server's PDP_STALE_DAYS default.
|
|
39
|
+
STALE_DAYS = 100
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def intended_plan_year(now=None) -> int:
|
|
43
|
+
"""AEP rule: Oct–Dec comparisons target NEXT calendar year."""
|
|
44
|
+
from datetime import datetime
|
|
45
|
+
now = now or datetime.now()
|
|
46
|
+
return now.year + 1 if now.month >= 10 else now.year
|
pdpcompare/costsim.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Month-by-month simulation of a plan year's out-of-pocket drug costs.
|
|
2
|
+
|
|
3
|
+
Approximates the post-IRA (2025+) defined standard benefit:
|
|
4
|
+
deductible -> initial coverage (tier cost-share) -> catastrophic ($0 after
|
|
5
|
+
the annual OOP cap). Matches the accumulation model Medicare Plan Finder
|
|
6
|
+
uses at the fidelity available in the CMS public files (plan-average
|
|
7
|
+
retail pricing; manufacturer-discount amounts excluded).
|
|
8
|
+
"""
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class PricedDrug:
|
|
14
|
+
monthly_full_cost: float # unit_cost * quantity for one 30-day fill
|
|
15
|
+
cost_type: str # "copay" | "coinsurance"
|
|
16
|
+
cost_amt: float # dollars if copay; percent (e.g. 25.0) if coinsurance
|
|
17
|
+
ded_applies: bool
|
|
18
|
+
is_insulin: bool = False # IRA statutory pricing (no deductible, $35 cap)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
INSULIN_MONTHLY_CAP = 35.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def simulate_annual_cost(priced_drugs: list[PricedDrug],
|
|
25
|
+
deductible: float, oop_cap: float) -> float:
|
|
26
|
+
total_paid = 0.0
|
|
27
|
+
ded_remaining = deductible
|
|
28
|
+
for _month in range(12):
|
|
29
|
+
for d in priced_drugs:
|
|
30
|
+
if total_paid >= oop_cap:
|
|
31
|
+
continue
|
|
32
|
+
remaining_cost = d.monthly_full_cost
|
|
33
|
+
pay = 0.0
|
|
34
|
+
if d.is_insulin:
|
|
35
|
+
# IRA: the deductible never applies to covered insulin; the
|
|
36
|
+
# member pays the lesser of the plan share and $35/month.
|
|
37
|
+
if d.cost_type == "coinsurance":
|
|
38
|
+
share = remaining_cost * d.cost_amt / 100.0
|
|
39
|
+
else:
|
|
40
|
+
share = min(d.cost_amt, remaining_cost)
|
|
41
|
+
pay = min(share, INSULIN_MONTHLY_CAP, remaining_cost)
|
|
42
|
+
else:
|
|
43
|
+
if d.ded_applies and ded_remaining > 0:
|
|
44
|
+
ded_part = min(remaining_cost, ded_remaining)
|
|
45
|
+
pay += ded_part
|
|
46
|
+
ded_remaining -= ded_part
|
|
47
|
+
remaining_cost -= ded_part
|
|
48
|
+
if remaining_cost > 0:
|
|
49
|
+
if d.cost_type == "coinsurance":
|
|
50
|
+
pay += remaining_cost * d.cost_amt / 100.0
|
|
51
|
+
else:
|
|
52
|
+
pay += min(d.cost_amt, remaining_cost)
|
|
53
|
+
pay = min(pay, max(0.0, oop_cap - total_paid))
|
|
54
|
+
total_paid += pay
|
|
55
|
+
return round(total_paid, 2)
|
pdpcompare/db.py
ADDED