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 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
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+ import duckdb
3
+ from . import config
4
+
5
+
6
+ def connect(db_path: Path | None = None) -> duckdb.DuckDBPyConnection:
7
+ path = db_path or config.DB_PATH
8
+ path.parent.mkdir(parents=True, exist_ok=True)
9
+ return duckdb.connect(str(path))