studio2ga4gh 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Tim Hearn 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: studio2ga4gh
3
+ Version: 0.1.0
4
+ Summary: Convert Pedigree Studio sessions to GA4GH Pedigree Standard and Phenopackets
5
+ Author-email: Tim Hearn <tjh70@cam.ac.uk>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/comparativechrono/studio2ga4gh
8
+ Project-URL: Issues, https://github.com/comparativechrono/studio2ga4gh/issues
9
+ Keywords: pedigree,ga4gh,phenopackets,genetics,clinical-genetics,interoperability
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
14
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # studio2ga4gh
23
+
24
+ Convert Pedigree Studio session JSON files to GA4GH interoperability formats:
25
+
26
+ - **GA4GH Pedigree Standard** — relationship-graph model using the Kinship Ontology (KIN)
27
+ - **Phenopackets Family** — PED-compatible pedigree with disease information
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install studio2ga4gh
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### Command line
38
+
39
+ ```bash
40
+ studio2ga4gh session.json
41
+ studio2ga4gh session.json -o my_family -d ./output/
42
+ ```
43
+
44
+ This produces two files per pedigree:
45
+ - `{name}_ga4gh.json` — GA4GH Pedigree (relationship graph with KIN ontology codes)
46
+ - `{name}_phenopackets.json` — Phenopackets Family (PED-compatible pedigree + diseases)
47
+
48
+ ### Python API
49
+
50
+ ```python
51
+ import studio2ga4gh, json
52
+
53
+ with open("session.json") as f:
54
+ session = json.load(f)
55
+
56
+ result = studio2ga4gh.convert(session)
57
+ print(result.summary)
58
+ for filename, content in result.files:
59
+ print(f"--- {filename} ---")
60
+ print(content[:200])
61
+ ```
62
+
63
+ ## What Each Output Contains
64
+
65
+ ### GA4GH Pedigree (`_ga4gh.json`)
66
+
67
+ The rich graph-based format from the GA4GH Pedigree Standard. It represents each parent-child and twin relationship as an explicit edge with a KIN ontology code:
68
+
69
+ | Relationship | KIN Code | Label |
70
+ |-------------|----------|-------|
71
+ | Biological mother | KIN:027 | isBiologicalMotherOf |
72
+ | Biological father | KIN:028 | isBiologicalFatherOf |
73
+ | Adoptive parent | KIN:022 | isAdoptiveParentOf |
74
+ | Identical twin | KIN:010 | isMonozygoticMultipleBirthSiblingOf |
75
+ | Non-identical twin | KIN:011 | isDizygoticMultipleBirthSiblingOf |
76
+
77
+ Dashed child lines in Pedigree Studio (adoption) are exported as `isAdoptiveParentOf` rather than biological parent relationships.
78
+
79
+ ### Phenopackets Family (`_phenopackets.json`)
80
+
81
+ The GA4GH Phenopackets schema `Family` element, containing:
82
+ - A PED-compatible `pedigree` with `Person` records (familyId, individualId, parents, sex, affectedStatus)
83
+ - A `proband` Phenopacket with disease terms extracted from the legend
84
+ - `relatives` Phenopackets for affected family members with their diseases
85
+ - `consanguinousParents` flag if any consanguineous partnerships exist
86
+
87
+ ## Phenotype Mapping
88
+
89
+ Disease/condition names are taken from the Pedigree Studio legend labels. The converter maps visual markers to affected status using the same logic as studio2ped.
90
+
91
+ ## Multiple Pedigrees
92
+
93
+ If the session contains disconnected pedigrees, numbered file pairs are produced:
94
+ ```
95
+ pedigree_1_ga4gh.json
96
+ pedigree_1_phenopackets.json
97
+ pedigree_2_ga4gh.json
98
+ pedigree_2_phenopackets.json
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,82 @@
1
+ # studio2ga4gh
2
+
3
+ Convert Pedigree Studio session JSON files to GA4GH interoperability formats:
4
+
5
+ - **GA4GH Pedigree Standard** — relationship-graph model using the Kinship Ontology (KIN)
6
+ - **Phenopackets Family** — PED-compatible pedigree with disease information
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install studio2ga4gh
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ### Command line
17
+
18
+ ```bash
19
+ studio2ga4gh session.json
20
+ studio2ga4gh session.json -o my_family -d ./output/
21
+ ```
22
+
23
+ This produces two files per pedigree:
24
+ - `{name}_ga4gh.json` — GA4GH Pedigree (relationship graph with KIN ontology codes)
25
+ - `{name}_phenopackets.json` — Phenopackets Family (PED-compatible pedigree + diseases)
26
+
27
+ ### Python API
28
+
29
+ ```python
30
+ import studio2ga4gh, json
31
+
32
+ with open("session.json") as f:
33
+ session = json.load(f)
34
+
35
+ result = studio2ga4gh.convert(session)
36
+ print(result.summary)
37
+ for filename, content in result.files:
38
+ print(f"--- {filename} ---")
39
+ print(content[:200])
40
+ ```
41
+
42
+ ## What Each Output Contains
43
+
44
+ ### GA4GH Pedigree (`_ga4gh.json`)
45
+
46
+ The rich graph-based format from the GA4GH Pedigree Standard. It represents each parent-child and twin relationship as an explicit edge with a KIN ontology code:
47
+
48
+ | Relationship | KIN Code | Label |
49
+ |-------------|----------|-------|
50
+ | Biological mother | KIN:027 | isBiologicalMotherOf |
51
+ | Biological father | KIN:028 | isBiologicalFatherOf |
52
+ | Adoptive parent | KIN:022 | isAdoptiveParentOf |
53
+ | Identical twin | KIN:010 | isMonozygoticMultipleBirthSiblingOf |
54
+ | Non-identical twin | KIN:011 | isDizygoticMultipleBirthSiblingOf |
55
+
56
+ Dashed child lines in Pedigree Studio (adoption) are exported as `isAdoptiveParentOf` rather than biological parent relationships.
57
+
58
+ ### Phenopackets Family (`_phenopackets.json`)
59
+
60
+ The GA4GH Phenopackets schema `Family` element, containing:
61
+ - A PED-compatible `pedigree` with `Person` records (familyId, individualId, parents, sex, affectedStatus)
62
+ - A `proband` Phenopacket with disease terms extracted from the legend
63
+ - `relatives` Phenopackets for affected family members with their diseases
64
+ - `consanguinousParents` flag if any consanguineous partnerships exist
65
+
66
+ ## Phenotype Mapping
67
+
68
+ Disease/condition names are taken from the Pedigree Studio legend labels. The converter maps visual markers to affected status using the same logic as studio2ped.
69
+
70
+ ## Multiple Pedigrees
71
+
72
+ If the session contains disconnected pedigrees, numbered file pairs are produced:
73
+ ```
74
+ pedigree_1_ga4gh.json
75
+ pedigree_1_phenopackets.json
76
+ pedigree_2_ga4gh.json
77
+ pedigree_2_phenopackets.json
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "studio2ga4gh"
7
+ version = "0.1.0"
8
+ description = "Convert Pedigree Studio sessions to GA4GH Pedigree Standard and Phenopackets"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Science/Research",
15
+ "Intended Audience :: Healthcare Industry",
16
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
17
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ ]
21
+ keywords = ["pedigree", "ga4gh", "phenopackets", "genetics", "clinical-genetics", "interoperability"]
22
+ authors = [
23
+ {name = "Tim Hearn", email = "tjh70@cam.ac.uk"},
24
+ ]
25
+
26
+ [project.scripts]
27
+ studio2ga4gh = "studio2ga4gh.cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/comparativechrono/studio2ga4gh"
31
+ Issues = "https://github.com/comparativechrono/studio2ga4gh/issues"
32
+
33
+ [tool.setuptools.packages.find]
34
+ include = ["studio2ga4gh*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """studio2ga4gh — Convert Pedigree Studio sessions to GA4GH Pedigree and Phenopackets."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .converter import convert, convert_file, parse_session, ConversionResult
6
+
7
+ __all__ = ["convert", "convert_file", "parse_session", "ConversionResult"]
@@ -0,0 +1,4 @@
1
+ """Allow running as `python -m studio2ga4gh`."""
2
+ import sys
3
+ from .cli import main
4
+ sys.exit(main())
@@ -0,0 +1,27 @@
1
+ """Command-line interface for studio2ga4gh."""
2
+ from __future__ import annotations
3
+ import argparse, sys
4
+ from .converter import convert_file
5
+
6
+ def main(argv=None):
7
+ parser = argparse.ArgumentParser(prog="studio2ga4gh",
8
+ description="Convert Pedigree Studio session JSON to GA4GH Pedigree and Phenopackets.")
9
+ parser.add_argument("input", help="Path to the Pedigree Studio session .json file")
10
+ parser.add_argument("-o", "--output-prefix", default=None, dest="output_prefix",
11
+ help="Base name for output files (default: input filename stem)")
12
+ parser.add_argument("-d", "--output-dir", default=None, dest="output_dir",
13
+ help="Directory for output files (default: same as input)")
14
+ args = parser.parse_args(argv)
15
+ try:
16
+ result = convert_file(args.input, output_prefix=args.output_prefix, output_dir=args.output_dir)
17
+ print(result.summary)
18
+ for filepath, _ in result.files:
19
+ print(f" Written: {filepath}")
20
+ return 0
21
+ except FileNotFoundError:
22
+ print(f"Error: file not found: {args.input}", file=sys.stderr); return 1
23
+ except Exception as e:
24
+ print(f"Error: {e}", file=sys.stderr); return 1
25
+
26
+ if __name__ == "__main__":
27
+ sys.exit(main())
@@ -0,0 +1,622 @@
1
+ """
2
+ studio2ga4gh - Convert Pedigree Studio sessions to GA4GH Pedigree and Phenopackets.
3
+
4
+ Produces two interoperability formats:
5
+ 1. GA4GH Pedigree (relationship-graph model with KIN ontology codes)
6
+ 2. Phenopackets Family (PED-compatible pedigree + disease information)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass, field
16
+ from datetime import date
17
+ from typing import Optional
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ # ── KIN Ontology codes ───────────────────────────────────────────────────
22
+ KIN_BIOLOGICAL_MOTHER_OF = {"id": "KIN:027", "label": "isBiologicalMotherOf"}
23
+ KIN_BIOLOGICAL_FATHER_OF = {"id": "KIN:028", "label": "isBiologicalFatherOf"}
24
+ KIN_ADOPTIVE_PARENT_OF = {"id": "KIN:022", "label": "isAdoptiveParentOf"}
25
+ KIN_MONOZYGOTIC_TWIN = {"id": "KIN:010", "label": "isMonozygoticMultipleBirthSiblingOf"}
26
+ KIN_DIZYGOTIC_TWIN = {"id": "KIN:011", "label": "isDizygoticMultipleBirthSiblingOf"}
27
+
28
+ # ── Sex mappings ──────────────────────────────────────────────────────────
29
+ SHAPE_TO_SEX_CODE = {
30
+ "male": 1, "female": 2, "unknown": 0,
31
+ "adopted-male": 1, "adopted-female": 2, "adopted-unknown": 0,
32
+ "miscarriage": 0, "no-offspring": 0, "infertile": 0,
33
+ }
34
+ SEX_CODE_TO_GA4GH = {1: "MALE", 2: "FEMALE", 0: "UNKNOWN_SEX"}
35
+ SEX_CODE_TO_PHENOPACKET = {1: "MALE", 2: "FEMALE", 0: "UNKNOWN_SEX"}
36
+
37
+ # ── Phenopacket affected status ──────────────────────────────────────────
38
+ AFFECTED_STATUS = {"MISSING": "MISSING", "UNAFFECTED": "UNAFFECTED", "AFFECTED": "AFFECTED"}
39
+
40
+
41
+ # ── Session data models ──────────────────────────────────────────────────
42
+
43
+ @dataclass
44
+ class Person:
45
+ id: str
46
+ shape: str
47
+ sex: int
48
+ text: str
49
+ fill_mode: str
50
+ fill_color: Optional[str]
51
+ halves: list
52
+ quarters: list
53
+ shading_pattern: str
54
+ shading_coverage: str
55
+ center_text: str
56
+ deceased: bool
57
+ proband: bool
58
+ custom_label: str
59
+ father_id: Optional[str] = None
60
+ mother_id: Optional[str] = None
61
+ family_id: Optional[str] = None
62
+ is_adopted: bool = False # derived from dashed child link
63
+
64
+
65
+ @dataclass
66
+ class Partnership:
67
+ id: str
68
+ from_id: str
69
+ to_id: str
70
+ type: str
71
+
72
+
73
+ @dataclass
74
+ class ChildLink:
75
+ id: str
76
+ partnership_id: str
77
+ child_id: str
78
+ dashed: bool
79
+
80
+
81
+ @dataclass
82
+ class TwinLink:
83
+ id: str
84
+ child1_id: str
85
+ child2_id: str
86
+ type: str # 'identical' or 'non-identical'
87
+ partnership_id: str
88
+
89
+
90
+ @dataclass
91
+ class Phenotype:
92
+ name: str
93
+ key: str
94
+ is_shading: bool
95
+ color: Optional[str]
96
+ pattern: Optional[str]
97
+ coverage: Optional[str]
98
+
99
+
100
+ @dataclass
101
+ class Family:
102
+ family_id: str
103
+ person_ids: set
104
+ persons: dict
105
+ partnerships: list
106
+ child_links: list
107
+ twin_links: list
108
+
109
+
110
+ # ── Session parser ────────────────────────────────────────────────────────
111
+
112
+ def parse_session(data: dict) -> tuple:
113
+ """Parse Pedigree Studio session JSON. Returns (persons, partnerships, child_links, twin_links, legend_labels)."""
114
+ persons = {}
115
+ for pd in data.get("persons", []):
116
+ pid = pd["id"]
117
+ persons[pid] = Person(
118
+ id=pid, shape=pd.get("shape", "unknown"),
119
+ sex=SHAPE_TO_SEX_CODE.get(pd.get("shape", "unknown"), 0),
120
+ text=pd.get("text", ""),
121
+ fill_mode=pd.get("fillMode", "none"),
122
+ fill_color=pd.get("fillColor"),
123
+ halves=pd.get("halves", [None, None]),
124
+ quarters=pd.get("quarters", [None, None, None, None]),
125
+ shading_pattern=pd.get("shadingPattern", "stripes"),
126
+ shading_coverage=pd.get("shadingCoverage", "full"),
127
+ center_text=pd.get("centerText", ""),
128
+ deceased=pd.get("deceased", False),
129
+ proband=pd.get("proband", False),
130
+ custom_label=pd.get("customLabel", ""),
131
+ )
132
+
133
+ partnerships = [Partnership(id=p["id"], from_id=p["fromId"], to_id=p["toId"],
134
+ type=p.get("type", "normal")) for p in data.get("partnerships", [])]
135
+
136
+ child_links = [ChildLink(id=c["id"], partnership_id=c["partnershipId"],
137
+ child_id=c["childId"], dashed=c.get("dashed", False))
138
+ for c in data.get("childLinks", [])]
139
+
140
+ twin_links = [TwinLink(id=t["id"], child1_id=t["child1Id"], child2_id=t["child2Id"],
141
+ type=t.get("type", "identical"), partnership_id=t.get("partnershipId", ""))
142
+ for t in data.get("twinLinks", [])]
143
+
144
+ legend_labels = data.get("legendLabels", {})
145
+ return persons, partnerships, child_links, twin_links, legend_labels
146
+
147
+
148
+ # ── Graph analysis (reused from studio2ped) ───────────────────────────────
149
+
150
+ def detect_families(persons, partnerships, child_links, twin_links):
151
+ adj = defaultdict(set)
152
+ pship_map = {p.id: p for p in partnerships}
153
+
154
+ for p in partnerships:
155
+ if p.from_id in persons and p.to_id in persons:
156
+ adj[p.from_id].add(p.to_id)
157
+ adj[p.to_id].add(p.from_id)
158
+
159
+ for cl in child_links:
160
+ pship = pship_map.get(cl.partnership_id)
161
+ if pship and cl.child_id in persons:
162
+ for pid in (pship.from_id, pship.to_id):
163
+ if pid in persons:
164
+ adj[cl.child_id].add(pid)
165
+ adj[pid].add(cl.child_id)
166
+
167
+ for tl in twin_links:
168
+ if tl.child1_id in persons and tl.child2_id in persons:
169
+ adj[tl.child1_id].add(tl.child2_id)
170
+ adj[tl.child2_id].add(tl.child1_id)
171
+
172
+ visited = set()
173
+ families = []
174
+ fam_counter = 0
175
+
176
+ for pid in persons:
177
+ if pid in visited:
178
+ continue
179
+ fam_counter += 1
180
+ component = set()
181
+ queue = [pid]
182
+ while queue:
183
+ current = queue.pop(0)
184
+ if current in visited:
185
+ continue
186
+ visited.add(current)
187
+ component.add(current)
188
+ for n in adj.get(current, set()):
189
+ if n not in visited:
190
+ queue.append(n)
191
+
192
+ fam_partnerships = [p for p in partnerships if p.from_id in component or p.to_id in component]
193
+ fam_child_links = [cl for cl in child_links if cl.child_id in component]
194
+ fam_twin_links = [tl for tl in twin_links
195
+ if tl.child1_id in component or tl.child2_id in component]
196
+ fam_persons = {pid: persons[pid] for pid in component}
197
+
198
+ families.append(Family(
199
+ family_id=f"FAM{fam_counter}", person_ids=component,
200
+ persons=fam_persons, partnerships=fam_partnerships,
201
+ child_links=fam_child_links, twin_links=fam_twin_links,
202
+ ))
203
+ return families
204
+
205
+
206
+ def resolve_parents(family):
207
+ pship_map = {p.id: p for p in family.partnerships}
208
+ for cl in family.child_links:
209
+ pship = pship_map.get(cl.partnership_id)
210
+ if not pship:
211
+ continue
212
+ child = family.persons.get(cl.child_id)
213
+ if not child:
214
+ continue
215
+ p1 = family.persons.get(pship.from_id)
216
+ p2 = family.persons.get(pship.to_id)
217
+ if not p1 or not p2:
218
+ continue
219
+ if p1.sex == 1 and p2.sex == 2:
220
+ child.father_id, child.mother_id = p1.id, p2.id
221
+ elif p1.sex == 2 and p2.sex == 1:
222
+ child.father_id, child.mother_id = p2.id, p1.id
223
+ else:
224
+ child.father_id, child.mother_id = pship.from_id, pship.to_id
225
+ if cl.dashed:
226
+ child.is_adopted = True
227
+ for p in family.persons.values():
228
+ p.family_id = family.family_id
229
+
230
+
231
+ # ── ID and phenotype helpers ──────────────────────────────────────────────
232
+
233
+ def make_individual_id(person):
234
+ if person.custom_label and person.custom_label.strip() and person.custom_label.strip() != "Lbl":
235
+ raw = person.custom_label.strip()
236
+ elif person.text and person.text.strip():
237
+ raw = person.text.strip().split("\n")[0].strip()
238
+ else:
239
+ raw = person.id
240
+ sanitised = re.sub(r"\s+", "_", raw)
241
+ sanitised = re.sub(r"[^\w\-]", "", sanitised)
242
+ return sanitised or person.id
243
+
244
+
245
+ def extract_phenotypes(legend_labels):
246
+ phenotypes = []
247
+ for key, label in legend_labels.items():
248
+ if not label or not label.strip():
249
+ continue
250
+ label = label.strip()
251
+ if key.startswith("shading:"):
252
+ parts = key.split(":")
253
+ phenotypes.append(Phenotype(name=label, key=key, is_shading=True,
254
+ color=None, pattern=parts[1] if len(parts) > 1 else "stripes",
255
+ coverage=parts[2] if len(parts) > 2 else "full"))
256
+ else:
257
+ phenotypes.append(Phenotype(name=label, key=key, is_shading=False,
258
+ color=key, pattern=None, coverage=None))
259
+ return phenotypes
260
+
261
+
262
+ def is_person_affected(person, phenotypes):
263
+ """Check if a person is affected for any phenotype. Returns True/False."""
264
+ if person.fill_mode == "solid" and person.fill_color:
265
+ for ph in phenotypes:
266
+ if not ph.is_shading and ph.color == person.fill_color:
267
+ return True
268
+ if person.fill_mode == "half":
269
+ for c in (person.halves or []):
270
+ if c:
271
+ for ph in phenotypes:
272
+ if not ph.is_shading and ph.color == c:
273
+ return True
274
+ if person.fill_mode == "quartered":
275
+ for c in (person.quarters or []):
276
+ if c:
277
+ for ph in phenotypes:
278
+ if not ph.is_shading and ph.color == c:
279
+ return True
280
+ if person.fill_mode == "shading":
281
+ shading_key = f"shading:{person.shading_pattern}:{person.shading_coverage}"
282
+ for ph in phenotypes:
283
+ if ph.is_shading and (ph.key == shading_key or ph.pattern == person.shading_pattern):
284
+ return True
285
+ return False
286
+
287
+
288
+ def get_person_diseases(person, phenotypes):
289
+ """Return list of disease label strings this person is affected with."""
290
+ diseases = []
291
+ colours = set()
292
+ if person.fill_mode == "solid" and person.fill_color:
293
+ colours.add(person.fill_color)
294
+ elif person.fill_mode == "half":
295
+ for c in (person.halves or []):
296
+ if c:
297
+ colours.add(c)
298
+ elif person.fill_mode == "quartered":
299
+ for c in (person.quarters or []):
300
+ if c:
301
+ colours.add(c)
302
+
303
+ for ph in phenotypes:
304
+ if not ph.is_shading and ph.color in colours:
305
+ diseases.append(ph.name)
306
+ elif ph.is_shading and person.fill_mode == "shading":
307
+ if ph.pattern == person.shading_pattern:
308
+ diseases.append(ph.name)
309
+
310
+ return diseases
311
+
312
+
313
+ # ── GA4GH Pedigree output ────────────────────────────────────────────────
314
+
315
+ def build_ga4gh_pedigree(family, phenotypes, id_map):
316
+ """Build a GA4GH Pedigree Standard JSON document."""
317
+ pship_map = {p.id: p for p in family.partnerships}
318
+
319
+ # Build individuals
320
+ individuals = []
321
+ for p in sorted(family.persons.values(), key=lambda x: x.id):
322
+ ind = {
323
+ "id": id_map[p.id],
324
+ "sex": SEX_CODE_TO_GA4GH.get(p.sex, "UNKNOWN_SEX"),
325
+ }
326
+ # Name from annotation text
327
+ if p.text and p.text.strip():
328
+ parts = p.text.strip().split("\n")
329
+ ind["name"] = parts[0].strip()
330
+ # Date of birth from "b. YYYY" pattern
331
+ for part in parts:
332
+ dob_match = re.search(r"b\.\s*(\d{4})", part)
333
+ if dob_match:
334
+ ind["dateOfBirth"] = dob_match.group(1)
335
+
336
+ if p.deceased:
337
+ ind["deceased"] = True
338
+
339
+ if is_person_affected(p, phenotypes):
340
+ ind["affected"] = True
341
+
342
+ individuals.append(ind)
343
+
344
+ # Build relationships
345
+ relationships = []
346
+ for cl in family.child_links:
347
+ pship = pship_map.get(cl.partnership_id)
348
+ if not pship:
349
+ continue
350
+ child = family.persons.get(cl.child_id)
351
+ if not child:
352
+ continue
353
+
354
+ child_iid = id_map[cl.child_id]
355
+
356
+ # Mother relationship
357
+ if child.mother_id and child.mother_id in id_map:
358
+ mother = family.persons.get(child.mother_id)
359
+ if cl.dashed:
360
+ rel = dict(KIN_ADOPTIVE_PARENT_OF)
361
+ else:
362
+ rel = dict(KIN_BIOLOGICAL_MOTHER_OF)
363
+ relationships.append({
364
+ "individual": id_map[child.mother_id],
365
+ "relation": rel,
366
+ "relative": child_iid,
367
+ })
368
+
369
+ # Father relationship
370
+ if child.father_id and child.father_id in id_map:
371
+ if cl.dashed:
372
+ rel = dict(KIN_ADOPTIVE_PARENT_OF)
373
+ else:
374
+ rel = dict(KIN_BIOLOGICAL_FATHER_OF)
375
+ relationships.append({
376
+ "individual": id_map[child.father_id],
377
+ "relation": rel,
378
+ "relative": child_iid,
379
+ })
380
+
381
+ # Twin relationships
382
+ for tl in family.twin_links:
383
+ if tl.child1_id in id_map and tl.child2_id in id_map:
384
+ if tl.type == "identical":
385
+ rel = dict(KIN_MONOZYGOTIC_TWIN)
386
+ else:
387
+ rel = dict(KIN_DIZYGOTIC_TWIN)
388
+ relationships.append({
389
+ "individual": id_map[tl.child1_id],
390
+ "relation": rel,
391
+ "relative": id_map[tl.child2_id],
392
+ })
393
+
394
+ # Index patients (probands)
395
+ index_patients = [id_map[p.id] for p in family.persons.values() if p.proband]
396
+
397
+ pedigree = {
398
+ "id": family.family_id,
399
+ "individuals": individuals,
400
+ "relationships": relationships,
401
+ "date": date.today().isoformat(),
402
+ }
403
+ if index_patients:
404
+ pedigree["indexPatients"] = index_patients
405
+
406
+ # Add narrative
407
+ pheno_desc = ", ".join(ph.name for ph in phenotypes) if phenotypes else "unspecified"
408
+ pedigree["narrative"] = (
409
+ f"GA4GH Pedigree with {len(individuals)} individuals. "
410
+ f"Conditions tracked: {pheno_desc}."
411
+ )
412
+
413
+ return pedigree
414
+
415
+
416
+ # ── Phenopackets output ──────────────────────────────────────────────────
417
+
418
+ def _make_disease_term(label: str) -> dict:
419
+ """Create a Phenopackets Disease element from a free-text label.
420
+
421
+ The OntologyClass `term` uses the label as-is with an empty `id` field.
422
+ This follows the pattern used in the GA4GH Pedigree Standard examples.
423
+ For full Phenopackets compliance, downstream tools should map these labels
424
+ to ontology terms (OMIM, Orphanet, MONDO, etc.).
425
+ """
426
+ return {
427
+ "term": {"id": "", "label": label},
428
+ }
429
+
430
+
431
+ def build_phenopackets_family(family, phenotypes, id_map):
432
+ """Build a Phenopackets Family JSON document.
433
+
434
+ Contains:
435
+ - A PED-compatible Pedigree (list of Person records)
436
+ - Disease information extracted from the legend
437
+ - Proband Phenopacket
438
+ """
439
+ # Build PED-compatible pedigree
440
+ ped_persons = []
441
+ for p in sorted(family.persons.values(), key=lambda x: x.id):
442
+ iid = id_map[p.id]
443
+ father = id_map.get(p.father_id, "0") if p.father_id else "0"
444
+ mother = id_map.get(p.mother_id, "0") if p.mother_id else "0"
445
+
446
+ affected = is_person_affected(p, phenotypes)
447
+ status = "AFFECTED" if affected else "UNAFFECTED"
448
+
449
+ ped_persons.append({
450
+ "familyId": family.family_id,
451
+ "individualId": iid,
452
+ "paternalId": father,
453
+ "maternalId": mother,
454
+ "sex": SEX_CODE_TO_PHENOPACKET.get(p.sex, "UNKNOWN_SEX"),
455
+ "affectedStatus": status,
456
+ })
457
+
458
+ pedigree_block = {"persons": ped_persons}
459
+
460
+ # Check consanguinity
461
+ consanguinous = any(ps.type == "consanguineous" for ps in family.partnerships)
462
+
463
+ # Find proband
464
+ proband_person = None
465
+ for p in family.persons.values():
466
+ if p.proband:
467
+ proband_person = p
468
+ break
469
+ # Fallback: first affected individual
470
+ if not proband_person:
471
+ for p in family.persons.values():
472
+ if is_person_affected(p, phenotypes):
473
+ proband_person = p
474
+ break
475
+ # Fallback: first individual
476
+ if not proband_person and family.persons:
477
+ proband_person = next(iter(family.persons.values()))
478
+
479
+ # Build proband phenopacket
480
+ proband_phenopacket = None
481
+ if proband_person:
482
+ proband_subject = {
483
+ "id": id_map[proband_person.id],
484
+ "sex": SEX_CODE_TO_PHENOPACKET.get(proband_person.sex, "UNKNOWN_SEX"),
485
+ }
486
+ if proband_person.deceased:
487
+ proband_subject["vitalStatus"] = {"status": "DECEASED"}
488
+
489
+ proband_diseases = [_make_disease_term(d) for d in get_person_diseases(proband_person, phenotypes)]
490
+
491
+ proband_phenopacket = {
492
+ "id": f"{family.family_id}-proband",
493
+ "subject": proband_subject,
494
+ }
495
+ if proband_diseases:
496
+ proband_phenopacket["diseases"] = proband_diseases
497
+
498
+ # Build relative phenopackets — ONLY for affected relatives with disease info.
499
+ # Unaffected relatives and deceased-but-unaffected individuals do not need
500
+ # their own Phenopackets — their status is already in the pedigree block.
501
+ relatives = []
502
+ for p in sorted(family.persons.values(), key=lambda x: x.id):
503
+ if proband_person and p.id == proband_person.id:
504
+ continue
505
+ diseases = get_person_diseases(p, phenotypes)
506
+ if not diseases:
507
+ continue # no disease info = no Phenopacket needed
508
+
509
+ rel_subject = {
510
+ "id": id_map[p.id],
511
+ "sex": SEX_CODE_TO_PHENOPACKET.get(p.sex, "UNKNOWN_SEX"),
512
+ }
513
+ if p.deceased:
514
+ rel_subject["vitalStatus"] = {"status": "DECEASED"}
515
+
516
+ rel_packet = {"id": f"{family.family_id}-{id_map[p.id]}", "subject": rel_subject}
517
+ rel_packet["diseases"] = [_make_disease_term(d) for d in diseases]
518
+ relatives.append(rel_packet)
519
+
520
+ # Build metadata
521
+ meta_data = {
522
+ "created": date.today().isoformat() + "T00:00:00Z",
523
+ "createdBy": "studio2ga4gh",
524
+ "resources": [],
525
+ }
526
+
527
+ # Assemble Family
528
+ family_doc = {
529
+ "id": family.family_id,
530
+ "pedigree": pedigree_block,
531
+ "metaData": meta_data,
532
+ }
533
+ if proband_phenopacket:
534
+ family_doc["proband"] = proband_phenopacket
535
+ if relatives:
536
+ family_doc["relatives"] = relatives
537
+ if consanguinous:
538
+ family_doc["consanguinousParents"] = True
539
+
540
+ return family_doc
541
+
542
+
543
+ # ── Public API ────────────────────────────────────────────────────────────
544
+
545
+ @dataclass
546
+ class ConversionResult:
547
+ files: list # [(filename, content_string)]
548
+ families: list
549
+ phenotypes: list
550
+ summary: str
551
+
552
+
553
+ def convert(session_data: dict, output_prefix: str = "pedigree") -> ConversionResult:
554
+ """Convert a Pedigree Studio session to GA4GH Pedigree and Phenopackets files.
555
+
556
+ Produces two JSON files per family:
557
+ - {prefix}_ga4gh.json — GA4GH Pedigree Standard (relationship graph with KIN codes)
558
+ - {prefix}_phenopackets.json — Phenopackets Family (PED-compatible pedigree + diseases)
559
+
560
+ For sessions with multiple disconnected pedigrees, produces numbered pairs.
561
+ """
562
+ persons, partnerships, child_links, twin_links, legend_labels = parse_session(session_data)
563
+ if not persons:
564
+ return ConversionResult(files=[], families=[], phenotypes=[], summary="No persons found.")
565
+
566
+ families = detect_families(persons, partnerships, child_links, twin_links)
567
+ for fam in families:
568
+ resolve_parents(fam)
569
+
570
+ phenotypes = extract_phenotypes(legend_labels)
571
+
572
+ files = []
573
+ for i, fam in enumerate(families):
574
+ suffix = f"_{i + 1}" if len(families) > 1 else ""
575
+ id_map = {p.id: make_individual_id(p) for p in fam.persons.values()}
576
+
577
+ # GA4GH Pedigree
578
+ ga4gh = build_ga4gh_pedigree(fam, phenotypes, id_map)
579
+ ga4gh_json = json.dumps(ga4gh, indent=2)
580
+ files.append((f"{output_prefix}{suffix}_ga4gh.json", ga4gh_json))
581
+
582
+ # Phenopackets Family
583
+ phenopackets = build_phenopackets_family(fam, phenotypes, id_map)
584
+ phenopackets_json = json.dumps(phenopackets, indent=2)
585
+ files.append((f"{output_prefix}{suffix}_phenopackets.json", phenopackets_json))
586
+
587
+ fam_desc = ", ".join(f"{f.family_id} ({len(f.persons)} individuals)" for f in families)
588
+ pheno_desc = ", ".join(ph.name for ph in phenotypes) if phenotypes else "none"
589
+ twins = sum(len(f.twin_links) for f in families)
590
+ adopted = sum(1 for f in families for p in f.persons.values() if p.is_adopted)
591
+ summary = (
592
+ f"Converted {len(families)} pedigree(s): {fam_desc}. "
593
+ f"Phenotypes: {pheno_desc}. "
594
+ f"Twins: {twins}. Adoptions: {adopted}. "
595
+ f"Files: {len(files)} ({len(families)} GA4GH + {len(families)} Phenopackets)."
596
+ )
597
+
598
+ return ConversionResult(files=files, families=families, phenotypes=phenotypes, summary=summary)
599
+
600
+
601
+ def convert_file(input_path: str, output_prefix: Optional[str] = None,
602
+ output_dir: Optional[str] = None) -> ConversionResult:
603
+ """Read a session JSON and write GA4GH + Phenopackets files."""
604
+ with open(input_path, "r") as f:
605
+ session_data = json.load(f)
606
+
607
+ if output_prefix is None:
608
+ output_prefix = os.path.splitext(os.path.basename(input_path))[0]
609
+ if output_dir is None:
610
+ output_dir = os.path.dirname(input_path) or "."
611
+
612
+ result = convert(session_data, output_prefix)
613
+
614
+ written = []
615
+ for filename, content in result.files:
616
+ filepath = os.path.join(output_dir, filename)
617
+ with open(filepath, "w") as f:
618
+ f.write(content)
619
+ written.append((filepath, content))
620
+
621
+ result.files = written
622
+ return result
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: studio2ga4gh
3
+ Version: 0.1.0
4
+ Summary: Convert Pedigree Studio sessions to GA4GH Pedigree Standard and Phenopackets
5
+ Author-email: Tim Hearn <tjh70@cam.ac.uk>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/comparativechrono/studio2ga4gh
8
+ Project-URL: Issues, https://github.com/comparativechrono/studio2ga4gh/issues
9
+ Keywords: pedigree,ga4gh,phenopackets,genetics,clinical-genetics,interoperability
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
14
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # studio2ga4gh
23
+
24
+ Convert Pedigree Studio session JSON files to GA4GH interoperability formats:
25
+
26
+ - **GA4GH Pedigree Standard** — relationship-graph model using the Kinship Ontology (KIN)
27
+ - **Phenopackets Family** — PED-compatible pedigree with disease information
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install studio2ga4gh
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### Command line
38
+
39
+ ```bash
40
+ studio2ga4gh session.json
41
+ studio2ga4gh session.json -o my_family -d ./output/
42
+ ```
43
+
44
+ This produces two files per pedigree:
45
+ - `{name}_ga4gh.json` — GA4GH Pedigree (relationship graph with KIN ontology codes)
46
+ - `{name}_phenopackets.json` — Phenopackets Family (PED-compatible pedigree + diseases)
47
+
48
+ ### Python API
49
+
50
+ ```python
51
+ import studio2ga4gh, json
52
+
53
+ with open("session.json") as f:
54
+ session = json.load(f)
55
+
56
+ result = studio2ga4gh.convert(session)
57
+ print(result.summary)
58
+ for filename, content in result.files:
59
+ print(f"--- {filename} ---")
60
+ print(content[:200])
61
+ ```
62
+
63
+ ## What Each Output Contains
64
+
65
+ ### GA4GH Pedigree (`_ga4gh.json`)
66
+
67
+ The rich graph-based format from the GA4GH Pedigree Standard. It represents each parent-child and twin relationship as an explicit edge with a KIN ontology code:
68
+
69
+ | Relationship | KIN Code | Label |
70
+ |-------------|----------|-------|
71
+ | Biological mother | KIN:027 | isBiologicalMotherOf |
72
+ | Biological father | KIN:028 | isBiologicalFatherOf |
73
+ | Adoptive parent | KIN:022 | isAdoptiveParentOf |
74
+ | Identical twin | KIN:010 | isMonozygoticMultipleBirthSiblingOf |
75
+ | Non-identical twin | KIN:011 | isDizygoticMultipleBirthSiblingOf |
76
+
77
+ Dashed child lines in Pedigree Studio (adoption) are exported as `isAdoptiveParentOf` rather than biological parent relationships.
78
+
79
+ ### Phenopackets Family (`_phenopackets.json`)
80
+
81
+ The GA4GH Phenopackets schema `Family` element, containing:
82
+ - A PED-compatible `pedigree` with `Person` records (familyId, individualId, parents, sex, affectedStatus)
83
+ - A `proband` Phenopacket with disease terms extracted from the legend
84
+ - `relatives` Phenopackets for affected family members with their diseases
85
+ - `consanguinousParents` flag if any consanguineous partnerships exist
86
+
87
+ ## Phenotype Mapping
88
+
89
+ Disease/condition names are taken from the Pedigree Studio legend labels. The converter maps visual markers to affected status using the same logic as studio2ped.
90
+
91
+ ## Multiple Pedigrees
92
+
93
+ If the session contains disconnected pedigrees, numbered file pairs are produced:
94
+ ```
95
+ pedigree_1_ga4gh.json
96
+ pedigree_1_phenopackets.json
97
+ pedigree_2_ga4gh.json
98
+ pedigree_2_phenopackets.json
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ studio2ga4gh/__init__.py
5
+ studio2ga4gh/__main__.py
6
+ studio2ga4gh/cli.py
7
+ studio2ga4gh/converter.py
8
+ studio2ga4gh.egg-info/PKG-INFO
9
+ studio2ga4gh.egg-info/SOURCES.txt
10
+ studio2ga4gh.egg-info/dependency_links.txt
11
+ studio2ga4gh.egg-info/entry_points.txt
12
+ studio2ga4gh.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ studio2ga4gh = studio2ga4gh.cli:main
@@ -0,0 +1 @@
1
+ studio2ga4gh