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.
- ga4gh2studio-0.1.0/LICENSE +21 -0
- ga4gh2studio-0.1.0/PKG-INFO +93 -0
- ga4gh2studio-0.1.0/README.md +72 -0
- ga4gh2studio-0.1.0/ga4gh2studio/__init__.py +4 -0
- ga4gh2studio-0.1.0/ga4gh2studio/__main__.py +4 -0
- ga4gh2studio-0.1.0/ga4gh2studio/cli.py +32 -0
- ga4gh2studio-0.1.0/ga4gh2studio/converter.py +742 -0
- ga4gh2studio-0.1.0/ga4gh2studio.egg-info/PKG-INFO +93 -0
- ga4gh2studio-0.1.0/ga4gh2studio.egg-info/SOURCES.txt +12 -0
- ga4gh2studio-0.1.0/ga4gh2studio.egg-info/dependency_links.txt +1 -0
- ga4gh2studio-0.1.0/ga4gh2studio.egg-info/entry_points.txt +2 -0
- ga4gh2studio-0.1.0/ga4gh2studio.egg-info/top_level.txt +1 -0
- ga4gh2studio-0.1.0/pyproject.toml +34 -0
- ga4gh2studio-0.1.0/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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*"]
|