jpomop 0.0.1__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.
jpomop/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Convert Japanese DPC inpatient data to the OMOP Common Data Model v5.4."""
2
+
3
+ __version__ = "0.0.1"
jpomop/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from pathlib import Path
5
+
6
+ import click
7
+ import pyarrow as pa
8
+ import pyarrow.csv as pacsv
9
+
10
+ from jpomop import __version__
11
+ from jpomop.parse import (
12
+ Violation,
13
+ check_cross_file,
14
+ pivot_yoshiki1,
15
+ read_ef,
16
+ read_yoshiki1,
17
+ violations_table,
18
+ )
19
+
20
+ _existing = click.Path(exists=True, dir_okay=False, path_type=Path)
21
+
22
+
23
+ @click.group()
24
+ @click.version_option(__version__)
25
+ def main() -> None:
26
+ """jpomop: DPC to OMOP CDM v5.4."""
27
+
28
+
29
+ @main.command()
30
+ @click.option("--yoshiki1", "y1_paths", type=_existing, multiple=True, help="Form 1 file(s).")
31
+ @click.option("--ef", "ef_paths", type=_existing, multiple=True, help="EF integrated file(s).")
32
+ @click.option(
33
+ "--out",
34
+ type=click.Path(dir_okay=False, path_type=Path),
35
+ help="Write every violation to this CSV file.",
36
+ )
37
+ def validate(y1_paths: tuple[Path, ...], ef_paths: tuple[Path, ...], out: Path | None) -> None:
38
+ """Check DPC files against the FY2026 specification.
39
+
40
+ Exits with status 1 if any violation is found.
41
+ """
42
+ if not y1_paths and not ef_paths:
43
+ raise click.UsageError("give at least one --yoshiki1 or --ef file")
44
+
45
+ violations: list[Violation] = []
46
+ forms = []
47
+ for p in y1_paths:
48
+ res = read_yoshiki1(p)
49
+ tables = pivot_yoshiki1(res.table)
50
+ violations += res.violations + tables.violations
51
+ forms.append(tables.forms)
52
+ efs = []
53
+ for p in ef_paths:
54
+ res = read_ef(p)
55
+ violations += res.violations
56
+ efs.append(res.table)
57
+ if forms and efs:
58
+ violations += check_cross_file(
59
+ pa.concat_tables(efs),
60
+ pa.concat_tables(forms),
61
+ ef_file=ef_paths[0].name,
62
+ y1_file=y1_paths[0].name,
63
+ )
64
+
65
+ counts = Counter(v.rule for v in violations)
66
+ if not violations:
67
+ click.echo("no violations")
68
+ for rule, n in sorted(counts.items()):
69
+ click.echo(f"{rule}\t{n}")
70
+ if out:
71
+ pacsv.write_csv(violations_table(violations), out)
72
+ click.echo(f"wrote {len(violations)} rows to {out}")
73
+ if violations:
74
+ raise SystemExit(1)
75
+
76
+
77
+ @main.command()
78
+ @click.option("--n", "n_patients", default=100, show_default=True, help="Number of patients.")
79
+ @click.option("--seed", default=20261012, show_default=True)
80
+ @click.option("--out", type=click.Path(file_okay=False, path_type=Path), required=True)
81
+ def synth(n_patients: int, seed: int, out: Path) -> None:
82
+ """Write a synthetic Form 1 and EF file (June 2026). Everything is invented."""
83
+ from jpomop.synth import generate
84
+
85
+ m = generate(out, n_patients, seed=seed)
86
+ click.echo(f"{m.n_patients} patients, {m.n_stays} stays")
87
+ click.echo(f"{m.yoshiki1}\n{m.ef}")
88
+
89
+
90
+ @main.command()
91
+ @click.option("--yoshiki1", "y1_paths", type=_existing, multiple=True, required=True)
92
+ @click.option("--ef", "ef_paths", type=_existing, multiple=True, required=True)
93
+ @click.option(
94
+ "--out",
95
+ type=click.Path(path_type=Path),
96
+ required=True,
97
+ help="DuckDB file, or a directory with --format parquet.",
98
+ )
99
+ @click.option(
100
+ "--format", "fmt", type=click.Choice(["duckdb", "parquet"]), default="duckdb", show_default=True
101
+ )
102
+ @click.option(
103
+ "--vocab",
104
+ "vocab_path",
105
+ type=_existing,
106
+ help="vocab.duckdb from `jpomop vocab build`. Without it, concept IDs are 0.",
107
+ )
108
+ def convert(
109
+ y1_paths: tuple[Path, ...],
110
+ ef_paths: tuple[Path, ...],
111
+ out: Path,
112
+ fmt: str,
113
+ vocab_path: Path | None,
114
+ ) -> None:
115
+ """Convert DPC files to OMOP CDM v5.4 tables."""
116
+ from jpomop.etl import run_etl
117
+
118
+ report = run_etl(
119
+ list(y1_paths),
120
+ list(ef_paths),
121
+ out,
122
+ fmt, # type: ignore[arg-type]
123
+ vocab=vocab_path,
124
+ )
125
+ for name, n in report.rows.items():
126
+ click.echo(f"{name}\t{n}")
127
+ for kind, n in sorted(report.skipped_forms.items()):
128
+ click.echo(f"skipped forms {kind}\t{n}")
129
+ for table, reasons in report.skipped_rows.items():
130
+ for reason, n in sorted(reasons.items()):
131
+ click.echo(f"left out of {table}: {reason}\t{n}")
132
+ if report.n_violations:
133
+ click.echo(f"input violations\t{report.n_violations} (run jpomop validate for details)")
134
+ for p in report.invariant_problems:
135
+ click.echo(f"INVARIANT\t{p}", err=True)
136
+ if report.invariant_problems:
137
+ raise SystemExit(2)
138
+
139
+
140
+ @main.group()
141
+ def vocab() -> None:
142
+ """Build the local vocabulary from files you download yourself."""
143
+
144
+
145
+ @vocab.command("build")
146
+ @click.option(
147
+ "--athena",
148
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
149
+ required=True,
150
+ help="Folder with CONCEPT.csv and CONCEPT_RELATIONSHIP.csv.",
151
+ )
152
+ @click.option(
153
+ "--drug-master",
154
+ type=_existing,
155
+ multiple=True,
156
+ required=True,
157
+ help="医薬品マスター y_*.csv (repeat for several files).",
158
+ )
159
+ @click.option("--kimura", type=_existing, help="Kimura et al. 2025 mapping (rxnorm_mapping.tsv).")
160
+ @click.option("--out", type=click.Path(dir_okay=False, path_type=Path), required=True)
161
+ def vocab_build(
162
+ athena: Path, drug_master: tuple[Path, ...], kimura: Path | None, out: Path
163
+ ) -> None:
164
+ from jpomop.vocab import build_vocab
165
+
166
+ r = build_vocab(athena, list(drug_master), kimura, out)
167
+ click.echo(f"ICD-10 codes\t{r.icd10_codes} ({r.icd10_mapped} map to a standard concept)")
168
+ click.echo(f"drug receipt codes\t{r.receipt_drugs} ({r.receipt_drugs_mapped} mapped)")
169
+
170
+
171
+ @main.command()
172
+ @click.option("--cdm", type=_existing, required=True, help="DuckDB file from jpomop convert.")
173
+ @click.option("--top", default=10, show_default=True, help="Unmapped source codes to list.")
174
+ def report(cdm: Path, top: int) -> None:
175
+ """Show how much of each domain maps to standard concepts."""
176
+ from jpomop.report import coverage
177
+
178
+ for c in coverage(cdm, top):
179
+ click.echo(
180
+ f"{c.table}\trows {c.mapped_rows}/{c.rows} ({c.row_rate:.1%})\t"
181
+ f"codes {c.mapped_codes}/{c.codes} ({c.code_rate:.1%})"
182
+ )
183
+ for code, n in c.top_unmapped:
184
+ click.echo(f" unmapped {code}\t{n}")
jpomop/etl/__init__.py ADDED
@@ -0,0 +1,130 @@
1
+ """Turn parsed DPC tables into OMOP CDM v5.4 tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import duckdb
10
+ import pyarrow as pa
11
+ import pyarrow.parquet as pq
12
+
13
+ from jpomop.etl.build import (
14
+ build_death,
15
+ build_observation_period,
16
+ build_person,
17
+ build_visit_detail,
18
+ build_visit_occurrence,
19
+ last_service_dates,
20
+ person_ids,
21
+ visit_ids,
22
+ whole_stay_forms,
23
+ )
24
+ from jpomop.etl.checks import check_invariants
25
+ from jpomop.etl.clinical import (
26
+ build_condition_occurrence,
27
+ build_drug_exposure,
28
+ build_procedure_occurrence,
29
+ )
30
+ from jpomop.parse import pivot_yoshiki1, read_ef, read_yoshiki1
31
+ from jpomop.vocab import Vocab
32
+
33
+ TABLE_ORDER = [
34
+ "person",
35
+ "observation_period",
36
+ "visit_occurrence",
37
+ "visit_detail",
38
+ "condition_occurrence",
39
+ "procedure_occurrence",
40
+ "drug_exposure",
41
+ "death",
42
+ ]
43
+
44
+
45
+ @dataclass
46
+ class EtlReport:
47
+ rows: dict[str, int]
48
+ skipped_forms: dict[str, int] = field(default_factory=dict)
49
+ skipped_rows: dict[str, dict[str, int]] = field(default_factory=dict)
50
+ n_violations: int = 0
51
+ invariant_problems: list[str] = field(default_factory=list)
52
+
53
+
54
+ def build_cdm(
55
+ forms: pa.Table, ef: pa.Table, diagnoses: pa.Table | None = None, vocab: Vocab | None = None
56
+ ) -> tuple[dict[str, pa.Table], EtlReport]:
57
+ vocab = vocab or Vocab()
58
+ whole, skipped = whole_stay_forms(forms)
59
+ pids, vids = person_ids(whole), visit_ids(whole)
60
+ visits = build_visit_occurrence(whole, pids, vids, last_service_dates(ef))
61
+ cdm = {
62
+ "person": build_person(whole, pids),
63
+ "observation_period": build_observation_period(visits),
64
+ "visit_occurrence": visits,
65
+ "visit_detail": build_visit_detail(ef, visits, vids),
66
+ "death": build_death(whole, pids),
67
+ }
68
+ skipped_rows: dict[str, dict[str, int]] = {}
69
+ if diagnoses is not None:
70
+ cdm["condition_occurrence"], skip = build_condition_occurrence(
71
+ diagnoses, visits, vids, vocab
72
+ )
73
+ skipped_rows["condition_occurrence"] = dict(skip)
74
+ cdm["drug_exposure"], skip = build_drug_exposure(ef, visits, vids, vocab)
75
+ skipped_rows["drug_exposure"] = dict(skip)
76
+ cdm["procedure_occurrence"] = build_procedure_occurrence(ef, visits, vids)
77
+ report = EtlReport(
78
+ rows={k: t.num_rows for k, t in cdm.items()},
79
+ skipped_forms=dict(skipped),
80
+ skipped_rows=skipped_rows,
81
+ invariant_problems=check_invariants(cdm),
82
+ )
83
+ return cdm, report
84
+
85
+
86
+ def write_cdm(
87
+ cdm: dict[str, pa.Table], out: Path, fmt: Literal["duckdb", "parquet"] = "duckdb"
88
+ ) -> None:
89
+ if fmt == "parquet":
90
+ out.mkdir(parents=True, exist_ok=True)
91
+ for name in (n for n in TABLE_ORDER if n in cdm):
92
+ pq.write_table(cdm[name], out / f"{name}.parquet")
93
+ return
94
+ out.unlink(missing_ok=True)
95
+ con = duckdb.connect(str(out))
96
+ try:
97
+ for name in (n for n in TABLE_ORDER if n in cdm):
98
+ tbl = cdm[name] # noqa: F841 (read by DuckDB through the local variable)
99
+ con.execute(f"CREATE TABLE {name} AS SELECT * FROM tbl")
100
+ finally:
101
+ con.close()
102
+
103
+
104
+ def run_etl(
105
+ yoshiki1: list[Path],
106
+ ef: list[Path],
107
+ out: Path,
108
+ fmt: Literal["duckdb", "parquet"] = "duckdb",
109
+ vocab: Path | None = None,
110
+ ) -> EtlReport:
111
+ forms, dxs, efs, n_violations = [], [], [], 0
112
+ for p in yoshiki1:
113
+ res = read_yoshiki1(p)
114
+ tables = pivot_yoshiki1(res.table)
115
+ forms.append(tables.forms)
116
+ dxs.append(tables.diagnoses)
117
+ n_violations += len(res.violations) + len(tables.violations)
118
+ for p in ef:
119
+ res = read_ef(p)
120
+ efs.append(res.table)
121
+ n_violations += len(res.violations)
122
+ cdm, report = build_cdm(
123
+ pa.concat_tables(forms),
124
+ pa.concat_tables(efs),
125
+ pa.concat_tables(dxs),
126
+ Vocab.load(vocab) if vocab else None,
127
+ )
128
+ report.n_violations = n_violations
129
+ write_cdm(cdm, out, fmt)
130
+ return report
jpomop/etl/build.py ADDED
@@ -0,0 +1,232 @@
1
+ """Build CDM tables from parsed DPC tables.
2
+
3
+ Keys:
4
+ - patient: (facility_code, data_id)
5
+ - stay: (facility_code, data_id, admission_date)
6
+
7
+ Only whole-stay forms (統括診療情報番号 0) become visits. Ward-group
8
+ forms, short readmissions (A, B) and the day-90 interim form (Z) are
9
+ counted by `run_etl` but not converted yet; see docs/adr.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ from collections import Counter, defaultdict
16
+ from datetime import date
17
+ from typing import Any
18
+
19
+ import pyarrow as pa
20
+
21
+ from jpomop.etl import concepts as C
22
+ from jpomop.etl import schema as S
23
+ from jpomop.spec import load_code_table
24
+
25
+ Patient = tuple[str, str]
26
+ Stay = tuple[str, str, date]
27
+
28
+
29
+ def source_value(facility_code: str, data_id: str) -> str:
30
+ """Hash of the patient key, so the hospital's data ID never leaves the ETL."""
31
+ return hashlib.sha256(f"{facility_code}:{data_id}".encode()).hexdigest()[:16]
32
+
33
+
34
+ def whole_stay_forms(forms: pa.Table) -> tuple[list[dict[str, Any]], Counter[str]]:
35
+ """Pick one form per stay and count what was not used.
36
+
37
+ - Whole-stay forms (統括診療情報番号 0) are used.
38
+ - A day-90 interim form (Z) is used only when the stay has no whole-stay
39
+ form yet, i.e. the patient was still in hospital when the data was cut.
40
+ The ward-group forms (1, 2, ...) and the aggregated short-readmission
41
+ forms (A, B) repeat information already in the whole-stay forms and are
42
+ not used (p42).
43
+ - Several one-day stays on the same day (回数管理番号 1, 2, ...) cannot be
44
+ told apart in the EF file, so they become one visit.
45
+ """
46
+ counts: Counter[str] = Counter()
47
+ by_stay: dict[Stay, dict[str, Any]] = {}
48
+ interim: dict[Stay, dict[str, Any]] = {}
49
+ for f in forms.to_pylist():
50
+ stay = (f["facility_code"], f["data_id"], f["admission_date"])
51
+ if f["summary_no"] == "0":
52
+ if stay in by_stay:
53
+ counts["same_day_stays_merged"] += 1
54
+ continue
55
+ by_stay[stay] = f
56
+ elif f["summary_no"] == "Z":
57
+ interim.setdefault(stay, f)
58
+ else:
59
+ counts[f"summary_no={f['summary_no']}"] += 1
60
+ for stay, f in interim.items():
61
+ if stay in by_stay:
62
+ counts["summary_no=Z"] += 1
63
+ else:
64
+ by_stay[stay] = f
65
+ counts["interim_form_used"] += 1
66
+ return [by_stay[k] for k in sorted(by_stay)], counts
67
+
68
+
69
+ def person_ids(forms: list[dict[str, Any]]) -> dict[Patient, int]:
70
+ keys = sorted({(f["facility_code"], f["data_id"]) for f in forms})
71
+ return {k: i for i, k in enumerate(keys, start=1)}
72
+
73
+
74
+ def visit_ids(forms: list[dict[str, Any]]) -> dict[Stay, int]:
75
+ keys = sorted({(f["facility_code"], f["data_id"], f["admission_date"]) for f in forms})
76
+ return {k: i for i, k in enumerate(keys, start=1)}
77
+
78
+
79
+ def build_person(forms: list[dict[str, Any]], pids: dict[Patient, int]) -> pa.Table:
80
+ first: dict[Patient, dict[str, Any]] = {}
81
+ for f in forms: # forms are sorted, so the first form per patient is the earliest stay
82
+ first.setdefault((f["facility_code"], f["data_id"]), f)
83
+ rows = []
84
+ for key, pid in sorted(pids.items(), key=lambda kv: kv[1]):
85
+ f = first[key]
86
+ b: date | None = f["birth_date"]
87
+ rows.append(
88
+ {
89
+ "person_id": pid,
90
+ "gender_concept_id": C.GENDER.get(f["sex"] or "", C.UNKNOWN),
91
+ "year_of_birth": b.year if b else None,
92
+ "month_of_birth": b.month if b else None,
93
+ "day_of_birth": b.day if b else None,
94
+ "race_concept_id": C.UNKNOWN,
95
+ "ethnicity_concept_id": C.UNKNOWN,
96
+ "person_source_value": source_value(*key),
97
+ "gender_source_value": f["sex"],
98
+ }
99
+ )
100
+ return pa.Table.from_pylist(rows, schema=S.PERSON)
101
+
102
+
103
+ def last_service_dates(ef: pa.Table) -> dict[Stay, date]:
104
+ last: dict[Stay, date] = {}
105
+ for r in ef.select(["facility_code", "data_id", "admission_date", "exec_date"]).to_pylist():
106
+ if r["exec_date"] is None:
107
+ continue
108
+ stay = (r["facility_code"], r["data_id"], r["admission_date"])
109
+ last[stay] = max(last.get(stay, r["exec_date"]), r["exec_date"])
110
+ return last
111
+
112
+
113
+ def build_visit_occurrence(
114
+ forms: list[dict[str, Any]],
115
+ pids: dict[Patient, int],
116
+ vids: dict[Stay, int],
117
+ last_service: dict[Stay, date] | None = None,
118
+ ) -> pa.Table:
119
+ """Visit end is the discharge date. A stay with no discharge date yet (an
120
+ interim form) ends on its last date of service in the EF file, or on the
121
+ admission date if there is none."""
122
+ last_service = last_service or {}
123
+ rows = []
124
+ for f in forms:
125
+ stay = (f["facility_code"], f["data_id"], f["admission_date"])
126
+ end = f["discharge_date"] or last_service.get(stay) or f["admission_date"]
127
+ rows.append(
128
+ {
129
+ "visit_occurrence_id": vids[stay],
130
+ "person_id": pids[stay[:2]],
131
+ "visit_concept_id": C.INPATIENT_VISIT,
132
+ "visit_start_date": f["admission_date"],
133
+ "visit_end_date": end,
134
+ "visit_type_concept_id": C.TYPE_CLAIM,
135
+ "visit_source_value": "dpc_yoshiki1",
136
+ "visit_source_concept_id": C.UNKNOWN,
137
+ "admitted_from_concept_id": C.UNKNOWN,
138
+ "admitted_from_source_value": f["admission_route"],
139
+ "discharged_to_concept_id": C.UNKNOWN,
140
+ "discharged_to_source_value": f["discharge_to"],
141
+ }
142
+ )
143
+ return pa.Table.from_pylist(rows, schema=S.VISIT_OCCURRENCE)
144
+
145
+
146
+ def build_visit_detail(ef: pa.Table, visits: pa.Table, vids: dict[Stay, int]) -> pa.Table:
147
+ """One row per run of consecutive service dates on the same ward."""
148
+ vinfo = {r["visit_occurrence_id"]: r for r in visits.to_pylist()}
149
+ days: dict[int, dict[date, str]] = defaultdict(dict)
150
+ for r in ef.select(
151
+ ["facility_code", "data_id", "admission_date", "exec_date", "ward_code"]
152
+ ).to_pylist():
153
+ vid = vids.get((r["facility_code"], r["data_id"], r["admission_date"]))
154
+ if vid is None or r["exec_date"] is None:
155
+ continue
156
+ days[vid].setdefault(r["exec_date"], r["ward_code"])
157
+ rows = []
158
+ next_id = 1
159
+ for vid in sorted(days):
160
+ v = vinfo[vid]
161
+ runs: list[list[Any]] = [] # [ward, start, end]
162
+ for d in sorted(days[vid]):
163
+ ward = days[vid][d]
164
+ if runs and runs[-1][0] == ward:
165
+ runs[-1][2] = d
166
+ else:
167
+ runs.append([ward, d, d])
168
+ prev = None
169
+ for ward, start, end in runs:
170
+ start = max(start, v["visit_start_date"])
171
+ end = min(end, v["visit_end_date"])
172
+ rows.append(
173
+ {
174
+ "visit_detail_id": next_id,
175
+ "person_id": v["person_id"],
176
+ "visit_detail_concept_id": C.INPATIENT_VISIT,
177
+ "visit_detail_start_date": start,
178
+ "visit_detail_end_date": end,
179
+ "visit_detail_type_concept_id": C.TYPE_CLAIM,
180
+ "visit_detail_source_value": ward,
181
+ "visit_detail_source_concept_id": C.UNKNOWN,
182
+ "admitted_from_concept_id": C.UNKNOWN,
183
+ "discharged_to_concept_id": C.UNKNOWN,
184
+ "preceding_visit_detail_id": prev,
185
+ "visit_occurrence_id": vid,
186
+ }
187
+ )
188
+ prev = next_id
189
+ next_id += 1
190
+ return pa.Table.from_pylist(rows, schema=S.VISIT_DETAIL)
191
+
192
+
193
+ def build_observation_period(visits: pa.Table) -> pa.Table:
194
+ span: dict[int, list[date]] = {}
195
+ for v in visits.to_pylist():
196
+ s, e = v["visit_start_date"], v["visit_end_date"]
197
+ if v["person_id"] in span:
198
+ span[v["person_id"]][0] = min(span[v["person_id"]][0], s)
199
+ span[v["person_id"]][1] = max(span[v["person_id"]][1], e)
200
+ else:
201
+ span[v["person_id"]] = [s, e]
202
+ rows = [
203
+ {
204
+ "observation_period_id": pid,
205
+ "person_id": pid,
206
+ "observation_period_start_date": s,
207
+ "observation_period_end_date": e,
208
+ "period_type_concept_id": C.TYPE_CLAIM,
209
+ }
210
+ for pid, (s, e) in sorted(span.items())
211
+ ]
212
+ return pa.Table.from_pylist(rows, schema=S.OBSERVATION_PERIOD)
213
+
214
+
215
+ def build_death(forms: list[dict[str, Any]], pids: dict[Patient, int]) -> pa.Table:
216
+ """A stay whose discharge outcome (退院時転帰) is death (6 or 7). Latest one per person."""
217
+ death_codes = set(load_code_table("y1_discharge_outcome")["death"])
218
+ latest: dict[int, date] = {}
219
+ for f in forms:
220
+ if f["discharge_outcome"] in death_codes and f["discharge_date"]:
221
+ pid = pids[(f["facility_code"], f["data_id"])]
222
+ latest[pid] = max(latest.get(pid, f["discharge_date"]), f["discharge_date"])
223
+ rows = [
224
+ {
225
+ "person_id": pid,
226
+ "death_date": d,
227
+ "death_type_concept_id": C.TYPE_CLAIM,
228
+ "cause_concept_id": C.UNKNOWN,
229
+ }
230
+ for pid, d in sorted(latest.items())
231
+ ]
232
+ return pa.Table.from_pylist(rows, schema=S.DEATH)
jpomop/etl/checks.py ADDED
@@ -0,0 +1,63 @@
1
+ """Invariants every CDM output must satisfy. Each returns a list of problems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+
7
+ import pyarrow as pa
8
+
9
+
10
+ def check_invariants(cdm: dict[str, pa.Table]) -> list[str]:
11
+ problems: list[str] = []
12
+ person_ids = cdm["person"].column("person_id").to_pylist()
13
+ dup = [p for p, n in Counter(person_ids).items() if n > 1]
14
+ if dup:
15
+ problems.append(f"person: duplicate person_id {dup[:5]}")
16
+ known = set(person_ids)
17
+
18
+ periods: dict[int, tuple[object, object]] = {}
19
+ for r in cdm["observation_period"].to_pylist():
20
+ if r["person_id"] in periods:
21
+ problems.append(f"observation_period: more than one period for {r['person_id']}")
22
+ periods[r["person_id"]] = (
23
+ r["observation_period_start_date"],
24
+ r["observation_period_end_date"],
25
+ )
26
+
27
+ events = {
28
+ "visit_occurrence": ("visit_start_date", "visit_end_date"),
29
+ "visit_detail": ("visit_detail_start_date", "visit_detail_end_date"),
30
+ "death": ("death_date", "death_date"),
31
+ "condition_occurrence": ("condition_start_date", "condition_start_date"),
32
+ "drug_exposure": ("drug_exposure_start_date", "drug_exposure_end_date"),
33
+ "procedure_occurrence": ("procedure_date", "procedure_date"),
34
+ }
35
+ for table, (start_col, end_col) in events.items():
36
+ if table not in cdm:
37
+ continue
38
+ for r in cdm[table].to_pylist():
39
+ pid, s, e = r["person_id"], r[start_col], r[end_col]
40
+ if pid not in known:
41
+ problems.append(f"{table}: person_id {pid} not in person")
42
+ continue
43
+ if s > e:
44
+ problems.append(f"{table}: start after end for person {pid}")
45
+ ps, pe = periods.get(pid, (None, None))
46
+ if ps is None or s < ps or e > pe: # type: ignore[operator]
47
+ problems.append(f"{table}: outside observation_period for person {pid}")
48
+
49
+ visits = {r["visit_occurrence_id"]: r for r in cdm["visit_occurrence"].to_pylist()}
50
+ died = {r["person_id"]: r["death_date"] for r in cdm["death"].to_pylist()}
51
+ for v in visits.values():
52
+ if v["person_id"] in died and v["visit_start_date"] > died[v["person_id"]]:
53
+ problems.append(f"visit_occurrence: stay starts after death for {v['person_id']}")
54
+ for r in cdm["visit_detail"].to_pylist():
55
+ v = visits.get(r["visit_occurrence_id"])
56
+ if v is None:
57
+ problems.append(f"visit_detail {r['visit_detail_id']}: unknown visit")
58
+ elif not (
59
+ v["visit_start_date"] <= r["visit_detail_start_date"]
60
+ and r["visit_detail_end_date"] <= v["visit_end_date"]
61
+ ):
62
+ problems.append(f"visit_detail {r['visit_detail_id']}: outside its visit")
63
+ return problems