ga4gh2studio 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,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: ga4gh2studio
3
+ Version: 0.1.0
4
+ Summary: Convert GA4GH Pedigree Standard and Phenopackets to Pedigree Studio session JSON
5
+ Author-email: Tim Hearn <tjh70@cam.ac.uk>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/comparativechrono/ga4gh2studio
8
+ Project-URL: Issues, https://github.com/comparativechrono/ga4gh2studio/issues
9
+ Keywords: pedigree,ga4gh,phenopackets,genetics,clinical-genetics,pedigree-studio
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
+ # ga4gh2studio
23
+
24
+ Convert GA4GH Pedigree Standard and Phenopackets Family files into Pedigree Studio session JSON.
25
+
26
+ The reverse complement of `studio2ga4gh`. Together they provide bidirectional interoperability between Pedigree Studio and the GA4GH ecosystem.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install ga4gh2studio
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Command line
37
+
38
+ ```bash
39
+ # From GA4GH Pedigree only
40
+ ga4gh2studio -g pedigree_ga4gh.json -o session.json
41
+
42
+ # From Phenopackets Family only
43
+ ga4gh2studio -p family_phenopackets.json -o session.json
44
+
45
+ # From both (GA4GH for structure, Phenopackets for disease labels)
46
+ ga4gh2studio -g pedigree_ga4gh.json -p family_phenopackets.json -o session.json
47
+ ```
48
+
49
+ ### Python API
50
+
51
+ ```python
52
+ import ga4gh2studio, json
53
+
54
+ # From GA4GH Pedigree
55
+ with open("pedigree_ga4gh.json") as f:
56
+ ga4gh = json.load(f)
57
+ result = ga4gh2studio.convert(ga4gh_data=ga4gh)
58
+ with open("session.json", "w") as f:
59
+ json.dump(result.session, f, indent=2)
60
+
61
+ # From both sources
62
+ with open("family_phenopackets.json") as f:
63
+ pp = json.load(f)
64
+ result = ga4gh2studio.convert(ga4gh_data=ga4gh, phenopackets_data=pp)
65
+ print(result.summary)
66
+ ```
67
+
68
+ ## Input Handling
69
+
70
+ | Input | What it provides |
71
+ |-------|-----------------|
72
+ | GA4GH Pedigree only | Full structure: parent-child (biological + adoptive), twins, sex, deceased status, proband |
73
+ | Phenopackets Family only | PED-compatible structure (parents, sex, affected status) + disease labels |
74
+ | Both (recommended) | GA4GH provides rich structure; Phenopackets enriches with disease names and vital status |
75
+
76
+ ## Feature Mapping
77
+
78
+ | GA4GH / Phenopackets | Pedigree Studio |
79
+ |---------------------|-----------------|
80
+ | KIN:027 isBiologicalMotherOf | Partnership + solid child link |
81
+ | KIN:028 isBiologicalFatherOf | Partnership + solid child link |
82
+ | KIN:022 isAdoptiveParentOf | Partnership + dashed child link |
83
+ | KIN:010 isMonozygoticTwin | Identical twin link |
84
+ | KIN:011 isDizygoticTwin | Non-identical twin link |
85
+ | indexPatients | Proband arrow |
86
+ | Individual.deceased | Deceased diagonal line |
87
+ | Individual.name / dateOfBirth | Annotation text ("Name\nb. 1985") |
88
+ | Disease term labels | Legend entries with distinct colours |
89
+ | Shared ancestors | Consanguineous partnership (double line) |
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,72 @@
1
+ # ga4gh2studio
2
+
3
+ Convert GA4GH Pedigree Standard and Phenopackets Family files into Pedigree Studio session JSON.
4
+
5
+ The reverse complement of `studio2ga4gh`. Together they provide bidirectional interoperability between Pedigree Studio and the GA4GH ecosystem.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install ga4gh2studio
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Command line
16
+
17
+ ```bash
18
+ # From GA4GH Pedigree only
19
+ ga4gh2studio -g pedigree_ga4gh.json -o session.json
20
+
21
+ # From Phenopackets Family only
22
+ ga4gh2studio -p family_phenopackets.json -o session.json
23
+
24
+ # From both (GA4GH for structure, Phenopackets for disease labels)
25
+ ga4gh2studio -g pedigree_ga4gh.json -p family_phenopackets.json -o session.json
26
+ ```
27
+
28
+ ### Python API
29
+
30
+ ```python
31
+ import ga4gh2studio, json
32
+
33
+ # From GA4GH Pedigree
34
+ with open("pedigree_ga4gh.json") as f:
35
+ ga4gh = json.load(f)
36
+ result = ga4gh2studio.convert(ga4gh_data=ga4gh)
37
+ with open("session.json", "w") as f:
38
+ json.dump(result.session, f, indent=2)
39
+
40
+ # From both sources
41
+ with open("family_phenopackets.json") as f:
42
+ pp = json.load(f)
43
+ result = ga4gh2studio.convert(ga4gh_data=ga4gh, phenopackets_data=pp)
44
+ print(result.summary)
45
+ ```
46
+
47
+ ## Input Handling
48
+
49
+ | Input | What it provides |
50
+ |-------|-----------------|
51
+ | GA4GH Pedigree only | Full structure: parent-child (biological + adoptive), twins, sex, deceased status, proband |
52
+ | Phenopackets Family only | PED-compatible structure (parents, sex, affected status) + disease labels |
53
+ | Both (recommended) | GA4GH provides rich structure; Phenopackets enriches with disease names and vital status |
54
+
55
+ ## Feature Mapping
56
+
57
+ | GA4GH / Phenopackets | Pedigree Studio |
58
+ |---------------------|-----------------|
59
+ | KIN:027 isBiologicalMotherOf | Partnership + solid child link |
60
+ | KIN:028 isBiologicalFatherOf | Partnership + solid child link |
61
+ | KIN:022 isAdoptiveParentOf | Partnership + dashed child link |
62
+ | KIN:010 isMonozygoticTwin | Identical twin link |
63
+ | KIN:011 isDizygoticTwin | Non-identical twin link |
64
+ | indexPatients | Proband arrow |
65
+ | Individual.deceased | Deceased diagonal line |
66
+ | Individual.name / dateOfBirth | Annotation text ("Name\nb. 1985") |
67
+ | Disease term labels | Legend entries with distinct colours |
68
+ | Shared ancestors | Consanguineous partnership (double line) |
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,4 @@
1
+ """ga4gh2studio — Convert GA4GH Pedigree and Phenopackets to Pedigree Studio sessions."""
2
+ __version__ = "0.1.0"
3
+ from .converter import convert, convert_file, ConversionResult
4
+ __all__ = ["convert", "convert_file", "ConversionResult"]
@@ -0,0 +1,4 @@
1
+ """Allow running as `python -m ga4gh2studio`."""
2
+ import sys
3
+ from .cli import main
4
+ sys.exit(main())
@@ -0,0 +1,32 @@
1
+ """Command-line interface for ga4gh2studio."""
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="ga4gh2studio",
8
+ description="Convert GA4GH Pedigree and/or Phenopackets Family to Pedigree Studio session JSON.")
9
+ parser.add_argument("-g", "--ga4gh", default=None,
10
+ help="Path to GA4GH Pedigree JSON file")
11
+ parser.add_argument("-p", "--phenopackets", default=None,
12
+ help="Path to Phenopackets Family JSON file")
13
+ parser.add_argument("-o", "--output", default="pedigree_session.json",
14
+ help="Output Pedigree Studio session JSON path (default: pedigree_session.json)")
15
+ args = parser.parse_args(argv)
16
+
17
+ if not args.ga4gh and not args.phenopackets:
18
+ parser.error("At least one of --ga4gh or --phenopackets must be provided")
19
+
20
+ try:
21
+ result = convert_file(ga4gh_path=args.ga4gh, phenopackets_path=args.phenopackets,
22
+ output_path=args.output)
23
+ print(result.summary)
24
+ print(f" Written: {args.output}")
25
+ return 0
26
+ except FileNotFoundError as e:
27
+ print(f"Error: {e}", file=sys.stderr); return 1
28
+ except Exception as e:
29
+ print(f"Error: {e}", file=sys.stderr); return 1
30
+
31
+ if __name__ == "__main__":
32
+ sys.exit(main())
@@ -0,0 +1,742 @@
1
+ """
2
+ ga4gh2studio - Convert GA4GH Pedigree and Phenopackets to Pedigree Studio sessions.
3
+
4
+ Accepts GA4GH Pedigree JSON, Phenopackets Family JSON, or both.
5
+ When both are provided, GA4GH is used for structure (relationships, twins,
6
+ adoption) and Phenopackets enriches with disease labels.
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 typing import Optional
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ # ── Pedigree Studio layout constants ──────────────────────────────────────
21
+ SHAPE_SIZE = 52
22
+ SHAPE_HALF = 26
23
+ PARTNER_GAP = SHAPE_SIZE * 2
24
+ CHILD_HEIGHT = SHAPE_SIZE * 2.5
25
+ CHILD_SPACING = 104
26
+ MIN_BLOCK_GAP = 60
27
+ STROKE_COLOR = "#1a2332"
28
+
29
+ # Colour palette for disease phenotypes
30
+ DISEASE_PALETTE = [
31
+ '#c0392b', '#2980b9', '#27ae60', '#e67e22',
32
+ '#8e44ad', '#f1c40f', '#16a085', '#d35400', '#7f8c8d',
33
+ ]
34
+
35
+ # ── KIN code mapping ──────────────────────────────────────────────────────
36
+ # Maps KIN IDs to semantic meaning
37
+ KIN_MOTHER = {"KIN:027"} # isBiologicalMotherOf
38
+ KIN_FATHER = {"KIN:028"} # isBiologicalFatherOf
39
+ KIN_ADOPTIVE = {"KIN:022"} # isAdoptiveParentOf
40
+ KIN_MONO_TWIN = {"KIN:010"} # isMonozygoticMultipleBirthSiblingOf
41
+ KIN_DI_TWIN = {"KIN:011"} # isDizygoticMultipleBirthSiblingOf
42
+
43
+ SEX_GA4GH_TO_CODE = {"MALE": 1, "FEMALE": 2, "UNKNOWN_SEX": 0, "OTHER_SEX": 0}
44
+ SEX_CODE_TO_SHAPE = {1: "male", 2: "female", 0: "unknown"}
45
+
46
+
47
+ # ── Internal data models ─────────────────────────────────────────────────
48
+
49
+ @dataclass
50
+ class Individual:
51
+ id: str
52
+ sex: int # 1=male, 2=female, 0=unknown
53
+ name: str = ""
54
+ date_of_birth: str = ""
55
+ deceased: bool = False
56
+ affected: bool = False
57
+ proband: bool = False
58
+ diseases: list[str] = field(default_factory=list) # disease label strings
59
+ father_id: Optional[str] = None
60
+ mother_id: Optional[str] = None
61
+ is_adopted: bool = False
62
+ generation: int = -1
63
+ x: float = 0.0
64
+ y: float = 0.0
65
+ partners: list[str] = field(default_factory=list)
66
+ mating_units: list[str] = field(default_factory=list)
67
+
68
+
69
+ @dataclass
70
+ class MatingUnit:
71
+ key: str
72
+ father_id: str
73
+ mother_id: str
74
+ child_ids: list[str] = field(default_factory=list)
75
+
76
+
77
+ @dataclass
78
+ class TwinPair:
79
+ child1_id: str
80
+ child2_id: str
81
+ type: str # 'identical' or 'non-identical'
82
+
83
+
84
+ # ── GA4GH Pedigree parser ────────────────────────────────────────────────
85
+
86
+ def parse_ga4gh(data: dict) -> tuple[dict[str, Individual], list[TwinPair]]:
87
+ """Parse a GA4GH Pedigree Standard JSON.
88
+
89
+ Extracts individuals from the individuals array and derives parent-child
90
+ and twin relationships from the relationships array using KIN ontology codes.
91
+
92
+ Returns (individuals dict, twin pairs list).
93
+ """
94
+ inds: dict[str, Individual] = {}
95
+ twins: list[TwinPair] = []
96
+
97
+ # Parse individuals
98
+ for ind_data in data.get("individuals", []):
99
+ iid = ind_data.get("id", "")
100
+ if not iid:
101
+ continue
102
+ sex_str = ind_data.get("sex", "UNKNOWN_SEX")
103
+ inds[iid] = Individual(
104
+ id=iid,
105
+ sex=SEX_GA4GH_TO_CODE.get(sex_str, 0),
106
+ name=ind_data.get("name", ""),
107
+ date_of_birth=ind_data.get("dateOfBirth", ""),
108
+ deceased=ind_data.get("deceased", False),
109
+ affected=ind_data.get("affected", False),
110
+ proband=False,
111
+ )
112
+
113
+ # Mark probands from indexPatients
114
+ for pid in data.get("indexPatients", []):
115
+ if pid in inds:
116
+ inds[pid].proband = True
117
+
118
+ # Parse relationships
119
+ for rel in data.get("relationships", []):
120
+ subject = rel.get("individual", "")
121
+ obj = rel.get("relative", "")
122
+ kin_id = rel.get("relation", {}).get("id", "")
123
+
124
+ if not subject or not obj:
125
+ continue
126
+
127
+ if kin_id in KIN_MOTHER:
128
+ # subject isBiologicalMotherOf obj → obj's mother is subject
129
+ if obj in inds:
130
+ inds[obj].mother_id = subject
131
+ elif kin_id in KIN_FATHER:
132
+ # subject isBiologicalFatherOf obj → obj's father is subject
133
+ if obj in inds:
134
+ inds[obj].father_id = subject
135
+ elif kin_id in KIN_ADOPTIVE:
136
+ # subject isAdoptiveParentOf obj
137
+ if obj in inds:
138
+ parent = inds.get(subject)
139
+ if parent:
140
+ if parent.sex == 2:
141
+ inds[obj].mother_id = subject
142
+ else:
143
+ inds[obj].father_id = subject
144
+ inds[obj].is_adopted = True
145
+ elif kin_id in KIN_MONO_TWIN:
146
+ twins.append(TwinPair(child1_id=subject, child2_id=obj, type="identical"))
147
+ elif kin_id in KIN_DI_TWIN:
148
+ twins.append(TwinPair(child1_id=subject, child2_id=obj, type="non-identical"))
149
+
150
+ return inds, twins
151
+
152
+
153
+ # ── Phenopackets parser ───────────────────────────────────────────────────
154
+
155
+ def parse_phenopackets(data: dict) -> tuple[dict[str, Individual], list[str]]:
156
+ """Parse a Phenopackets Family JSON.
157
+
158
+ Extracts individuals from the pedigree block and disease labels from
159
+ proband and relative Phenopackets.
160
+
161
+ Returns (individuals dict, disease names list).
162
+ """
163
+ inds: dict[str, Individual] = {}
164
+ disease_names: list[str] = []
165
+ seen_diseases: set[str] = set()
166
+
167
+ # Parse pedigree block
168
+ pedigree = data.get("pedigree", {})
169
+ for person in pedigree.get("persons", []):
170
+ iid = person.get("individualId", "")
171
+ if not iid:
172
+ continue
173
+ sex_str = person.get("sex", "UNKNOWN_SEX")
174
+ father = person.get("paternalId", "0")
175
+ mother = person.get("maternalId", "0")
176
+ status = person.get("affectedStatus", "MISSING")
177
+
178
+ inds[iid] = Individual(
179
+ id=iid,
180
+ sex=SEX_GA4GH_TO_CODE.get(sex_str, 0),
181
+ father_id=father if father != "0" else None,
182
+ mother_id=mother if mother != "0" else None,
183
+ affected=(status == "AFFECTED"),
184
+ )
185
+
186
+ # Extract diseases from proband
187
+ proband = data.get("proband", {})
188
+ proband_id = proband.get("subject", {}).get("id", "")
189
+ if proband_id and proband_id in inds:
190
+ inds[proband_id].proband = True
191
+ vs = proband.get("subject", {}).get("vitalStatus", {})
192
+ if vs.get("status") == "DECEASED":
193
+ inds[proband_id].deceased = True
194
+ for disease in proband.get("diseases", []):
195
+ label = disease.get("term", {}).get("label", "")
196
+ if label and label not in seen_diseases:
197
+ seen_diseases.add(label)
198
+ disease_names.append(label)
199
+ if label and proband_id in inds:
200
+ inds[proband_id].diseases.append(label)
201
+
202
+ # Extract diseases from relatives
203
+ for rel in data.get("relatives", []):
204
+ rel_id = rel.get("subject", {}).get("id", "")
205
+ if rel_id and rel_id in inds:
206
+ vs = rel.get("subject", {}).get("vitalStatus", {})
207
+ if vs.get("status") == "DECEASED":
208
+ inds[rel_id].deceased = True
209
+ for disease in rel.get("diseases", []):
210
+ label = disease.get("term", {}).get("label", "")
211
+ if label and label not in seen_diseases:
212
+ seen_diseases.add(label)
213
+ disease_names.append(label)
214
+ if label:
215
+ inds[rel_id].diseases.append(label)
216
+
217
+ # Consanguinity flag
218
+ # (stored on result but not on individuals — used when building session)
219
+
220
+ return inds, disease_names
221
+
222
+
223
+ # ── Merge GA4GH + Phenopackets ────────────────────────────────────────────
224
+
225
+ def merge_inputs(
226
+ ga4gh_inds: Optional[dict[str, Individual]],
227
+ ga4gh_twins: Optional[list[TwinPair]],
228
+ pp_inds: Optional[dict[str, Individual]],
229
+ pp_diseases: Optional[list[str]],
230
+ ) -> tuple[dict[str, Individual], list[TwinPair], list[str]]:
231
+ """Merge GA4GH and Phenopackets inputs into a single individual set.
232
+
233
+ GA4GH is primary for structure (parent-child, twins, adoption).
234
+ Phenopackets enriches with disease labels and vital status.
235
+ """
236
+ if ga4gh_inds and pp_inds:
237
+ # GA4GH is the structural source
238
+ inds = ga4gh_inds
239
+ twins = ga4gh_twins or []
240
+ diseases = pp_diseases or []
241
+
242
+ # Enrich from Phenopackets
243
+ for iid, pp_ind in pp_inds.items():
244
+ if iid in inds:
245
+ if pp_ind.diseases:
246
+ inds[iid].diseases = pp_ind.diseases
247
+ if pp_ind.deceased:
248
+ inds[iid].deceased = True
249
+ if pp_ind.proband:
250
+ inds[iid].proband = True
251
+ # Don't add individuals only in Phenopackets — GA4GH is authoritative
252
+
253
+ return inds, twins, diseases
254
+
255
+ elif ga4gh_inds:
256
+ return ga4gh_inds, ga4gh_twins or [], []
257
+
258
+ elif pp_inds:
259
+ return pp_inds, [], pp_diseases or []
260
+
261
+ else:
262
+ return {}, [], []
263
+
264
+
265
+ # ── Graph analysis (from ped2studio) ──────────────────────────────────────
266
+
267
+ def build_mating_units(inds: dict[str, Individual]) -> dict[str, MatingUnit]:
268
+ units: dict[str, MatingUnit] = {}
269
+ for ind in inds.values():
270
+ if ind.father_id and ind.mother_id:
271
+ key = f"{ind.father_id}+{ind.mother_id}"
272
+ if key not in units:
273
+ units[key] = MatingUnit(key=key, father_id=ind.father_id, mother_id=ind.mother_id)
274
+ units[key].child_ids.append(ind.id)
275
+ if ind.father_id in inds:
276
+ if ind.mother_id not in inds[ind.father_id].partners:
277
+ inds[ind.father_id].partners.append(ind.mother_id)
278
+ if key not in inds[ind.father_id].mating_units:
279
+ inds[ind.father_id].mating_units.append(key)
280
+ if ind.mother_id in inds:
281
+ if ind.father_id not in inds[ind.mother_id].partners:
282
+ inds[ind.mother_id].partners.append(ind.father_id)
283
+ if key not in inds[ind.mother_id].mating_units:
284
+ inds[ind.mother_id].mating_units.append(key)
285
+ return units
286
+
287
+
288
+ def assign_generations(inds: dict[str, Individual]) -> int:
289
+ for ind in inds.values():
290
+ has_f = ind.father_id and ind.father_id in inds
291
+ has_m = ind.mother_id and ind.mother_id in inds
292
+ if not has_f and not has_m:
293
+ ind.generation = 0
294
+
295
+ changed = True
296
+ while changed:
297
+ changed = False
298
+ for ind in inds.values():
299
+ if ind.generation >= 0:
300
+ continue
301
+ fg = inds[ind.father_id].generation if (ind.father_id and ind.father_id in inds) else -1
302
+ mg = inds[ind.mother_id].generation if (ind.mother_id and ind.mother_id in inds) else -1
303
+ if fg >= 0 and mg >= 0:
304
+ ind.generation = max(fg, mg) + 1; changed = True
305
+ elif fg >= 0 and not (ind.mother_id and ind.mother_id in inds):
306
+ ind.generation = fg + 1; changed = True
307
+ elif mg >= 0 and not (ind.father_id and ind.father_id in inds):
308
+ ind.generation = mg + 1; changed = True
309
+
310
+ for ind in inds.values():
311
+ if ind.generation >= 0 and ind.partners:
312
+ has_parent = (ind.father_id and ind.father_id in inds) or (ind.mother_id and ind.mother_id in inds)
313
+ if has_parent:
314
+ continue
315
+ for pid in ind.partners:
316
+ if pid in inds and inds[pid].generation > ind.generation:
317
+ ind.generation = inds[pid].generation; break
318
+
319
+ for ind in inds.values():
320
+ if ind.generation < 0:
321
+ ind.generation = 0
322
+
323
+ return max(ind.generation for ind in inds.values()) + 1 if inds else 0
324
+
325
+
326
+ def get_ancestors(inds, ind_id):
327
+ ancestors = set()
328
+ stack = [ind_id]
329
+ visited = set()
330
+ while stack:
331
+ current = stack.pop()
332
+ if current in visited:
333
+ continue
334
+ visited.add(current)
335
+ ind = inds.get(current)
336
+ if not ind:
337
+ continue
338
+ for p in (ind.father_id, ind.mother_id):
339
+ if p and p in inds and p not in ancestors:
340
+ ancestors.add(p)
341
+ stack.append(p)
342
+ return ancestors
343
+
344
+
345
+ def is_consanguineous(inds, id1, id2):
346
+ return bool(get_ancestors(inds, id1) & get_ancestors(inds, id2))
347
+
348
+
349
+ def get_mating_unit_for_child(units, child_id):
350
+ for mk, mu in units.items():
351
+ if child_id in mu.child_ids:
352
+ return mk
353
+ return None
354
+
355
+
356
+ # ── Layout engine (from ped2studio) ───────────────────────────────────────
357
+
358
+ def compute_layout(inds, units, num_gens):
359
+ gen_members = defaultdict(list)
360
+ for ind in inds.values():
361
+ gen_members[ind.generation].append(ind.id)
362
+
363
+ placed = set()
364
+
365
+ # Gen 0
366
+ y0 = 100.0
367
+ cursor = 200.0
368
+ gen0_couples = []
369
+ gen0_coupled = set()
370
+ for mu in units.values():
371
+ f, m = mu.father_id, mu.mother_id
372
+ if f in inds and m in inds:
373
+ if inds[f].generation == 0 and inds[m].generation == 0:
374
+ pair = (min(f, m), max(f, m))
375
+ if pair[0] not in gen0_coupled:
376
+ gen0_couples.append((f, m))
377
+ gen0_coupled.add(f); gen0_coupled.add(m)
378
+
379
+ for f, m in gen0_couples:
380
+ inds[f].x, inds[f].y = cursor, y0; cursor += PARTNER_GAP
381
+ inds[m].x, inds[m].y = cursor, y0; cursor += SHAPE_SIZE + MIN_BLOCK_GAP
382
+ placed.add(f); placed.add(m)
383
+
384
+ for iid in gen_members.get(0, []):
385
+ if iid not in placed:
386
+ inds[iid].x, inds[iid].y = cursor, y0
387
+ cursor += SHAPE_SIZE + MIN_BLOCK_GAP; placed.add(iid)
388
+
389
+ # Gen 1+
390
+ for gen in range(1, num_gens):
391
+ y = 100 + gen * CHILD_HEIGHT
392
+ mu_children = {}
393
+ orphans = []
394
+ for iid in gen_members.get(gen, []):
395
+ found = get_mating_unit_for_child(units, iid)
396
+ if found:
397
+ mu_children.setdefault(found, []).append(iid)
398
+ else:
399
+ orphans.append(iid)
400
+
401
+ consang_pairs = set()
402
+ all_children = set()
403
+ for clist in mu_children.values():
404
+ all_children.update(clist)
405
+ for iid in all_children:
406
+ for pid in inds[iid].partners:
407
+ if pid in all_children:
408
+ consang_pairs.add((min(iid, pid), max(iid, pid)))
409
+
410
+ def mu_sort_key(mk):
411
+ mu = units[mk]
412
+ xs = []
413
+ if mu.father_id in inds and inds[mu.father_id].generation < gen: xs.append(inds[mu.father_id].x)
414
+ if mu.mother_id in inds and inds[mu.mother_id].generation < gen: xs.append(inds[mu.mother_id].x)
415
+ return (sum(xs) / len(xs)) if xs else 0
416
+
417
+ mu_order = sorted(mu_children.keys(), key=mu_sort_key)
418
+ used_ranges = []
419
+
420
+ for mk in mu_order:
421
+ mu = units[mk]
422
+ children = mu_children[mk]
423
+ if not children:
424
+ continue
425
+ fx = (inds[mu.father_id].x + SHAPE_HALF) if mu.father_id in inds else 0
426
+ mx = (inds[mu.mother_id].x + SHAPE_HALF) if mu.mother_id in inds else 0
427
+ parent_mid = (fx + mx) / 2
428
+
429
+ child_infos = []
430
+ for cid in children:
431
+ partner_id = None
432
+ partner_is_consang = False
433
+ for pid in inds[cid].partners:
434
+ if pid in inds and inds[pid].generation == gen and pid not in children:
435
+ partner_id = pid
436
+ partner_is_consang = (min(cid, pid), max(cid, pid)) in consang_pairs
437
+ break
438
+ child_infos.append((cid, partner_id is not None, partner_id, partner_is_consang))
439
+
440
+ partnered = [e for e in child_infos if e[1]]
441
+ unpartnered = [e for e in child_infos if not e[1]]
442
+ left_p, right_p = [], []
443
+ for entry in partnered:
444
+ cid, p, pid, pc = entry
445
+ if pc and pid and pid in placed:
446
+ (left_p if inds[pid].x < parent_mid else right_p).append(entry)
447
+ elif pc:
448
+ right_p.append(entry)
449
+ else:
450
+ right_p.append(entry)
451
+ marry_ins = [e for e in right_p if not e[3]]
452
+ consang_right = [e for e in right_p if e[3]]
453
+ if len(marry_ins) > 1:
454
+ half = len(marry_ins) // 2
455
+ left_p = left_p + marry_ins[:half]; right_p = consang_right + marry_ins[half:]
456
+ else:
457
+ right_p = consang_right + marry_ins
458
+
459
+ ordered = left_p + unpartnered + right_p
460
+ n_slots = len(ordered)
461
+ total_width = (n_slots - 1) * CHILD_SPACING if n_slots > 1 else 0
462
+ start_x = parent_mid - total_width / 2 - SHAPE_HALF
463
+ positions = []
464
+ for i, (cid, has_partner, partner_id, partner_is_consang) in enumerate(ordered):
465
+ cx = start_x + i * CHILD_SPACING
466
+ if has_partner and partner_id:
467
+ if partner_is_consang:
468
+ positions.append((cid, cx))
469
+ else:
470
+ child_centre = cx + SHAPE_HALF
471
+ if child_centre <= parent_mid:
472
+ positions.append((partner_id, cx - PARTNER_GAP)); positions.append((cid, cx))
473
+ else:
474
+ positions.append((cid, cx)); positions.append((partner_id, cx + PARTNER_GAP))
475
+ else:
476
+ positions.append((cid, cx))
477
+
478
+ if positions:
479
+ min_x = min(px for _, px in positions); max_x = max(px for _, px in positions) + SHAPE_SIZE
480
+ for rng_min, rng_max in used_ranges:
481
+ if min_x < rng_max + MIN_BLOCK_GAP and max_x > rng_min - MIN_BLOCK_GAP:
482
+ shift = rng_max + MIN_BLOCK_GAP - min_x
483
+ positions = [(pid, px + shift) for pid, px in positions]
484
+ min_x += shift; max_x += shift
485
+ used_ranges.append((min_x, max_x))
486
+ for pid, px in positions:
487
+ if pid and pid in inds:
488
+ inds[pid].x = px; inds[pid].y = y; placed.add(pid)
489
+
490
+ orphan_cursor = (used_ranges[-1][1] + MIN_BLOCK_GAP) if used_ranges else 100.0
491
+ for iid in orphans:
492
+ if iid not in placed:
493
+ inds[iid].x = orphan_cursor; inds[iid].y = y
494
+ orphan_cursor += SHAPE_SIZE + MIN_BLOCK_GAP; placed.add(iid)
495
+
496
+ if inds:
497
+ min_x_global = min(ind.x for ind in inds.values())
498
+ if min_x_global < 100:
499
+ shift = 100 - min_x_global
500
+ for ind in inds.values():
501
+ ind.x += shift
502
+
503
+
504
+ # ── Session builder ───────────────────────────────────────────────────────
505
+
506
+ def build_session(
507
+ inds: dict[str, Individual],
508
+ units: dict[str, MatingUnit],
509
+ twins: list[TwinPair],
510
+ diseases: list[str],
511
+ consanguineous_flag: bool = False,
512
+ ) -> dict:
513
+ """Build a Pedigree Studio v4 session dict."""
514
+ persons, partnerships, child_links, twin_links = [], [], [], []
515
+ legend_labels = {}
516
+
517
+ # Assign colours to diseases
518
+ disease_colors = {}
519
+ for i, d in enumerate(diseases):
520
+ disease_colors[d] = DISEASE_PALETTE[i % len(DISEASE_PALETTE)]
521
+
522
+ # ID map (ga4gh IDs may not match person-N pattern)
523
+ pid_counter = 0
524
+ id_map = {}
525
+ for ind in inds.values():
526
+ pid_counter += 1
527
+ id_map[ind.id] = f"person-{pid_counter}"
528
+
529
+ for ind in inds.values():
530
+ ps_id = id_map[ind.id]
531
+ shape = SEX_CODE_TO_SHAPE.get(ind.sex, "unknown")
532
+
533
+ # Annotation text
534
+ text_parts = []
535
+ if ind.name:
536
+ text_parts.append(ind.name)
537
+ elif ind.id:
538
+ text_parts.append(ind.id)
539
+ if ind.date_of_birth:
540
+ text_parts.append(f"b. {ind.date_of_birth}")
541
+ text = "\n".join(text_parts)
542
+
543
+ # Fill based on diseases
544
+ fill_mode = "none"
545
+ fill_color = STROKE_COLOR
546
+ halves = [None, None]
547
+ quarters = [None, None, None, None]
548
+
549
+ if len(ind.diseases) >= 4:
550
+ fill_mode = "quartered"
551
+ for qi in range(min(4, len(ind.diseases))):
552
+ quarters[qi] = disease_colors.get(ind.diseases[qi], STROKE_COLOR)
553
+ elif len(ind.diseases) >= 2:
554
+ fill_mode = "quartered"
555
+ for qi, d in enumerate(ind.diseases[:4]):
556
+ quarters[qi] = disease_colors.get(d, STROKE_COLOR)
557
+ elif len(ind.diseases) == 1:
558
+ fill_mode = "solid"
559
+ fill_color = disease_colors.get(ind.diseases[0], STROKE_COLOR)
560
+ elif ind.affected and not diseases:
561
+ # Affected but no disease labels — use default
562
+ fill_mode = "solid"
563
+ fill_color = STROKE_COLOR
564
+
565
+ persons.append({
566
+ "id": ps_id, "shape": shape,
567
+ "x": round(ind.x, 1), "y": round(ind.y, 1),
568
+ "text": text, "deceased": ind.deceased, "proband": ind.proband,
569
+ "fillMode": fill_mode, "fillColor": fill_color,
570
+ "quarters": quarters, "halves": halves,
571
+ "shadingPattern": "stripes", "shadingCoverage": "full",
572
+ "centerText": "", "miscarriageSex": "unknown",
573
+ "noSnap": False, "siblingFixed": False, "customLabel": "Lbl",
574
+ })
575
+
576
+ # Partnerships
577
+ pship_counter = 0
578
+ pship_id_map = {}
579
+ for mk, mu in units.items():
580
+ if mu.father_id in id_map and mu.mother_id in id_map:
581
+ pship_counter += 1
582
+ ps_id = f"pship-{pship_counter}"
583
+ pship_id_map[mk] = ps_id
584
+ ptype = "consanguineous" if is_consanguineous(inds, mu.father_id, mu.mother_id) else "normal"
585
+ partnerships.append({
586
+ "id": ps_id,
587
+ "fromId": id_map[mu.father_id],
588
+ "toId": id_map[mu.mother_id],
589
+ "type": ptype,
590
+ })
591
+
592
+ # Child links
593
+ cl_counter = 0
594
+ for mk, mu in units.items():
595
+ if mk not in pship_id_map:
596
+ continue
597
+ for cid in mu.child_ids:
598
+ if cid in id_map:
599
+ cl_counter += 1
600
+ child_links.append({
601
+ "id": f"child-{cl_counter}",
602
+ "partnershipId": pship_id_map[mk],
603
+ "childId": id_map[cid],
604
+ "branchYOffset": 0,
605
+ "dashed": inds[cid].is_adopted,
606
+ })
607
+
608
+ # Twin links
609
+ tl_counter = 0
610
+ for tp in twins:
611
+ if tp.child1_id in id_map and tp.child2_id in id_map:
612
+ # Find the partnership for these twins
613
+ pship_id = ""
614
+ for mk, mu in units.items():
615
+ if tp.child1_id in mu.child_ids and tp.child2_id in mu.child_ids:
616
+ pship_id = pship_id_map.get(mk, "")
617
+ break
618
+ if pship_id:
619
+ tl_counter += 1
620
+ twin_links.append({
621
+ "id": f"twin-{tl_counter}",
622
+ "child1Id": id_map[tp.child1_id],
623
+ "child2Id": id_map[tp.child2_id],
624
+ "type": tp.type,
625
+ "partnershipId": pship_id,
626
+ })
627
+
628
+ # Legend
629
+ for d_name, d_color in disease_colors.items():
630
+ any_has = any(d_name in ind.diseases for ind in inds.values())
631
+ if any_has:
632
+ legend_labels[d_color] = d_name
633
+
634
+ return {
635
+ "version": 4,
636
+ "personIdCounter": pid_counter,
637
+ "labelIdCounter": 0,
638
+ "lineIdCounter": max(pship_counter, cl_counter, tl_counter) if any([pship_counter, cl_counter, tl_counter]) else 0,
639
+ "persons": persons, "labels": [],
640
+ "partnerships": partnerships, "childLinks": child_links,
641
+ "twinLinks": twin_links,
642
+ "legendLabels": legend_labels,
643
+ "legendVisible": bool(legend_labels),
644
+ "legendX": 50, "legendY": 80,
645
+ "zoom": 1, "panX": 0, "panY": 0,
646
+ "numberingMode": "numbers",
647
+ "partnerGapMultiplier": 2, "childHeightMultiplier": 2.5,
648
+ }
649
+
650
+
651
+ # ── Public API ────────────────────────────────────────────────────────────
652
+
653
+ @dataclass
654
+ class ConversionResult:
655
+ session: dict
656
+ summary: str
657
+
658
+
659
+ def convert(
660
+ ga4gh_data: Optional[dict] = None,
661
+ phenopackets_data: Optional[dict] = None,
662
+ ) -> ConversionResult:
663
+ """Convert GA4GH Pedigree and/or Phenopackets Family to a Pedigree Studio session.
664
+
665
+ Parameters
666
+ ----------
667
+ ga4gh_data : dict, optional
668
+ Parsed GA4GH Pedigree Standard JSON.
669
+ phenopackets_data : dict, optional
670
+ Parsed Phenopackets Family JSON.
671
+
672
+ At least one must be provided. If both are provided, GA4GH is used for
673
+ structure and Phenopackets enriches with disease labels.
674
+ """
675
+ if not ga4gh_data and not phenopackets_data:
676
+ return ConversionResult(session={}, summary="No input provided.")
677
+
678
+ ga4gh_inds, ga4gh_twins = None, None
679
+ pp_inds, pp_diseases = None, None
680
+
681
+ if ga4gh_data:
682
+ ga4gh_inds, ga4gh_twins = parse_ga4gh(ga4gh_data)
683
+ if phenopackets_data:
684
+ pp_inds, pp_diseases = parse_phenopackets(phenopackets_data)
685
+
686
+ inds, twins, diseases = merge_inputs(ga4gh_inds, ga4gh_twins, pp_inds, pp_diseases)
687
+
688
+ if not inds:
689
+ return ConversionResult(session={}, summary="No individuals found in input.")
690
+
691
+ units = build_mating_units(inds)
692
+ num_gens = assign_generations(inds)
693
+ compute_layout(inds, units, num_gens)
694
+
695
+ consang = any(is_consanguineous(inds, mu.father_id, mu.mother_id)
696
+ for mu in units.values()
697
+ if mu.father_id in inds and mu.mother_id in inds)
698
+
699
+ session = build_session(inds, units, twins, diseases, consang)
700
+
701
+ sources = []
702
+ if ga4gh_data:
703
+ sources.append("GA4GH Pedigree")
704
+ if phenopackets_data:
705
+ sources.append("Phenopackets Family")
706
+
707
+ summary = (
708
+ f"Converted from {' + '.join(sources)}. "
709
+ f"{len(inds)} individuals, {len(units)} partnerships, "
710
+ f"{len(twins)} twin pairs. "
711
+ f"Diseases: {', '.join(diseases) if diseases else 'none'}."
712
+ )
713
+
714
+ return ConversionResult(session=session, summary=summary)
715
+
716
+
717
+ def convert_file(
718
+ ga4gh_path: Optional[str] = None,
719
+ phenopackets_path: Optional[str] = None,
720
+ output_path: str = "pedigree_session.json",
721
+ ) -> ConversionResult:
722
+ """Read GA4GH and/or Phenopackets files and write a Pedigree Studio session.
723
+
724
+ At least one input path must be provided.
725
+ """
726
+ ga4gh_data = None
727
+ pp_data = None
728
+
729
+ if ga4gh_path:
730
+ with open(ga4gh_path) as f:
731
+ ga4gh_data = json.load(f)
732
+ if phenopackets_path:
733
+ with open(phenopackets_path) as f:
734
+ pp_data = json.load(f)
735
+
736
+ result = convert(ga4gh_data, pp_data)
737
+
738
+ if result.session:
739
+ with open(output_path, "w") as f:
740
+ json.dump(result.session, f, indent=2)
741
+
742
+ return result
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: ga4gh2studio
3
+ Version: 0.1.0
4
+ Summary: Convert GA4GH Pedigree Standard and Phenopackets to Pedigree Studio session JSON
5
+ Author-email: Tim Hearn <tjh70@cam.ac.uk>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/comparativechrono/ga4gh2studio
8
+ Project-URL: Issues, https://github.com/comparativechrono/ga4gh2studio/issues
9
+ Keywords: pedigree,ga4gh,phenopackets,genetics,clinical-genetics,pedigree-studio
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
+ # ga4gh2studio
23
+
24
+ Convert GA4GH Pedigree Standard and Phenopackets Family files into Pedigree Studio session JSON.
25
+
26
+ The reverse complement of `studio2ga4gh`. Together they provide bidirectional interoperability between Pedigree Studio and the GA4GH ecosystem.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install ga4gh2studio
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Command line
37
+
38
+ ```bash
39
+ # From GA4GH Pedigree only
40
+ ga4gh2studio -g pedigree_ga4gh.json -o session.json
41
+
42
+ # From Phenopackets Family only
43
+ ga4gh2studio -p family_phenopackets.json -o session.json
44
+
45
+ # From both (GA4GH for structure, Phenopackets for disease labels)
46
+ ga4gh2studio -g pedigree_ga4gh.json -p family_phenopackets.json -o session.json
47
+ ```
48
+
49
+ ### Python API
50
+
51
+ ```python
52
+ import ga4gh2studio, json
53
+
54
+ # From GA4GH Pedigree
55
+ with open("pedigree_ga4gh.json") as f:
56
+ ga4gh = json.load(f)
57
+ result = ga4gh2studio.convert(ga4gh_data=ga4gh)
58
+ with open("session.json", "w") as f:
59
+ json.dump(result.session, f, indent=2)
60
+
61
+ # From both sources
62
+ with open("family_phenopackets.json") as f:
63
+ pp = json.load(f)
64
+ result = ga4gh2studio.convert(ga4gh_data=ga4gh, phenopackets_data=pp)
65
+ print(result.summary)
66
+ ```
67
+
68
+ ## Input Handling
69
+
70
+ | Input | What it provides |
71
+ |-------|-----------------|
72
+ | GA4GH Pedigree only | Full structure: parent-child (biological + adoptive), twins, sex, deceased status, proband |
73
+ | Phenopackets Family only | PED-compatible structure (parents, sex, affected status) + disease labels |
74
+ | Both (recommended) | GA4GH provides rich structure; Phenopackets enriches with disease names and vital status |
75
+
76
+ ## Feature Mapping
77
+
78
+ | GA4GH / Phenopackets | Pedigree Studio |
79
+ |---------------------|-----------------|
80
+ | KIN:027 isBiologicalMotherOf | Partnership + solid child link |
81
+ | KIN:028 isBiologicalFatherOf | Partnership + solid child link |
82
+ | KIN:022 isAdoptiveParentOf | Partnership + dashed child link |
83
+ | KIN:010 isMonozygoticTwin | Identical twin link |
84
+ | KIN:011 isDizygoticTwin | Non-identical twin link |
85
+ | indexPatients | Proband arrow |
86
+ | Individual.deceased | Deceased diagonal line |
87
+ | Individual.name / dateOfBirth | Annotation text ("Name\nb. 1985") |
88
+ | Disease term labels | Legend entries with distinct colours |
89
+ | Shared ancestors | Consanguineous partnership (double line) |
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ ga4gh2studio/__init__.py
5
+ ga4gh2studio/__main__.py
6
+ ga4gh2studio/cli.py
7
+ ga4gh2studio/converter.py
8
+ ga4gh2studio.egg-info/PKG-INFO
9
+ ga4gh2studio.egg-info/SOURCES.txt
10
+ ga4gh2studio.egg-info/dependency_links.txt
11
+ ga4gh2studio.egg-info/entry_points.txt
12
+ ga4gh2studio.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ga4gh2studio = ga4gh2studio.cli:main
@@ -0,0 +1 @@
1
+ ga4gh2studio
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ga4gh2studio"
7
+ version = "0.1.0"
8
+ description = "Convert GA4GH Pedigree Standard and Phenopackets to Pedigree Studio session JSON"
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", "pedigree-studio"]
22
+ authors = [
23
+ {name = "Tim Hearn", email = "tjh70@cam.ac.uk"},
24
+ ]
25
+
26
+ [project.scripts]
27
+ ga4gh2studio = "ga4gh2studio.cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/comparativechrono/ga4gh2studio"
31
+ Issues = "https://github.com/comparativechrono/ga4gh2studio/issues"
32
+
33
+ [tool.setuptools.packages.find]
34
+ include = ["ga4gh2studio*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+