gem-mapping-studio 0.2.2__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.
- forome/gem/_reference/__init__.py +12 -0
- forome/gem/_reference/dimensions.md +388 -0
- forome/gem/_reference/genetic_evidence.shacl.ttl +387 -0
- forome/gem/_reference/semantic_types.yaml +949 -0
- forome/gem/extraction/__init__.py +0 -0
- forome/gem/extraction/extract_annotations.py +106 -0
- forome/gem/extraction/extract_annotations_pypdf.py +140 -0
- forome/gem/extraction/yaml_to_rdf.py +191 -0
- forome/gem/umls/__init__.py +0 -0
- forome/gem/umls/_paths.py +58 -0
- forome/gem/umls/adjudicate_ui.py +2253 -0
- forome/gem/umls/build_umls_crosswalk.py +384 -0
- forome/gem/umls/classify_credibility_sweep.py +112 -0
- forome/gem/umls/fetch_semantic_network.py +133 -0
- forome/gem/umls/local_umls.py +806 -0
- forome/gem/umls/render_crosswalk_tex.py +312 -0
- forome/gem/umls/semantic_types.py +130 -0
- forome/gem/umls/sweep.py +576 -0
- forome/gem/umls/uts_client.py +415 -0
- forome/gem/validation/__init__.py +0 -0
- forome/gem/validation/compute_coverage.py +149 -0
- forome/gem/validation/validate_annotations.py +123 -0
- gem_mapping_studio-0.2.2.dist-info/METADATA +465 -0
- gem_mapping_studio-0.2.2.dist-info/RECORD +29 -0
- gem_mapping_studio-0.2.2.dist-info/WHEEL +5 -0
- gem_mapping_studio-0.2.2.dist-info/entry_points.txt +11 -0
- gem_mapping_studio-0.2.2.dist-info/licenses/LICENSE-code.txt +202 -0
- gem_mapping_studio-0.2.2.dist-info/licenses/LICENSE.txt +395 -0
- gem_mapping_studio-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render the UMLS + OMOP/CDM crosswalk as a LaTeX supplement section.
|
|
3
|
+
|
|
4
|
+
Reads the harness output (``data/umls/umls_crosswalk.yaml``), overlays the
|
|
5
|
+
curator's decisions (``data/umls/adjudications.yaml``: the typed *relation*
|
|
6
|
+
of every accepted concept and the structured rationale of every argued
|
|
7
|
+
gap) and the hand-authored OMOP examples (``data/umls/omop_cdm_examples.yaml``),
|
|
8
|
+
and writes a self-contained ``.tex`` section: an honest UMLS concept table
|
|
9
|
+
(grouped by dimension; mapped / review / unmapped status surfaced; one
|
|
10
|
+
relation column) plus an illustrative OMOP/CDM positioning table.
|
|
11
|
+
|
|
12
|
+
This produces its own supplement section (``paper/sections/s18_umls_crosswalk.tex``,
|
|
13
|
+
``\\input`` by ``supplement.tex``); it does not modify ``s15_crosswalk.tex``
|
|
14
|
+
(the SO/HPO/NCBITaxon/ECO/SEPIO/GA4GH-VA/FHIR crosswalk). The generated file
|
|
15
|
+
uses ``longtable`` and ``booktabs``.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
gem-umls-render # regenerate the supplement section
|
|
19
|
+
gem-umls-render --crosswalk data/umls/umls_crosswalk.demo.yaml \\
|
|
20
|
+
--out data/umls/s18_umls_crosswalk.preview.tex
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
from forome.gem.umls import build_umls_crosswalk as H
|
|
30
|
+
from forome.gem.umls._paths import DATA_DIR, PAPER_SECTIONS
|
|
31
|
+
|
|
32
|
+
DEFAULT_CROSSWALK = DATA_DIR / "umls_crosswalk.yaml"
|
|
33
|
+
DEFAULT_OMOP = DATA_DIR / "omop_cdm_examples.yaml"
|
|
34
|
+
DEFAULT_OUT = PAPER_SECTIONS / "s18_umls_crosswalk.tex"
|
|
35
|
+
|
|
36
|
+
_TEX = {"\\": r"\textbackslash{}", "&": r"\&", "%": r"\%", "$": r"\$",
|
|
37
|
+
"#": r"\#", "_": r"\_", "{": r"\{", "}": r"\}",
|
|
38
|
+
"~": r"\textasciitilde{}", "^": r"\textasciicircum{}"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tex(s) -> str:
|
|
42
|
+
if s is None:
|
|
43
|
+
return ""
|
|
44
|
+
return "".join(_TEX.get(c, c) for c in str(s))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _adj_key(e: dict) -> str:
|
|
48
|
+
return H._adj_key(e["dimension"], None if e.get("kind") == "axis" else e.get("token"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _argued(a: dict) -> bool:
|
|
52
|
+
"""An unmapped verdict counts as *argued* when it carries a rationale:
|
|
53
|
+
rejected near-misses and/or the search protocol that failed."""
|
|
54
|
+
return bool(a.get("rejected") or a.get("protocol"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _relation_cell(e: dict, a: dict) -> str:
|
|
58
|
+
if e.get("kind") == "axis":
|
|
59
|
+
return "---"
|
|
60
|
+
rel = a.get("relation")
|
|
61
|
+
if e["status"] == "unmapped":
|
|
62
|
+
return r"\emph{none}" if _argued(a) else "---"
|
|
63
|
+
if rel:
|
|
64
|
+
return tex(rel)
|
|
65
|
+
return r"\emph{auto}" # harness match not yet confirmed by a curator
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _concept_cell(e: dict, a: dict) -> str:
|
|
69
|
+
st = e["status"]
|
|
70
|
+
if e.get("sty_tui"): # axis row -> a UMLS semantic TYPE, not a concept
|
|
71
|
+
return (rf"{tex(e.get('sty_name'))} "
|
|
72
|
+
rf"\emph{{(semantic type, tree {tex(e.get('sty_tree'))})}}")
|
|
73
|
+
if e.get("kind") == "axis":
|
|
74
|
+
return r"\emph{no semantic type (boolean / free-text dimension)}"
|
|
75
|
+
if st == "pending":
|
|
76
|
+
return r"\emph{pending --- run harness with a UMLS key}"
|
|
77
|
+
if st == "unmapped":
|
|
78
|
+
if _argued(a):
|
|
79
|
+
n = len(a.get("rejected") or [])
|
|
80
|
+
detail = (rf"{n} rejected near-miss{'es' if n != 1 else ''}"
|
|
81
|
+
if n else "search protocol recorded")
|
|
82
|
+
return rf"\emph{{no faithful concept; argued gap ({detail})}}"
|
|
83
|
+
return r"\emph{unresolved; no faithful concept found}"
|
|
84
|
+
name = tex(e.get("matched_name"))
|
|
85
|
+
sty = "; ".join(tex(x) for x in (e.get("semantic_types") or []))
|
|
86
|
+
src = tex(e.get("root_source"))
|
|
87
|
+
detail = "; ".join(x for x in (sty, src) if x)
|
|
88
|
+
cell = name
|
|
89
|
+
if detail:
|
|
90
|
+
cell += rf" \emph{{({detail})}}"
|
|
91
|
+
if st == "review":
|
|
92
|
+
cell = r"\textsuperscript{\dag}" + cell
|
|
93
|
+
return cell
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _coverage(doc: dict, adj: dict) -> str:
|
|
97
|
+
"""Honest coverage sentence computed from the rows, not hard-coded."""
|
|
98
|
+
entries = doc["entries"]
|
|
99
|
+
axes = [e for e in entries if e.get("kind") == "axis"]
|
|
100
|
+
values = [e for e in entries if e.get("kind") != "axis"]
|
|
101
|
+
typed_axes = [e for e in axes if e.get("sty_tui")]
|
|
102
|
+
untyped_axes = [e for e in axes if not e.get("sty_tui")]
|
|
103
|
+
mapped = [e for e in values if e["status"] == "mapped"]
|
|
104
|
+
review = [e for e in values if e["status"] == "review"]
|
|
105
|
+
unmapped = [e for e in values if e["status"] == "unmapped"]
|
|
106
|
+
argued = [e for e in unmapped if _argued(adj.get(_adj_key(e), {}))]
|
|
107
|
+
unresolved = [e for e in unmapped if e not in argued]
|
|
108
|
+
with_rel = [e for e in mapped if adj.get(_adj_key(e), {}).get("relation")]
|
|
109
|
+
|
|
110
|
+
by_dim: dict[str, list[str]] = {}
|
|
111
|
+
for e in argued:
|
|
112
|
+
by_dim.setdefault(e["dimension"], []).append(e["token"])
|
|
113
|
+
gaps = "; ".join(rf"\emph{{{tex(d)}}}: {', '.join(tex(t) for t in ts)}"
|
|
114
|
+
for d, ts in by_dim.items())
|
|
115
|
+
|
|
116
|
+
s1 = (rf"\noindent\textbf{{Coverage.}} The model has {len(axes)} "
|
|
117
|
+
rf"dimension axes; {len(typed_axes)} are bound to a UMLS Semantic "
|
|
118
|
+
rf"Type")
|
|
119
|
+
if untyped_axes:
|
|
120
|
+
s1 += (rf", and {len(untyped_axes)} boolean or free-text dimensions "
|
|
121
|
+
rf"take no type by design")
|
|
122
|
+
s1 += "."
|
|
123
|
+
s2 = (rf" Of the {len(values)} enumerated values, {len(mapped)} carry an "
|
|
124
|
+
rf"accepted mapping")
|
|
125
|
+
if len(with_rel) != len(mapped):
|
|
126
|
+
s2 += rf" ({len(with_rel)} with a curated relation)"
|
|
127
|
+
if review:
|
|
128
|
+
s2 += rf", {len(review)} remain flagged for review"
|
|
129
|
+
if unmapped:
|
|
130
|
+
if argued and not unresolved:
|
|
131
|
+
s2 += (rf" and {len(unmapped)} are argued gaps with a structured "
|
|
132
|
+
rf"rationale ({gaps})")
|
|
133
|
+
else:
|
|
134
|
+
s2 += rf" and {len(unmapped)} are without a faithful UMLS concept"
|
|
135
|
+
if argued:
|
|
136
|
+
s2 += (rf": {len(argued)} argued gaps with a structured "
|
|
137
|
+
rf"rationale ({gaps})")
|
|
138
|
+
if unresolved:
|
|
139
|
+
s2 += rf"; {len(unresolved)} unresolved"
|
|
140
|
+
return s1 + s2 + "."
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def render_umls(doc: dict, adj: dict) -> list[str]:
|
|
144
|
+
out = [
|
|
145
|
+
r"\subsection{UMLS concept crosswalk (generated)}",
|
|
146
|
+
r"\label{sec:s-umls-crosswalk}",
|
|
147
|
+
"",
|
|
148
|
+
# Purpose and provenance.
|
|
149
|
+
r"This crosswalk binds Genetic Evidence Model dimensions to "
|
|
150
|
+
r"Unified Medical Language System (UMLS) Semantic Types and maps "
|
|
151
|
+
r"enumerated values to UMLS concepts where an adequate mapping "
|
|
152
|
+
r"is available. It is produced by the mapping harness "
|
|
153
|
+
r"(\texttt{gem-umls-crosswalk}), which resolves each term against "
|
|
154
|
+
r"the UMLS Metathesaurus through the UTS REST API or against a "
|
|
155
|
+
r"locally indexed licensed copy, and by curator adjudication in the "
|
|
156
|
+
r"accompanying curation studio (\texttt{gem-mapping-studio}). The "
|
|
157
|
+
r"machine-readable sources are \texttt{data/umls/umls\_crosswalk.yaml} "
|
|
158
|
+
r"(the harness output) and \texttt{data/umls/adjudications.yaml} "
|
|
159
|
+
r"(the curator's decisions). The narrative behind the decisions is "
|
|
160
|
+
r"kept in the repository's \texttt{data/umls/DECISIONS.md}.",
|
|
161
|
+
"",
|
|
162
|
+
# Relation to the standards crosswalk.
|
|
163
|
+
r"This UMLS crosswalk is distinct from the structural standards "
|
|
164
|
+
r"crosswalk in \Cref{sec:supp-crosswalk} and does not replace it.",
|
|
165
|
+
"",
|
|
166
|
+
# Identifier policy.
|
|
167
|
+
r"A concept is reported as mapped only when the harness retrieved it "
|
|
168
|
+
r"and it was confirmed against UMLS. No concept identifier is "
|
|
169
|
+
r"asserted unless it was returned by a query and confirmed "
|
|
170
|
+
r"against UMLS.",
|
|
171
|
+
"",
|
|
172
|
+
# Mapping structure.
|
|
173
|
+
r"Each dimension \emph{axis} is bound to a UMLS Semantic Type. The "
|
|
174
|
+
r"type scopes the search for that dimension's values; a curator may "
|
|
175
|
+
r"deliberately accept a concept outside the axis and records why. "
|
|
176
|
+
r"Each dimension \emph{value} is mapped to a Metathesaurus concept. "
|
|
177
|
+
r"The \emph{relation} column records how the accepted concept "
|
|
178
|
+
r"relates to the GEM token: \emph{exact}, \emph{close}, "
|
|
179
|
+
r"\emph{narrower} (the GEM token is more specific than the concept), "
|
|
180
|
+
r"\emph{broader} (the GEM token is more general), or \emph{related} "
|
|
181
|
+
r"(a constituent or operator concept anchors a composite token).",
|
|
182
|
+
"",
|
|
183
|
+
# Argued gaps.
|
|
184
|
+
r"An unmapped value is an adjudicated conclusion, not an absence. "
|
|
185
|
+
r"Relation \emph{none} means that no faithful UMLS mapping was "
|
|
186
|
+
r"accepted. The verdict is supported by rejected near-miss "
|
|
187
|
+
r"concepts, each assigned the adequacy criterion it failed, and by "
|
|
188
|
+
r"the documented search protocol that found no proxy. Where a "
|
|
189
|
+
r"concept identifier appears in connection with an argued gap, it "
|
|
190
|
+
r"identifies a retrieved or rejected near miss, not an accepted "
|
|
191
|
+
r"mapping. The credibility tiers are argued in full in "
|
|
192
|
+
r"\Cref{sec:supp-credibility}.",
|
|
193
|
+
"",
|
|
194
|
+
_coverage(doc, adj),
|
|
195
|
+
"",
|
|
196
|
+
]
|
|
197
|
+
if not doc["meta"].get("live", False):
|
|
198
|
+
out += [r"\noindent\emph{This table was generated in scaffold mode "
|
|
199
|
+
r"(no UMLS key); concept columns are pending until the harness "
|
|
200
|
+
r"is run against UMLS.}", ""]
|
|
201
|
+
|
|
202
|
+
header = r"GEM value & CUI / TUI & Relation & UMLS concept or semantic type \\"
|
|
203
|
+
out += [
|
|
204
|
+
r"{\footnotesize",
|
|
205
|
+
r"\begin{longtable}{@{}l l l p{7.2cm}@{}}",
|
|
206
|
+
r"\caption{GEM dimensional vocabulary mapped to UMLS concepts "
|
|
207
|
+
r"(generated by the mapping harness and curator adjudication).}\\",
|
|
208
|
+
r"\toprule", header, r"\midrule", r"\endfirsthead",
|
|
209
|
+
r"\toprule", header, r"\midrule", r"\endhead",
|
|
210
|
+
]
|
|
211
|
+
# group by dimension, preserving inventory order
|
|
212
|
+
cur = None
|
|
213
|
+
for e in doc["entries"]:
|
|
214
|
+
if e["dimension"] != cur:
|
|
215
|
+
cur = e["dimension"]
|
|
216
|
+
out.append(rf"\midrule \multicolumn{{4}}{{@{{}}l}}"
|
|
217
|
+
rf"{{\textbf{{{tex(cur)}}}}} \\")
|
|
218
|
+
a = adj.get(_adj_key(e), {}) or {}
|
|
219
|
+
label = "(axis)" if e["kind"] == "axis" else tex(e["token"])
|
|
220
|
+
ident = tex(e.get("sty_tui")) if e.get("sty_tui") else (
|
|
221
|
+
tex(e.get("cui")) if e.get("cui") else "---")
|
|
222
|
+
out.append(rf"\quad {label} & {ident} & {_relation_cell(e, a)} & "
|
|
223
|
+
rf"{_concept_cell(e, a)} \\")
|
|
224
|
+
out += [r"\bottomrule", r"\end{longtable}", r"}", ""]
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def render_omop(omop: dict) -> list[str]:
|
|
229
|
+
out = [
|
|
230
|
+
r"\subsection{OMOP CDM positioning (illustrative)}",
|
|
231
|
+
r"\label{sec:s-omop-positioning}",
|
|
232
|
+
"",
|
|
233
|
+
r"The following worked examples position GEM dimensions relative to "
|
|
234
|
+
r"the OHDSI OMOP Common Data Model (CDM) and its standardized "
|
|
235
|
+
r"vocabularies. This is an illustrative, hand-authored positioning "
|
|
236
|
+
r"exercise, not a full crosswalk. " + tex(omop["meta"]["note"]),
|
|
237
|
+
"",
|
|
238
|
+
r"{\footnotesize",
|
|
239
|
+
r"\begin{longtable}{@{}p{2.9cm} p{2.6cm} p{2.1cm} p{6.0cm}@{}}",
|
|
240
|
+
r"\caption{Illustrative positioning of GEM dimensions against the "
|
|
241
|
+
r"OMOP CDM (hand-authored, not exhaustive).}\\",
|
|
242
|
+
r"\toprule",
|
|
243
|
+
r"GEM dimension & OMOP domain / table & Standard vocab. & "
|
|
244
|
+
r"Alignment and gap \\",
|
|
245
|
+
r"\midrule",
|
|
246
|
+
r"\endfirsthead",
|
|
247
|
+
r"\toprule",
|
|
248
|
+
r"GEM dimension & OMOP domain / table & Standard vocab. & "
|
|
249
|
+
r"Alignment and gap \\",
|
|
250
|
+
r"\midrule",
|
|
251
|
+
r"\endhead",
|
|
252
|
+
]
|
|
253
|
+
# Marker convention for hand-authored YAML text: [[supp-crosswalk]]
|
|
254
|
+
# becomes a \Cref to the standards crosswalk note (raw LaTeX cannot pass
|
|
255
|
+
# through the escaper).
|
|
256
|
+
refs = {"[[supp-crosswalk]]": r"\Cref{sec:supp-crosswalk}"}
|
|
257
|
+
for ex in omop["examples"]:
|
|
258
|
+
domain = tex(ex["omop_domain"]) + r"\newline " + tex(ex["omop_table"])
|
|
259
|
+
cell = tex(ex["alignment"]) + r" \emph{Gap:} " + tex(ex["gap"])
|
|
260
|
+
for k, v in refs.items():
|
|
261
|
+
cell = cell.replace(k, v)
|
|
262
|
+
out.append(rf"{tex(ex['gem_dimension'])} & {domain} & "
|
|
263
|
+
rf"{tex(ex['standard_vocabulary'])} & {cell} \\")
|
|
264
|
+
out.append(r"\addlinespace")
|
|
265
|
+
out += [r"\bottomrule", r"\end{longtable}", r"}", ""]
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def render(crosswalk_path: Path, omop_path: Path,
|
|
270
|
+
adjudications_path: Path | None = None) -> str:
|
|
271
|
+
"""Render the section. ``adjudications_path`` defaults to the workspace's
|
|
272
|
+
current adjudications file (``build_umls_crosswalk.ADJUDICATIONS``); a
|
|
273
|
+
missing file simply yields no relation/rationale overlay."""
|
|
274
|
+
doc = yaml.safe_load(crosswalk_path.read_text())
|
|
275
|
+
omop = yaml.safe_load(omop_path.read_text())
|
|
276
|
+
adj = H.load_adjudications(adjudications_path)
|
|
277
|
+
lines = [
|
|
278
|
+
r"% =====================================================================",
|
|
279
|
+
r"% GENERATED by gem-umls-render (forome.gem.umls.render_crosswalk_tex)",
|
|
280
|
+
r"% -- do not edit by hand. Sources: data/umls/umls_crosswalk.yaml,",
|
|
281
|
+
r"% data/umls/adjudications.yaml, data/umls/omop_cdm_examples.yaml.",
|
|
282
|
+
r"% Requires: \usepackage{longtable} and \usepackage{booktabs}.",
|
|
283
|
+
r"% Its own supplement section; it does not touch s15_crosswalk.tex.",
|
|
284
|
+
r"% =====================================================================",
|
|
285
|
+
r"\section{UMLS crosswalk and illustrative OMOP CDM positioning}",
|
|
286
|
+
r"\label{sec:s-umls-omop}",
|
|
287
|
+
"",
|
|
288
|
+
]
|
|
289
|
+
lines += render_umls(doc, adj)
|
|
290
|
+
lines += render_omop(omop)
|
|
291
|
+
return "\n".join(lines) + "\n"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main(argv=None) -> int:
|
|
295
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
296
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
297
|
+
ap.add_argument("--crosswalk", default=str(DEFAULT_CROSSWALK))
|
|
298
|
+
ap.add_argument("--omop", default=str(DEFAULT_OMOP))
|
|
299
|
+
ap.add_argument("--adjudications", default=None,
|
|
300
|
+
help="curator decisions YAML (default: the workspace's "
|
|
301
|
+
"data/umls/adjudications.yaml)")
|
|
302
|
+
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
|
303
|
+
args = ap.parse_args(argv)
|
|
304
|
+
out = render(Path(args.crosswalk), Path(args.omop),
|
|
305
|
+
Path(args.adjudications) if args.adjudications else None)
|
|
306
|
+
Path(args.out).write_text(out)
|
|
307
|
+
print(f"Wrote {args.out} ({len(out)} chars)")
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Load the UMLS Semantic Network reference (semantic_types.yaml, produced by
|
|
3
|
+
fetch_semantic_network.py) and provide subtree + lookup helpers used by the
|
|
4
|
+
crosswalk harness to constrain value searches by a dimension's axis type.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from forome.gem.umls._paths import DATA_DIR, REFERENCE_DIR
|
|
13
|
+
|
|
14
|
+
_BY_TUI: dict | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _types_yaml() -> Path:
|
|
18
|
+
"""The Semantic Network reference: prefer the workspace's copy, else fall
|
|
19
|
+
back to the repo's canonical one so type exploration works with an empty
|
|
20
|
+
workspace."""
|
|
21
|
+
local = DATA_DIR / "semantic_types.yaml"
|
|
22
|
+
return local if local.is_file() else REFERENCE_DIR / "semantic_types.yaml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load() -> dict:
|
|
26
|
+
global _BY_TUI
|
|
27
|
+
if _BY_TUI is None:
|
|
28
|
+
yml = _types_yaml()
|
|
29
|
+
if yml.is_file():
|
|
30
|
+
doc = yaml.safe_load(yml.read_text()) or {}
|
|
31
|
+
_BY_TUI = {t["tui"]: t for t in (doc.get("types") or [])}
|
|
32
|
+
else:
|
|
33
|
+
_BY_TUI = {}
|
|
34
|
+
return _BY_TUI
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get(tui: str) -> dict | None:
|
|
38
|
+
return _load().get(tui)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def subtree_tuis(tui: str) -> set[str]:
|
|
42
|
+
"""The TUI plus all its descendants, by semantic-network tree-number prefix.
|
|
43
|
+
|
|
44
|
+
So a dimension whose axis is T082 (Spatial Concept, tree A2.1.5) constrains
|
|
45
|
+
its value search to A2.1.5 and everything under it (Molecular Sequence,
|
|
46
|
+
Nucleotide Sequence, ...), which is exactly the set a value of that axis may
|
|
47
|
+
faithfully map to.
|
|
48
|
+
"""
|
|
49
|
+
types = _load()
|
|
50
|
+
root = types.get(tui)
|
|
51
|
+
if not root or not root.get("tree_number"):
|
|
52
|
+
return {tui} if tui else set()
|
|
53
|
+
prefix = root["tree_number"]
|
|
54
|
+
|
|
55
|
+
def under(tree: str) -> bool:
|
|
56
|
+
# descendant iff equal, or extends the prefix at a level boundary.
|
|
57
|
+
# Levels below a single-letter root (A -> A1 -> A1.1) have no dot,
|
|
58
|
+
# so "A1" is a child of "A" even though "A." is not its prefix.
|
|
59
|
+
if not tree:
|
|
60
|
+
return False
|
|
61
|
+
if tree == prefix:
|
|
62
|
+
return True
|
|
63
|
+
return tree.startswith(prefix) and (len(prefix) == 1
|
|
64
|
+
or tree[len(prefix)] == ".")
|
|
65
|
+
|
|
66
|
+
return {t["tui"] for t in types.values() if under(t.get("tree_number") or "")}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def filter_param(tui: str | None) -> str | None:
|
|
70
|
+
"""Comma-joined TUI list for the UTS `semanticTypes` search parameter."""
|
|
71
|
+
if not tui:
|
|
72
|
+
return None
|
|
73
|
+
tuis = subtree_tuis(tui)
|
|
74
|
+
return ",".join(sorted(tuis)) if tuis else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _by_tree() -> dict:
|
|
78
|
+
return {t["tree_number"]: t for t in _load().values() if t.get("tree_number")}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _row(t: dict) -> dict:
|
|
82
|
+
return {"tui": t["tui"], "name": t["name"], "tree": t.get("tree_number")}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def most_specific(tuis: list[str]) -> str | None:
|
|
86
|
+
"""The deepest (most specific) of several semantic types, by tree depth."""
|
|
87
|
+
cand = [_load().get(t) for t in tuis if _load().get(t)]
|
|
88
|
+
if not cand:
|
|
89
|
+
return None
|
|
90
|
+
return max(cand, key=lambda x: len((x.get("tree_number") or "").split(".")))["tui"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def path_to(tui: str, axis_tui: str | None) -> list[dict]:
|
|
94
|
+
"""Path (deepest-first) from a semantic type up to ``axis_tui`` if it is a
|
|
95
|
+
descendant, ending at the axis type; otherwise the walk continues to the
|
|
96
|
+
branch root. Each element is {tui, name, tree}. Empty if tui is unknown."""
|
|
97
|
+
types = _load()
|
|
98
|
+
t = types.get(tui)
|
|
99
|
+
if not t or not t.get("tree_number"):
|
|
100
|
+
return [_row(t)] if t else []
|
|
101
|
+
by_tree = _by_tree()
|
|
102
|
+
axis = types.get(axis_tui) if axis_tui else None
|
|
103
|
+
axis_tree = axis.get("tree_number") if axis else None
|
|
104
|
+
path, cur = [], t["tree_number"]
|
|
105
|
+
while True:
|
|
106
|
+
node = by_tree.get(cur)
|
|
107
|
+
if node:
|
|
108
|
+
path.append(_row(node))
|
|
109
|
+
if axis_tree and cur == axis_tree:
|
|
110
|
+
break
|
|
111
|
+
if "." in cur:
|
|
112
|
+
cur = cur.rsplit(".", 1)[0]
|
|
113
|
+
elif len(cur) > 1:
|
|
114
|
+
cur = cur[0] # A1 -> A, B2 -> B: the single-letter root
|
|
115
|
+
else:
|
|
116
|
+
break
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def all_types() -> list:
|
|
121
|
+
"""All semantic TYPES (A/B branches), tree-ordered, for the UI picker."""
|
|
122
|
+
def key(t):
|
|
123
|
+
import re
|
|
124
|
+
out = []
|
|
125
|
+
for p in (t.get("tree_number") or "").split("."):
|
|
126
|
+
m = re.match(r"([A-Za-z]*)(\d+)", p)
|
|
127
|
+
out.append((m.group(1), int(m.group(2))) if m else ("", 0))
|
|
128
|
+
return out
|
|
129
|
+
return sorted((t for t in _load().values()
|
|
130
|
+
if (t.get("tree_number") or "").startswith(("A", "B"))), key=key)
|