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.
- studio2ga4gh-0.1.0/LICENSE +21 -0
- studio2ga4gh-0.1.0/PKG-INFO +103 -0
- studio2ga4gh-0.1.0/README.md +82 -0
- studio2ga4gh-0.1.0/pyproject.toml +34 -0
- studio2ga4gh-0.1.0/setup.cfg +4 -0
- studio2ga4gh-0.1.0/studio2ga4gh/__init__.py +7 -0
- studio2ga4gh-0.1.0/studio2ga4gh/__main__.py +4 -0
- studio2ga4gh-0.1.0/studio2ga4gh/cli.py +27 -0
- studio2ga4gh-0.1.0/studio2ga4gh/converter.py +622 -0
- studio2ga4gh-0.1.0/studio2ga4gh.egg-info/PKG-INFO +103 -0
- studio2ga4gh-0.1.0/studio2ga4gh.egg-info/SOURCES.txt +12 -0
- studio2ga4gh-0.1.0/studio2ga4gh.egg-info/dependency_links.txt +1 -0
- studio2ga4gh-0.1.0/studio2ga4gh.egg-info/entry_points.txt +2 -0
- studio2ga4gh-0.1.0/studio2ga4gh.egg-info/top_level.txt +1 -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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
studio2ga4gh
|