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 +3 -0
- jpomop/cli.py +184 -0
- jpomop/etl/__init__.py +130 -0
- jpomop/etl/build.py +232 -0
- jpomop/etl/checks.py +63 -0
- jpomop/etl/clinical.py +148 -0
- jpomop/etl/concepts.py +25 -0
- jpomop/etl/schema.py +169 -0
- jpomop/parse/__init__.py +17 -0
- jpomop/parse/cross.py +77 -0
- jpomop/parse/ef.py +81 -0
- jpomop/parse/encoding.py +28 -0
- jpomop/parse/fields.py +78 -0
- jpomop/parse/result.py +37 -0
- jpomop/parse/yoshiki1.py +291 -0
- jpomop/report/__init__.py +62 -0
- jpomop/spec/__init__.py +100 -0
- jpomop/spec/codes/ef_data_kubun.yaml +25 -0
- jpomop/spec/codes/ef_in_out.yaml +2 -0
- jpomop/spec/codes/ef_yen_point.yaml +2 -0
- jpomop/spec/codes/y1_admission_route.yaml +8 -0
- jpomop/spec/codes/y1_death_24h.yaml +5 -0
- jpomop/spec/codes/y1_discharge_outcome.yaml +10 -0
- jpomop/spec/codes/y1_discharge_to.yaml +14 -0
- jpomop/spec/codes/y1_sex.yaml +2 -0
- jpomop/spec/dpc_ef.yaml +50 -0
- jpomop/spec/dpc_yoshiki1.yaml +93 -0
- jpomop/synth/__init__.py +210 -0
- jpomop/vocab/__init__.py +184 -0
- jpomop-0.0.1.dist-info/METADATA +121 -0
- jpomop-0.0.1.dist-info/RECORD +35 -0
- jpomop-0.0.1.dist-info/WHEEL +4 -0
- jpomop-0.0.1.dist-info/entry_points.txt +2 -0
- jpomop-0.0.1.dist-info/licenses/LICENSE +202 -0
- jpomop-0.0.1.dist-info/licenses/LICENSE-DATA +6 -0
jpomop/__init__.py
ADDED
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
|