studio2ped 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.
- studio2ped-0.1.0/LICENSE +21 -0
- studio2ped-0.1.0/PKG-INFO +157 -0
- studio2ped-0.1.0/README.md +131 -0
- studio2ped-0.1.0/pyproject.toml +39 -0
- studio2ped-0.1.0/setup.cfg +4 -0
- studio2ped-0.1.0/studio2ped/__init__.py +7 -0
- studio2ped-0.1.0/studio2ped/__main__.py +4 -0
- studio2ped-0.1.0/studio2ped/cli.py +54 -0
- studio2ped-0.1.0/studio2ped/converter.py +718 -0
- studio2ped-0.1.0/studio2ped.egg-info/PKG-INFO +157 -0
- studio2ped-0.1.0/studio2ped.egg-info/SOURCES.txt +12 -0
- studio2ped-0.1.0/studio2ped.egg-info/dependency_links.txt +1 -0
- studio2ped-0.1.0/studio2ped.egg-info/entry_points.txt +2 -0
- studio2ped-0.1.0/studio2ped.egg-info/top_level.txt +1 -0
studio2ped-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 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,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: studio2ped
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Pedigree Studio session JSON to PED/MPED pedigree files
|
|
5
|
+
Author-email: Tim Hearn <tjh70@cam.ac.uk>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/comparativechrono/studio2ped
|
|
8
|
+
Project-URL: Issues, https://github.com/comparativechrono/studio2ped/issues
|
|
9
|
+
Keywords: pedigree,genetics,ped,family-tree,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
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# studio2ped
|
|
28
|
+
|
|
29
|
+
Convert Pedigree Studio session JSON files into standard PED pedigree files or extended MPED (multi-phenotype PED) files.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install studio2ped
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or use the standalone script (`studio2ped_standalone.py`) with no installation required — needs only Python 3.9+.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Command line
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Basic conversion
|
|
45
|
+
studio2ped session.json
|
|
46
|
+
|
|
47
|
+
# Specify output name and directory
|
|
48
|
+
studio2ped session.json -o my_pedigree -d ./output/
|
|
49
|
+
|
|
50
|
+
# Using the standalone script
|
|
51
|
+
python studio2ped_standalone.py session.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Python API
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import studio2ped
|
|
58
|
+
|
|
59
|
+
# From a session dict
|
|
60
|
+
import json
|
|
61
|
+
with open("session.json") as f:
|
|
62
|
+
session = json.load(f)
|
|
63
|
+
|
|
64
|
+
result = studio2ped.convert(session)
|
|
65
|
+
print(result.summary)
|
|
66
|
+
for filename, content in result.files:
|
|
67
|
+
print(f"--- {filename} ---")
|
|
68
|
+
print(content)
|
|
69
|
+
|
|
70
|
+
# From file to file
|
|
71
|
+
result = studio2ped.convert_file("session.json")
|
|
72
|
+
for filepath, _ in result.files:
|
|
73
|
+
print(f"Written: {filepath}")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## What It Does
|
|
77
|
+
|
|
78
|
+
The converter reads a Pedigree Studio session JSON file and:
|
|
79
|
+
|
|
80
|
+
1. **Detects separate pedigrees** — if the canvas contains multiple unconnected family trees, each is exported as a separate file
|
|
81
|
+
2. **Resolves family structure** via graph traversal through partnerships and child links — not spatial position (so rearranged pedigrees convert correctly)
|
|
82
|
+
3. **Extracts phenotypes** from the legend/colour key: solid fills, half fills, quartered fills, and shading patterns are all recognised
|
|
83
|
+
4. **Detects carrier notation** — centre dots (●) and half-filled shapes are identified as carrier status
|
|
84
|
+
5. **Chooses the right format** automatically:
|
|
85
|
+
- **Single phenotype** → standard `.ped` file (6 columns)
|
|
86
|
+
- **Multiple phenotypes** or **carrier notation alongside a phenotype** → extended `.mped` file
|
|
87
|
+
|
|
88
|
+
## Output Formats
|
|
89
|
+
|
|
90
|
+
### Standard PED (`.ped`)
|
|
91
|
+
|
|
92
|
+
The standard 6-column format used by PLINK, GATK, and most genetics tools:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
# FamID IndID FatherID MotherID Sex Phenotype
|
|
96
|
+
FAM1 John 0 0 1 1
|
|
97
|
+
FAM1 Mary 0 0 2 1
|
|
98
|
+
FAM1 Alice John Mary 2 2
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Phenotype codes: `0` = unknown, `1` = unaffected, `2` = affected.
|
|
102
|
+
|
|
103
|
+
### Extended MPED (`.mped`)
|
|
104
|
+
|
|
105
|
+
A multi-phenotype extension for pedigrees with more than one condition tracked. The format adds a header line naming the phenotype columns and supports carrier status:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
# MPED v1 Breast_cancer Carrier
|
|
109
|
+
# FamID IndID FatherID MotherID Sex Breast_cancer Carrier
|
|
110
|
+
FAM1 John 0 0 1 1 1
|
|
111
|
+
FAM1 Mary 0 0 2 1 3
|
|
112
|
+
FAM1 Alice John Mary 2 2 1
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Phenotype codes: `0` = unknown, `1` = unaffected, `2` = affected, `3` = carrier.
|
|
116
|
+
|
|
117
|
+
The MPED format is designed to be easily parseable: the first line declares the format and phenotype column names, the second line is a human-readable column header, and data lines follow the same tab-delimited structure as standard PED with additional columns.
|
|
118
|
+
|
|
119
|
+
## Phenotype Detection
|
|
120
|
+
|
|
121
|
+
The converter maps Pedigree Studio's visual markers to phenotype status using the legend/colour key:
|
|
122
|
+
|
|
123
|
+
| Visual Marker | Legend Key Format | Status |
|
|
124
|
+
|---------------|-------------------|--------|
|
|
125
|
+
| Solid colour fill | `#rrggbb` | Affected (2) |
|
|
126
|
+
| Full shading (stripes/dots) | `shading:pattern:full` | Affected (2) |
|
|
127
|
+
| Half colour fill (one side only) | `#rrggbb` | Carrier (3) |
|
|
128
|
+
| Half shading | `shading:pattern:half-left` | Carrier (3) |
|
|
129
|
+
| Quartered fill (any quarter) | `#rrggbb` | Affected (2) |
|
|
130
|
+
| Centre dot (●) | — | Carrier (3) |
|
|
131
|
+
| No fill | — | Unaffected (1) |
|
|
132
|
+
|
|
133
|
+
Phenotype names are taken from the text the user typed into the legend (e.g., "Breast cancer", "Carrier status"). If the legend has no text for an entry, that visual marker is ignored.
|
|
134
|
+
|
|
135
|
+
## Multiple Pedigrees
|
|
136
|
+
|
|
137
|
+
If a session contains multiple unconnected pedigrees (common when a user draws several families on the same canvas), the converter detects each connected component via graph traversal and exports them as separate files:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
pedigree_1.ped # First family
|
|
141
|
+
pedigree_2.ped # Second family
|
|
142
|
+
pedigree_3.mped # Third family (if multi-phenotype)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Individual IDs
|
|
146
|
+
|
|
147
|
+
The converter generates individual IDs in priority order:
|
|
148
|
+
|
|
149
|
+
1. **Custom freetext label** (if the user edited the numbering label)
|
|
150
|
+
2. **First line of annotation text** (the text below each shape)
|
|
151
|
+
3. **Pedigree Studio internal ID** (fallback, e.g. `p-3`)
|
|
152
|
+
|
|
153
|
+
IDs are sanitised to remove whitespace and special characters.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# studio2ped
|
|
2
|
+
|
|
3
|
+
Convert Pedigree Studio session JSON files into standard PED pedigree files or extended MPED (multi-phenotype PED) files.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install studio2ped
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use the standalone script (`studio2ped_standalone.py`) with no installation required — needs only Python 3.9+.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Command line
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Basic conversion
|
|
19
|
+
studio2ped session.json
|
|
20
|
+
|
|
21
|
+
# Specify output name and directory
|
|
22
|
+
studio2ped session.json -o my_pedigree -d ./output/
|
|
23
|
+
|
|
24
|
+
# Using the standalone script
|
|
25
|
+
python studio2ped_standalone.py session.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Python API
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import studio2ped
|
|
32
|
+
|
|
33
|
+
# From a session dict
|
|
34
|
+
import json
|
|
35
|
+
with open("session.json") as f:
|
|
36
|
+
session = json.load(f)
|
|
37
|
+
|
|
38
|
+
result = studio2ped.convert(session)
|
|
39
|
+
print(result.summary)
|
|
40
|
+
for filename, content in result.files:
|
|
41
|
+
print(f"--- {filename} ---")
|
|
42
|
+
print(content)
|
|
43
|
+
|
|
44
|
+
# From file to file
|
|
45
|
+
result = studio2ped.convert_file("session.json")
|
|
46
|
+
for filepath, _ in result.files:
|
|
47
|
+
print(f"Written: {filepath}")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## What It Does
|
|
51
|
+
|
|
52
|
+
The converter reads a Pedigree Studio session JSON file and:
|
|
53
|
+
|
|
54
|
+
1. **Detects separate pedigrees** — if the canvas contains multiple unconnected family trees, each is exported as a separate file
|
|
55
|
+
2. **Resolves family structure** via graph traversal through partnerships and child links — not spatial position (so rearranged pedigrees convert correctly)
|
|
56
|
+
3. **Extracts phenotypes** from the legend/colour key: solid fills, half fills, quartered fills, and shading patterns are all recognised
|
|
57
|
+
4. **Detects carrier notation** — centre dots (●) and half-filled shapes are identified as carrier status
|
|
58
|
+
5. **Chooses the right format** automatically:
|
|
59
|
+
- **Single phenotype** → standard `.ped` file (6 columns)
|
|
60
|
+
- **Multiple phenotypes** or **carrier notation alongside a phenotype** → extended `.mped` file
|
|
61
|
+
|
|
62
|
+
## Output Formats
|
|
63
|
+
|
|
64
|
+
### Standard PED (`.ped`)
|
|
65
|
+
|
|
66
|
+
The standard 6-column format used by PLINK, GATK, and most genetics tools:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
# FamID IndID FatherID MotherID Sex Phenotype
|
|
70
|
+
FAM1 John 0 0 1 1
|
|
71
|
+
FAM1 Mary 0 0 2 1
|
|
72
|
+
FAM1 Alice John Mary 2 2
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Phenotype codes: `0` = unknown, `1` = unaffected, `2` = affected.
|
|
76
|
+
|
|
77
|
+
### Extended MPED (`.mped`)
|
|
78
|
+
|
|
79
|
+
A multi-phenotype extension for pedigrees with more than one condition tracked. The format adds a header line naming the phenotype columns and supports carrier status:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
# MPED v1 Breast_cancer Carrier
|
|
83
|
+
# FamID IndID FatherID MotherID Sex Breast_cancer Carrier
|
|
84
|
+
FAM1 John 0 0 1 1 1
|
|
85
|
+
FAM1 Mary 0 0 2 1 3
|
|
86
|
+
FAM1 Alice John Mary 2 2 1
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Phenotype codes: `0` = unknown, `1` = unaffected, `2` = affected, `3` = carrier.
|
|
90
|
+
|
|
91
|
+
The MPED format is designed to be easily parseable: the first line declares the format and phenotype column names, the second line is a human-readable column header, and data lines follow the same tab-delimited structure as standard PED with additional columns.
|
|
92
|
+
|
|
93
|
+
## Phenotype Detection
|
|
94
|
+
|
|
95
|
+
The converter maps Pedigree Studio's visual markers to phenotype status using the legend/colour key:
|
|
96
|
+
|
|
97
|
+
| Visual Marker | Legend Key Format | Status |
|
|
98
|
+
|---------------|-------------------|--------|
|
|
99
|
+
| Solid colour fill | `#rrggbb` | Affected (2) |
|
|
100
|
+
| Full shading (stripes/dots) | `shading:pattern:full` | Affected (2) |
|
|
101
|
+
| Half colour fill (one side only) | `#rrggbb` | Carrier (3) |
|
|
102
|
+
| Half shading | `shading:pattern:half-left` | Carrier (3) |
|
|
103
|
+
| Quartered fill (any quarter) | `#rrggbb` | Affected (2) |
|
|
104
|
+
| Centre dot (●) | — | Carrier (3) |
|
|
105
|
+
| No fill | — | Unaffected (1) |
|
|
106
|
+
|
|
107
|
+
Phenotype names are taken from the text the user typed into the legend (e.g., "Breast cancer", "Carrier status"). If the legend has no text for an entry, that visual marker is ignored.
|
|
108
|
+
|
|
109
|
+
## Multiple Pedigrees
|
|
110
|
+
|
|
111
|
+
If a session contains multiple unconnected pedigrees (common when a user draws several families on the same canvas), the converter detects each connected component via graph traversal and exports them as separate files:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
pedigree_1.ped # First family
|
|
115
|
+
pedigree_2.ped # Second family
|
|
116
|
+
pedigree_3.mped # Third family (if multi-phenotype)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Individual IDs
|
|
120
|
+
|
|
121
|
+
The converter generates individual IDs in priority order:
|
|
122
|
+
|
|
123
|
+
1. **Custom freetext label** (if the user edited the numbering label)
|
|
124
|
+
2. **First line of annotation text** (the text below each shape)
|
|
125
|
+
3. **Pedigree Studio internal ID** (fallback, e.g. `p-3`)
|
|
126
|
+
|
|
127
|
+
IDs are sanitised to remove whitespace and special characters.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "studio2ped"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Convert Pedigree Studio session JSON to PED/MPED pedigree files"
|
|
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
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
keywords = ["pedigree", "genetics", "ped", "family-tree", "clinical-genetics", "pedigree-studio"]
|
|
27
|
+
authors = [
|
|
28
|
+
{name = "Tim Hearn", email = "tjh70@cam.ac.uk"},
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
studio2ped = "studio2ped.cli:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/comparativechrono/studio2ped"
|
|
36
|
+
Issues = "https://github.com/comparativechrono/studio2ped/issues"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
include = ["studio2ped*"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""studio2ped — Convert Pedigree Studio session JSON to PED/MPED pedigree files."""
|
|
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,54 @@
|
|
|
1
|
+
"""Command-line interface for studio2ped."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .converter import convert_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="studio2ped",
|
|
14
|
+
description="Convert Pedigree Studio session JSON to PED/MPED pedigree files.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"input",
|
|
18
|
+
help="Path to the input Pedigree Studio session .json file",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"-o", "--output-prefix",
|
|
22
|
+
default=None,
|
|
23
|
+
dest="output_prefix",
|
|
24
|
+
help="Base name for output files (default: input filename stem)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-d", "--output-dir",
|
|
28
|
+
default=None,
|
|
29
|
+
dest="output_dir",
|
|
30
|
+
help="Directory for output files (default: same as input file)",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
result = convert_file(
|
|
37
|
+
args.input,
|
|
38
|
+
output_prefix=args.output_prefix,
|
|
39
|
+
output_dir=args.output_dir,
|
|
40
|
+
)
|
|
41
|
+
print(result.summary)
|
|
42
|
+
for filepath, _ in result.files:
|
|
43
|
+
print(f" Written: {filepath}")
|
|
44
|
+
return 0
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
except Exception as e:
|
|
49
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"""
|
|
2
|
+
studio2ped - Convert Pedigree Studio session JSON to PED/MPED pedigree files.
|
|
3
|
+
|
|
4
|
+
Core converter module: parses Pedigree Studio session data, detects separate
|
|
5
|
+
pedigrees via graph traversal, extracts phenotypes from visual markers and
|
|
6
|
+
legend labels, and emits standard PED files (single phenotype) or extended
|
|
7
|
+
MPED files (multiple phenotypes).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
# ── PED phenotype codes ───────────────────────────────────────────────────
|
|
22
|
+
PHENO_UNKNOWN = 0
|
|
23
|
+
PHENO_UNAFFECTED = 1
|
|
24
|
+
PHENO_AFFECTED = 2
|
|
25
|
+
PHENO_CARRIER = 3 # MPED only
|
|
26
|
+
|
|
27
|
+
# ── Sex codes ─────────────────────────────────────────────────────────────
|
|
28
|
+
SHAPE_TO_SEX = {
|
|
29
|
+
"male": 1, "female": 2, "unknown": 0,
|
|
30
|
+
"adopted-male": 1, "adopted-female": 2, "adopted-unknown": 0,
|
|
31
|
+
"miscarriage": 0, "no-offspring": 0, "infertile": 0,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Data models ───────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Person:
|
|
39
|
+
"""A person extracted from a Pedigree Studio session."""
|
|
40
|
+
id: str
|
|
41
|
+
shape: str
|
|
42
|
+
sex: int
|
|
43
|
+
text: str # annotation text (may contain name, dob, etc.)
|
|
44
|
+
fill_mode: str # 'none'|'solid'|'half'|'quartered'|'shading'
|
|
45
|
+
fill_color: Optional[str] # hex colour for solid fill
|
|
46
|
+
halves: list # [left_colour|None, right_colour|None]
|
|
47
|
+
quarters: list # [TR, BR, BL, TL] colours or None
|
|
48
|
+
shading_pattern: str # 'stripes'|'dots'
|
|
49
|
+
shading_coverage: str # 'full'|'half-left'|'half-right'
|
|
50
|
+
center_text: str # 'S'|'P'|'D'|'dot'|''
|
|
51
|
+
deceased: bool
|
|
52
|
+
custom_label: str
|
|
53
|
+
father_id: Optional[str] = None # resolved during graph analysis
|
|
54
|
+
mother_id: Optional[str] = None
|
|
55
|
+
family_id: Optional[str] = None
|
|
56
|
+
generation: int = -1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Partnership:
|
|
61
|
+
"""A partnership connection."""
|
|
62
|
+
id: str
|
|
63
|
+
from_id: str
|
|
64
|
+
to_id: str
|
|
65
|
+
type: str # 'normal'|'consanguineous'|'separated'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ChildLink:
|
|
70
|
+
"""A parent-child connection."""
|
|
71
|
+
id: str
|
|
72
|
+
partnership_id: str
|
|
73
|
+
child_id: str
|
|
74
|
+
dashed: bool # dashed = adoption
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Phenotype:
|
|
79
|
+
"""A phenotype derived from the legend/visual markers."""
|
|
80
|
+
name: str # legend label text, e.g. "Breast cancer"
|
|
81
|
+
key: str # legend key, e.g. "#c0392b" or "shading:stripes:full"
|
|
82
|
+
is_shading: bool # True if this is a shading pattern phenotype
|
|
83
|
+
color: Optional[str] # hex colour (for colour-based phenotypes)
|
|
84
|
+
pattern: Optional[str] # 'stripes'|'dots' (for shading phenotypes)
|
|
85
|
+
coverage: Optional[str] # 'full'|'half-left'|'half-right' (for shading)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Family:
|
|
90
|
+
"""A connected pedigree (one connected component of the graph)."""
|
|
91
|
+
family_id: str
|
|
92
|
+
person_ids: set
|
|
93
|
+
persons: dict # id -> Person
|
|
94
|
+
partnerships: list # Partnership objects
|
|
95
|
+
child_links: list # ChildLink objects
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── Session parser ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def parse_session(data: dict) -> tuple[dict[str, Person], list[Partnership], list[ChildLink], dict]:
|
|
101
|
+
"""Parse a Pedigree Studio session JSON into internal data structures.
|
|
102
|
+
|
|
103
|
+
Returns (persons, partnerships, child_links, legend_labels).
|
|
104
|
+
"""
|
|
105
|
+
persons = {}
|
|
106
|
+
for pd in data.get("persons", []):
|
|
107
|
+
pid = pd["id"]
|
|
108
|
+
persons[pid] = Person(
|
|
109
|
+
id=pid,
|
|
110
|
+
shape=pd.get("shape", "unknown"),
|
|
111
|
+
sex=SHAPE_TO_SEX.get(pd.get("shape", "unknown"), 0),
|
|
112
|
+
text=pd.get("text", ""),
|
|
113
|
+
fill_mode=pd.get("fillMode", "none"),
|
|
114
|
+
fill_color=pd.get("fillColor"),
|
|
115
|
+
halves=pd.get("halves", [None, None]),
|
|
116
|
+
quarters=pd.get("quarters", [None, None, None, None]),
|
|
117
|
+
shading_pattern=pd.get("shadingPattern", "stripes"),
|
|
118
|
+
shading_coverage=pd.get("shadingCoverage", "full"),
|
|
119
|
+
center_text=pd.get("centerText", ""),
|
|
120
|
+
deceased=pd.get("deceased", False),
|
|
121
|
+
custom_label=pd.get("customLabel", ""),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
partnerships = []
|
|
125
|
+
for pd in data.get("partnerships", []):
|
|
126
|
+
partnerships.append(Partnership(
|
|
127
|
+
id=pd["id"],
|
|
128
|
+
from_id=pd["fromId"],
|
|
129
|
+
to_id=pd["toId"],
|
|
130
|
+
type=pd.get("type", "normal"),
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
child_links = []
|
|
134
|
+
for cd in data.get("childLinks", []):
|
|
135
|
+
child_links.append(ChildLink(
|
|
136
|
+
id=cd["id"],
|
|
137
|
+
partnership_id=cd["partnershipId"],
|
|
138
|
+
child_id=cd["childId"],
|
|
139
|
+
dashed=cd.get("dashed", False),
|
|
140
|
+
))
|
|
141
|
+
|
|
142
|
+
legend_labels = data.get("legendLabels", {})
|
|
143
|
+
|
|
144
|
+
return persons, partnerships, child_links, legend_labels
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Graph analysis ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def detect_families(
|
|
150
|
+
persons: dict[str, Person],
|
|
151
|
+
partnerships: list[Partnership],
|
|
152
|
+
child_links: list[ChildLink],
|
|
153
|
+
) -> list[Family]:
|
|
154
|
+
"""Detect separate pedigrees (connected components) via BFS graph traversal.
|
|
155
|
+
|
|
156
|
+
Uses partnership and child link connections — not spatial position.
|
|
157
|
+
Returns a list of Family objects, each containing its persons, partnerships,
|
|
158
|
+
and child links.
|
|
159
|
+
"""
|
|
160
|
+
# Build adjacency: person_id -> set of connected person_ids
|
|
161
|
+
adj: dict[str, set[str]] = defaultdict(set)
|
|
162
|
+
|
|
163
|
+
# Partnership ID -> Partnership object
|
|
164
|
+
pship_map: dict[str, Partnership] = {p.id: p for p in partnerships}
|
|
165
|
+
|
|
166
|
+
# Build adjacency from partnerships
|
|
167
|
+
for p in partnerships:
|
|
168
|
+
if p.from_id in persons and p.to_id in persons:
|
|
169
|
+
adj[p.from_id].add(p.to_id)
|
|
170
|
+
adj[p.to_id].add(p.from_id)
|
|
171
|
+
|
|
172
|
+
# Build adjacency from child links (child ↔ both parents)
|
|
173
|
+
for cl in child_links:
|
|
174
|
+
pship = pship_map.get(cl.partnership_id)
|
|
175
|
+
if pship and cl.child_id in persons:
|
|
176
|
+
if pship.from_id in persons:
|
|
177
|
+
adj[cl.child_id].add(pship.from_id)
|
|
178
|
+
adj[pship.from_id].add(cl.child_id)
|
|
179
|
+
if pship.to_id in persons:
|
|
180
|
+
adj[cl.child_id].add(pship.to_id)
|
|
181
|
+
adj[pship.to_id].add(cl.child_id)
|
|
182
|
+
|
|
183
|
+
# BFS connected components
|
|
184
|
+
visited: set[str] = set()
|
|
185
|
+
families: list[Family] = []
|
|
186
|
+
fam_counter = 0
|
|
187
|
+
|
|
188
|
+
for pid in persons:
|
|
189
|
+
if pid in visited:
|
|
190
|
+
continue
|
|
191
|
+
# BFS from this person
|
|
192
|
+
fam_counter += 1
|
|
193
|
+
component: set[str] = set()
|
|
194
|
+
queue = [pid]
|
|
195
|
+
while queue:
|
|
196
|
+
current = queue.pop(0)
|
|
197
|
+
if current in visited:
|
|
198
|
+
continue
|
|
199
|
+
visited.add(current)
|
|
200
|
+
component.add(current)
|
|
201
|
+
for neighbour in adj.get(current, set()):
|
|
202
|
+
if neighbour not in visited:
|
|
203
|
+
queue.append(neighbour)
|
|
204
|
+
|
|
205
|
+
# Collect partnerships and child links for this component
|
|
206
|
+
fam_partnerships = [p for p in partnerships
|
|
207
|
+
if p.from_id in component or p.to_id in component]
|
|
208
|
+
fam_child_links = [cl for cl in child_links
|
|
209
|
+
if cl.child_id in component]
|
|
210
|
+
|
|
211
|
+
fam_persons = {pid: persons[pid] for pid in component}
|
|
212
|
+
fam_id = f"FAM{fam_counter}"
|
|
213
|
+
|
|
214
|
+
families.append(Family(
|
|
215
|
+
family_id=fam_id,
|
|
216
|
+
person_ids=component,
|
|
217
|
+
persons=fam_persons,
|
|
218
|
+
partnerships=fam_partnerships,
|
|
219
|
+
child_links=fam_child_links,
|
|
220
|
+
))
|
|
221
|
+
|
|
222
|
+
return families
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def resolve_parents(family: Family):
|
|
226
|
+
"""For each person in the family, determine their father_id and mother_id
|
|
227
|
+
from the child link and partnership graph. Sets father_id and mother_id
|
|
228
|
+
on each Person object."""
|
|
229
|
+
pship_map = {p.id: p for p in family.partnerships}
|
|
230
|
+
|
|
231
|
+
for cl in family.child_links:
|
|
232
|
+
pship = pship_map.get(cl.partnership_id)
|
|
233
|
+
if not pship:
|
|
234
|
+
continue
|
|
235
|
+
child = family.persons.get(cl.child_id)
|
|
236
|
+
if not child:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Determine which partner is father and which is mother
|
|
240
|
+
p1 = family.persons.get(pship.from_id)
|
|
241
|
+
p2 = family.persons.get(pship.to_id)
|
|
242
|
+
if not p1 or not p2:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if p1.sex == 1 and p2.sex == 2:
|
|
246
|
+
child.father_id = p1.id
|
|
247
|
+
child.mother_id = p2.id
|
|
248
|
+
elif p1.sex == 2 and p2.sex == 1:
|
|
249
|
+
child.father_id = p2.id
|
|
250
|
+
child.mother_id = p1.id
|
|
251
|
+
else:
|
|
252
|
+
# Same sex or unknown — use from_id as father, to_id as mother
|
|
253
|
+
# (follows the convention used by Pedigree Studio)
|
|
254
|
+
child.father_id = pship.from_id
|
|
255
|
+
child.mother_id = pship.to_id
|
|
256
|
+
|
|
257
|
+
# Set family_id on all persons
|
|
258
|
+
for p in family.persons.values():
|
|
259
|
+
p.family_id = family.family_id
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def assign_generations(family: Family):
|
|
263
|
+
"""Assign generation numbers to all persons in the family via graph
|
|
264
|
+
traversal. Founders (no parents in the family) get generation 0.
|
|
265
|
+
Uses iterative propagation identical to ped2studio."""
|
|
266
|
+
persons = family.persons
|
|
267
|
+
|
|
268
|
+
# Pass 1: founders
|
|
269
|
+
for p in persons.values():
|
|
270
|
+
has_father = p.father_id and p.father_id in persons
|
|
271
|
+
has_mother = p.mother_id and p.mother_id in persons
|
|
272
|
+
if not has_father and not has_mother:
|
|
273
|
+
p.generation = 0
|
|
274
|
+
|
|
275
|
+
# Pass 2: propagate
|
|
276
|
+
changed = True
|
|
277
|
+
while changed:
|
|
278
|
+
changed = False
|
|
279
|
+
for p in persons.values():
|
|
280
|
+
if p.generation >= 0:
|
|
281
|
+
continue
|
|
282
|
+
fg = persons[p.father_id].generation if (p.father_id and p.father_id in persons) else -1
|
|
283
|
+
mg = persons[p.mother_id].generation if (p.mother_id and p.mother_id in persons) else -1
|
|
284
|
+
if fg >= 0 and mg >= 0:
|
|
285
|
+
p.generation = max(fg, mg) + 1
|
|
286
|
+
changed = True
|
|
287
|
+
elif fg >= 0 and not (p.mother_id and p.mother_id in persons):
|
|
288
|
+
p.generation = fg + 1
|
|
289
|
+
changed = True
|
|
290
|
+
elif mg >= 0 and not (p.father_id and p.father_id in persons):
|
|
291
|
+
p.generation = mg + 1
|
|
292
|
+
changed = True
|
|
293
|
+
|
|
294
|
+
# Pass 3: marry-in correction
|
|
295
|
+
# Build partner lookup
|
|
296
|
+
partner_of: dict[str, list[str]] = defaultdict(list)
|
|
297
|
+
for pship in family.partnerships:
|
|
298
|
+
if pship.from_id in persons and pship.to_id in persons:
|
|
299
|
+
partner_of[pship.from_id].append(pship.to_id)
|
|
300
|
+
partner_of[pship.to_id].append(pship.from_id)
|
|
301
|
+
|
|
302
|
+
for p in persons.values():
|
|
303
|
+
if p.generation >= 0:
|
|
304
|
+
has_parent = (p.father_id and p.father_id in persons) or \
|
|
305
|
+
(p.mother_id and p.mother_id in persons)
|
|
306
|
+
if has_parent:
|
|
307
|
+
continue
|
|
308
|
+
for pid in partner_of.get(p.id, []):
|
|
309
|
+
partner = persons.get(pid)
|
|
310
|
+
if partner and partner.generation > p.generation:
|
|
311
|
+
p.generation = partner.generation
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
# Fallback
|
|
315
|
+
for p in persons.values():
|
|
316
|
+
if p.generation < 0:
|
|
317
|
+
p.generation = 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── Phenotype extraction ──────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
def extract_phenotypes(legend_labels: dict) -> list[Phenotype]:
|
|
323
|
+
"""Extract phenotype definitions from the legend labels.
|
|
324
|
+
|
|
325
|
+
Legend keys can be:
|
|
326
|
+
- Hex colour strings: '#c0392b' → colour-based phenotype
|
|
327
|
+
- Shading key strings: 'shading:stripes:full' → shading-based phenotype
|
|
328
|
+
|
|
329
|
+
Returns a list of Phenotype objects.
|
|
330
|
+
"""
|
|
331
|
+
phenotypes = []
|
|
332
|
+
for key, label in legend_labels.items():
|
|
333
|
+
if not label or not label.strip():
|
|
334
|
+
continue # skip unlabelled entries
|
|
335
|
+
|
|
336
|
+
label = label.strip()
|
|
337
|
+
|
|
338
|
+
if key.startswith("shading:"):
|
|
339
|
+
# Parse shading key: "shading:{pattern}:{coverage}"
|
|
340
|
+
parts = key.split(":")
|
|
341
|
+
pattern = parts[1] if len(parts) > 1 else "stripes"
|
|
342
|
+
coverage = parts[2] if len(parts) > 2 else "full"
|
|
343
|
+
phenotypes.append(Phenotype(
|
|
344
|
+
name=label, key=key, is_shading=True,
|
|
345
|
+
color=None, pattern=pattern, coverage=coverage,
|
|
346
|
+
))
|
|
347
|
+
else:
|
|
348
|
+
# Colour-based phenotype
|
|
349
|
+
phenotypes.append(Phenotype(
|
|
350
|
+
name=label, key=key, is_shading=False,
|
|
351
|
+
color=key, pattern=None, coverage=None,
|
|
352
|
+
))
|
|
353
|
+
|
|
354
|
+
return phenotypes
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def classify_person_phenotypes(
|
|
358
|
+
person: Person,
|
|
359
|
+
phenotypes: list[Phenotype],
|
|
360
|
+
) -> dict[str, int]:
|
|
361
|
+
"""Determine which phenotypes a person expresses and whether they are
|
|
362
|
+
affected or carrier for each.
|
|
363
|
+
|
|
364
|
+
Returns a dict of phenotype_name -> status code:
|
|
365
|
+
0 = unknown/not applicable
|
|
366
|
+
1 = unaffected
|
|
367
|
+
2 = affected
|
|
368
|
+
3 = carrier
|
|
369
|
+
|
|
370
|
+
Logic:
|
|
371
|
+
- Solid fill matching a colour phenotype → affected (2)
|
|
372
|
+
- Full shading matching a shading phenotype → affected (2)
|
|
373
|
+
- Half fill with one half matching a colour phenotype → carrier (3)
|
|
374
|
+
- Half shading matching a shading phenotype with half coverage → carrier (3)
|
|
375
|
+
- Quartered fill with any quarter matching a colour phenotype → affected (2)
|
|
376
|
+
- Centre dot ('dot') → add carrier status for all phenotypes that person has
|
|
377
|
+
- No matching fill → unaffected (1)
|
|
378
|
+
"""
|
|
379
|
+
results = {ph.name: PHENO_UNAFFECTED for ph in phenotypes}
|
|
380
|
+
|
|
381
|
+
# Collect all colour markers on this person
|
|
382
|
+
person_colours: set[str] = set()
|
|
383
|
+
is_half_colour: dict[str, bool] = {} # colour -> True if only in a half fill
|
|
384
|
+
|
|
385
|
+
if person.fill_mode == "solid" and person.fill_color:
|
|
386
|
+
person_colours.add(person.fill_color)
|
|
387
|
+
is_half_colour[person.fill_color] = False
|
|
388
|
+
|
|
389
|
+
elif person.fill_mode == "half":
|
|
390
|
+
for i, c in enumerate(person.halves or []):
|
|
391
|
+
if c:
|
|
392
|
+
person_colours.add(c)
|
|
393
|
+
# If only one half is filled, it's a carrier indication
|
|
394
|
+
other = person.halves[1 - i] if person.halves else None
|
|
395
|
+
if not other:
|
|
396
|
+
is_half_colour[c] = True
|
|
397
|
+
else:
|
|
398
|
+
is_half_colour[c] = False
|
|
399
|
+
|
|
400
|
+
elif person.fill_mode == "quartered":
|
|
401
|
+
for c in (person.quarters or []):
|
|
402
|
+
if c:
|
|
403
|
+
person_colours.add(c)
|
|
404
|
+
is_half_colour[c] = False # quartered = affected
|
|
405
|
+
|
|
406
|
+
# Match colour phenotypes
|
|
407
|
+
for ph in phenotypes:
|
|
408
|
+
if ph.is_shading:
|
|
409
|
+
continue
|
|
410
|
+
if ph.color and ph.color in person_colours:
|
|
411
|
+
if is_half_colour.get(ph.color, False):
|
|
412
|
+
results[ph.name] = PHENO_CARRIER
|
|
413
|
+
else:
|
|
414
|
+
results[ph.name] = PHENO_AFFECTED
|
|
415
|
+
|
|
416
|
+
# Match shading phenotypes
|
|
417
|
+
if person.fill_mode == "shading":
|
|
418
|
+
person_shading_key = f"shading:{person.shading_pattern}:{person.shading_coverage}"
|
|
419
|
+
for ph in phenotypes:
|
|
420
|
+
if not ph.is_shading:
|
|
421
|
+
continue
|
|
422
|
+
if ph.key == person_shading_key:
|
|
423
|
+
if person.shading_coverage in ("half-left", "half-right"):
|
|
424
|
+
results[ph.name] = PHENO_CARRIER
|
|
425
|
+
else:
|
|
426
|
+
results[ph.name] = PHENO_AFFECTED
|
|
427
|
+
# Also check if the pattern matches but coverage differs
|
|
428
|
+
# (e.g. legend says "shading:stripes:full" but person has "shading:stripes:half-left")
|
|
429
|
+
elif ph.pattern == person.shading_pattern:
|
|
430
|
+
full_key = f"shading:{person.shading_pattern}:full"
|
|
431
|
+
if ph.key == full_key and person.shading_coverage in ("half-left", "half-right"):
|
|
432
|
+
results[ph.name] = PHENO_CARRIER
|
|
433
|
+
|
|
434
|
+
# Centre dot overrides: if person has a carrier dot and any phenotype
|
|
435
|
+
# is marked affected, downgrade to carrier. If no phenotype is marked,
|
|
436
|
+
# mark the first phenotype as carrier.
|
|
437
|
+
if person.center_text == "dot":
|
|
438
|
+
has_any = any(v >= PHENO_AFFECTED for v in results.values())
|
|
439
|
+
if has_any:
|
|
440
|
+
for name in results:
|
|
441
|
+
if results[name] == PHENO_AFFECTED:
|
|
442
|
+
results[name] = PHENO_CARRIER
|
|
443
|
+
elif phenotypes:
|
|
444
|
+
results[phenotypes[0].name] = PHENO_CARRIER
|
|
445
|
+
|
|
446
|
+
return results
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def has_carrier_notation(persons: dict[str, Person]) -> bool:
|
|
450
|
+
"""Check if any person in the pedigree uses carrier notation
|
|
451
|
+
(centre dot, or half fill/shading with one side empty)."""
|
|
452
|
+
for p in persons.values():
|
|
453
|
+
if p.center_text == "dot":
|
|
454
|
+
return True
|
|
455
|
+
if p.fill_mode == "half":
|
|
456
|
+
filled = sum(1 for h in (p.halves or []) if h)
|
|
457
|
+
if filled == 1:
|
|
458
|
+
return True
|
|
459
|
+
if p.fill_mode == "shading" and p.shading_coverage in ("half-left", "half-right"):
|
|
460
|
+
return True
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ── ID generation ─────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
def make_individual_id(person: Person) -> str:
|
|
467
|
+
"""Generate a human-readable individual ID from the person's data.
|
|
468
|
+
|
|
469
|
+
Priority: custom label → annotation text first line → person ID.
|
|
470
|
+
Sanitises to remove whitespace and special characters.
|
|
471
|
+
"""
|
|
472
|
+
# Try custom label
|
|
473
|
+
if person.custom_label and person.custom_label.strip() and person.custom_label.strip() != "Lbl":
|
|
474
|
+
raw = person.custom_label.strip()
|
|
475
|
+
# Try first line of annotation text
|
|
476
|
+
elif person.text and person.text.strip():
|
|
477
|
+
raw = person.text.strip().split("\n")[0].strip()
|
|
478
|
+
else:
|
|
479
|
+
raw = person.id
|
|
480
|
+
|
|
481
|
+
# Sanitise: replace whitespace with underscore, remove non-alphanumeric except _-
|
|
482
|
+
sanitised = re.sub(r"\s+", "_", raw)
|
|
483
|
+
sanitised = re.sub(r"[^\w\-]", "", sanitised)
|
|
484
|
+
if not sanitised:
|
|
485
|
+
sanitised = person.id
|
|
486
|
+
return sanitised
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── PED/MPED output ──────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
def build_standard_ped(family: Family, phenotypes: list[Phenotype]) -> str:
|
|
492
|
+
"""Build a standard 6-column PED file for a single-phenotype family.
|
|
493
|
+
|
|
494
|
+
If there's exactly one phenotype, affected=2, carrier/unaffected=1.
|
|
495
|
+
If there are no phenotypes, all get phenotype 1 (unaffected).
|
|
496
|
+
"""
|
|
497
|
+
lines = [f"# Family: {family.family_id}"]
|
|
498
|
+
lines.append("# FamID\tIndID\tFatherID\tMotherID\tSex\tPhenotype")
|
|
499
|
+
|
|
500
|
+
# Sort by generation then ID for consistent output
|
|
501
|
+
sorted_persons = sorted(family.persons.values(),
|
|
502
|
+
key=lambda p: (p.generation, p.id))
|
|
503
|
+
|
|
504
|
+
id_map = {p.id: make_individual_id(p) for p in sorted_persons}
|
|
505
|
+
|
|
506
|
+
for p in sorted_persons:
|
|
507
|
+
ind_id = id_map[p.id]
|
|
508
|
+
father = id_map.get(p.father_id, "0") if p.father_id else "0"
|
|
509
|
+
mother = id_map.get(p.mother_id, "0") if p.mother_id else "0"
|
|
510
|
+
|
|
511
|
+
# Determine phenotype
|
|
512
|
+
if phenotypes:
|
|
513
|
+
statuses = classify_person_phenotypes(p, phenotypes)
|
|
514
|
+
# For standard PED: affected if any phenotype is AFFECTED
|
|
515
|
+
max_status = max(statuses.values()) if statuses else PHENO_UNAFFECTED
|
|
516
|
+
if max_status == PHENO_AFFECTED:
|
|
517
|
+
pheno = PHENO_AFFECTED
|
|
518
|
+
else:
|
|
519
|
+
pheno = PHENO_UNAFFECTED
|
|
520
|
+
else:
|
|
521
|
+
pheno = PHENO_UNAFFECTED
|
|
522
|
+
|
|
523
|
+
lines.append(f"{family.family_id}\t{ind_id}\t{father}\t{mother}\t{p.sex}\t{pheno}")
|
|
524
|
+
|
|
525
|
+
return "\n".join(lines) + "\n"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def build_mped(family: Family, phenotypes: list[Phenotype], include_carrier: bool) -> str:
|
|
529
|
+
"""Build an MPED (multi-phenotype PED) file.
|
|
530
|
+
|
|
531
|
+
Format:
|
|
532
|
+
- Header line: # MPED v1 <tab-separated phenotype names>
|
|
533
|
+
- Column header: # FamID IndID FatherID MotherID Sex <phenotype1> <phenotype2> ...
|
|
534
|
+
- Data lines: standard PED columns + one column per phenotype
|
|
535
|
+
|
|
536
|
+
Phenotype codes: 0=unknown, 1=unaffected, 2=affected, 3=carrier
|
|
537
|
+
"""
|
|
538
|
+
# Build phenotype column names (sanitise for header)
|
|
539
|
+
col_names = [_sanitise_column_name(ph.name) for ph in phenotypes]
|
|
540
|
+
|
|
541
|
+
# Add a Carrier column if carrier notation is present and not already a phenotype
|
|
542
|
+
carrier_col = None
|
|
543
|
+
if include_carrier and not any(n.lower() == "carrier" for n in col_names):
|
|
544
|
+
carrier_col = "Carrier"
|
|
545
|
+
col_names.append(carrier_col)
|
|
546
|
+
|
|
547
|
+
lines = [f"# MPED v1\t" + "\t".join(col_names)]
|
|
548
|
+
lines.append("# FamID\tIndID\tFatherID\tMotherID\tSex\t" + "\t".join(col_names))
|
|
549
|
+
|
|
550
|
+
sorted_persons = sorted(family.persons.values(),
|
|
551
|
+
key=lambda p: (p.generation, p.id))
|
|
552
|
+
|
|
553
|
+
id_map = {p.id: make_individual_id(p) for p in sorted_persons}
|
|
554
|
+
|
|
555
|
+
for p in sorted_persons:
|
|
556
|
+
ind_id = id_map[p.id]
|
|
557
|
+
father = id_map.get(p.father_id, "0") if p.father_id else "0"
|
|
558
|
+
mother = id_map.get(p.mother_id, "0") if p.mother_id else "0"
|
|
559
|
+
|
|
560
|
+
statuses = classify_person_phenotypes(p, phenotypes)
|
|
561
|
+
pheno_values = [str(statuses.get(ph.name, PHENO_UNAFFECTED)) for ph in phenotypes]
|
|
562
|
+
|
|
563
|
+
# Carrier column (from centre dot, independent of other phenotypes)
|
|
564
|
+
if carrier_col:
|
|
565
|
+
if p.center_text == "dot":
|
|
566
|
+
pheno_values.append(str(PHENO_CARRIER))
|
|
567
|
+
else:
|
|
568
|
+
pheno_values.append(str(PHENO_UNAFFECTED))
|
|
569
|
+
|
|
570
|
+
line = f"{family.family_id}\t{ind_id}\t{father}\t{mother}\t{p.sex}"
|
|
571
|
+
line += "\t" + "\t".join(pheno_values)
|
|
572
|
+
lines.append(line)
|
|
573
|
+
|
|
574
|
+
return "\n".join(lines) + "\n"
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _sanitise_column_name(name: str) -> str:
|
|
578
|
+
"""Sanitise a phenotype name for use as a column header."""
|
|
579
|
+
s = re.sub(r"\s+", "_", name.strip())
|
|
580
|
+
s = re.sub(r"[^\w\-]", "", s)
|
|
581
|
+
return s or "Phenotype"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
@dataclass
|
|
587
|
+
class ConversionResult:
|
|
588
|
+
"""Result of a conversion, containing one or more output files."""
|
|
589
|
+
files: list # list of (filename, content) tuples
|
|
590
|
+
families: list[Family]
|
|
591
|
+
phenotypes: list[Phenotype]
|
|
592
|
+
is_multi_phenotype: bool
|
|
593
|
+
summary: str
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def convert(
|
|
597
|
+
session_data: dict,
|
|
598
|
+
output_prefix: str = "pedigree",
|
|
599
|
+
) -> ConversionResult:
|
|
600
|
+
"""Convert a Pedigree Studio session dict to PED/MPED file(s).
|
|
601
|
+
|
|
602
|
+
Parameters
|
|
603
|
+
----------
|
|
604
|
+
session_data : dict
|
|
605
|
+
Parsed Pedigree Studio session JSON.
|
|
606
|
+
output_prefix : str
|
|
607
|
+
Base name for output files (without extension).
|
|
608
|
+
|
|
609
|
+
Returns
|
|
610
|
+
-------
|
|
611
|
+
ConversionResult
|
|
612
|
+
Contains the generated file contents and metadata.
|
|
613
|
+
"""
|
|
614
|
+
persons, partnerships, child_links, legend_labels = parse_session(session_data)
|
|
615
|
+
|
|
616
|
+
if not persons:
|
|
617
|
+
return ConversionResult(
|
|
618
|
+
files=[], families=[], phenotypes=[],
|
|
619
|
+
is_multi_phenotype=False, summary="No persons found in session.",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# Detect families (connected components)
|
|
623
|
+
families = detect_families(persons, partnerships, child_links)
|
|
624
|
+
|
|
625
|
+
# Resolve parent-child relationships and generations for each family
|
|
626
|
+
for fam in families:
|
|
627
|
+
resolve_parents(fam)
|
|
628
|
+
assign_generations(fam)
|
|
629
|
+
|
|
630
|
+
# Extract phenotypes from legend
|
|
631
|
+
phenotypes = extract_phenotypes(legend_labels)
|
|
632
|
+
|
|
633
|
+
# Determine if multi-phenotype
|
|
634
|
+
# Multi-phenotype if: more than one phenotype in the legend,
|
|
635
|
+
# OR carrier notation is used alongside a phenotype
|
|
636
|
+
carrier_present = any(has_carrier_notation(fam.persons) for fam in families)
|
|
637
|
+
is_multi = len(phenotypes) > 1 or (len(phenotypes) == 1 and carrier_present)
|
|
638
|
+
|
|
639
|
+
# Build output files
|
|
640
|
+
files: list[tuple[str, str]] = []
|
|
641
|
+
|
|
642
|
+
for i, fam in enumerate(families):
|
|
643
|
+
suffix = f"_{i + 1}" if len(families) > 1 else ""
|
|
644
|
+
|
|
645
|
+
if is_multi:
|
|
646
|
+
ext = ".mped"
|
|
647
|
+
content = build_mped(fam, phenotypes, carrier_present)
|
|
648
|
+
else:
|
|
649
|
+
ext = ".ped"
|
|
650
|
+
content = build_standard_ped(fam, phenotypes)
|
|
651
|
+
|
|
652
|
+
filename = f"{output_prefix}{suffix}{ext}"
|
|
653
|
+
files.append((filename, content))
|
|
654
|
+
|
|
655
|
+
# Build summary
|
|
656
|
+
fam_desc = ", ".join(
|
|
657
|
+
f"{fam.family_id} ({len(fam.persons)} individuals)"
|
|
658
|
+
for fam in families
|
|
659
|
+
)
|
|
660
|
+
pheno_desc = ", ".join(ph.name for ph in phenotypes) if phenotypes else "none"
|
|
661
|
+
summary = (
|
|
662
|
+
f"Detected {len(families)} pedigree(s): {fam_desc}. "
|
|
663
|
+
f"Phenotypes: {pheno_desc}. "
|
|
664
|
+
f"Format: {'MPED (multi-phenotype)' if is_multi else 'standard PED'}. "
|
|
665
|
+
f"Carrier notation: {'yes' if carrier_present else 'no'}."
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
return ConversionResult(
|
|
669
|
+
files=files,
|
|
670
|
+
families=families,
|
|
671
|
+
phenotypes=phenotypes,
|
|
672
|
+
is_multi_phenotype=is_multi,
|
|
673
|
+
summary=summary,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def convert_file(
|
|
678
|
+
input_path: str,
|
|
679
|
+
output_prefix: Optional[str] = None,
|
|
680
|
+
output_dir: Optional[str] = None,
|
|
681
|
+
) -> ConversionResult:
|
|
682
|
+
"""Read a Pedigree Studio session JSON and write PED/MPED files.
|
|
683
|
+
|
|
684
|
+
Parameters
|
|
685
|
+
----------
|
|
686
|
+
input_path : str
|
|
687
|
+
Path to the input .json session file.
|
|
688
|
+
output_prefix : str, optional
|
|
689
|
+
Base name for output files. Defaults to the input filename stem.
|
|
690
|
+
output_dir : str, optional
|
|
691
|
+
Directory for output files. Defaults to the input file's directory.
|
|
692
|
+
|
|
693
|
+
Returns
|
|
694
|
+
-------
|
|
695
|
+
ConversionResult
|
|
696
|
+
Contains the written file paths and metadata.
|
|
697
|
+
"""
|
|
698
|
+
with open(input_path, "r") as f:
|
|
699
|
+
session_data = json.load(f)
|
|
700
|
+
|
|
701
|
+
if output_prefix is None:
|
|
702
|
+
output_prefix = os.path.splitext(os.path.basename(input_path))[0]
|
|
703
|
+
|
|
704
|
+
if output_dir is None:
|
|
705
|
+
output_dir = os.path.dirname(input_path) or "."
|
|
706
|
+
|
|
707
|
+
result = convert(session_data, output_prefix)
|
|
708
|
+
|
|
709
|
+
# Write files
|
|
710
|
+
written_files = []
|
|
711
|
+
for filename, content in result.files:
|
|
712
|
+
filepath = os.path.join(output_dir, filename)
|
|
713
|
+
with open(filepath, "w") as f:
|
|
714
|
+
f.write(content)
|
|
715
|
+
written_files.append((filepath, content))
|
|
716
|
+
|
|
717
|
+
result.files = written_files
|
|
718
|
+
return result
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: studio2ped
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Pedigree Studio session JSON to PED/MPED pedigree files
|
|
5
|
+
Author-email: Tim Hearn <tjh70@cam.ac.uk>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/comparativechrono/studio2ped
|
|
8
|
+
Project-URL: Issues, https://github.com/comparativechrono/studio2ped/issues
|
|
9
|
+
Keywords: pedigree,genetics,ped,family-tree,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
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# studio2ped
|
|
28
|
+
|
|
29
|
+
Convert Pedigree Studio session JSON files into standard PED pedigree files or extended MPED (multi-phenotype PED) files.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install studio2ped
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or use the standalone script (`studio2ped_standalone.py`) with no installation required — needs only Python 3.9+.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Command line
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Basic conversion
|
|
45
|
+
studio2ped session.json
|
|
46
|
+
|
|
47
|
+
# Specify output name and directory
|
|
48
|
+
studio2ped session.json -o my_pedigree -d ./output/
|
|
49
|
+
|
|
50
|
+
# Using the standalone script
|
|
51
|
+
python studio2ped_standalone.py session.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Python API
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import studio2ped
|
|
58
|
+
|
|
59
|
+
# From a session dict
|
|
60
|
+
import json
|
|
61
|
+
with open("session.json") as f:
|
|
62
|
+
session = json.load(f)
|
|
63
|
+
|
|
64
|
+
result = studio2ped.convert(session)
|
|
65
|
+
print(result.summary)
|
|
66
|
+
for filename, content in result.files:
|
|
67
|
+
print(f"--- {filename} ---")
|
|
68
|
+
print(content)
|
|
69
|
+
|
|
70
|
+
# From file to file
|
|
71
|
+
result = studio2ped.convert_file("session.json")
|
|
72
|
+
for filepath, _ in result.files:
|
|
73
|
+
print(f"Written: {filepath}")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## What It Does
|
|
77
|
+
|
|
78
|
+
The converter reads a Pedigree Studio session JSON file and:
|
|
79
|
+
|
|
80
|
+
1. **Detects separate pedigrees** — if the canvas contains multiple unconnected family trees, each is exported as a separate file
|
|
81
|
+
2. **Resolves family structure** via graph traversal through partnerships and child links — not spatial position (so rearranged pedigrees convert correctly)
|
|
82
|
+
3. **Extracts phenotypes** from the legend/colour key: solid fills, half fills, quartered fills, and shading patterns are all recognised
|
|
83
|
+
4. **Detects carrier notation** — centre dots (●) and half-filled shapes are identified as carrier status
|
|
84
|
+
5. **Chooses the right format** automatically:
|
|
85
|
+
- **Single phenotype** → standard `.ped` file (6 columns)
|
|
86
|
+
- **Multiple phenotypes** or **carrier notation alongside a phenotype** → extended `.mped` file
|
|
87
|
+
|
|
88
|
+
## Output Formats
|
|
89
|
+
|
|
90
|
+
### Standard PED (`.ped`)
|
|
91
|
+
|
|
92
|
+
The standard 6-column format used by PLINK, GATK, and most genetics tools:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
# FamID IndID FatherID MotherID Sex Phenotype
|
|
96
|
+
FAM1 John 0 0 1 1
|
|
97
|
+
FAM1 Mary 0 0 2 1
|
|
98
|
+
FAM1 Alice John Mary 2 2
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Phenotype codes: `0` = unknown, `1` = unaffected, `2` = affected.
|
|
102
|
+
|
|
103
|
+
### Extended MPED (`.mped`)
|
|
104
|
+
|
|
105
|
+
A multi-phenotype extension for pedigrees with more than one condition tracked. The format adds a header line naming the phenotype columns and supports carrier status:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
# MPED v1 Breast_cancer Carrier
|
|
109
|
+
# FamID IndID FatherID MotherID Sex Breast_cancer Carrier
|
|
110
|
+
FAM1 John 0 0 1 1 1
|
|
111
|
+
FAM1 Mary 0 0 2 1 3
|
|
112
|
+
FAM1 Alice John Mary 2 2 1
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Phenotype codes: `0` = unknown, `1` = unaffected, `2` = affected, `3` = carrier.
|
|
116
|
+
|
|
117
|
+
The MPED format is designed to be easily parseable: the first line declares the format and phenotype column names, the second line is a human-readable column header, and data lines follow the same tab-delimited structure as standard PED with additional columns.
|
|
118
|
+
|
|
119
|
+
## Phenotype Detection
|
|
120
|
+
|
|
121
|
+
The converter maps Pedigree Studio's visual markers to phenotype status using the legend/colour key:
|
|
122
|
+
|
|
123
|
+
| Visual Marker | Legend Key Format | Status |
|
|
124
|
+
|---------------|-------------------|--------|
|
|
125
|
+
| Solid colour fill | `#rrggbb` | Affected (2) |
|
|
126
|
+
| Full shading (stripes/dots) | `shading:pattern:full` | Affected (2) |
|
|
127
|
+
| Half colour fill (one side only) | `#rrggbb` | Carrier (3) |
|
|
128
|
+
| Half shading | `shading:pattern:half-left` | Carrier (3) |
|
|
129
|
+
| Quartered fill (any quarter) | `#rrggbb` | Affected (2) |
|
|
130
|
+
| Centre dot (●) | — | Carrier (3) |
|
|
131
|
+
| No fill | — | Unaffected (1) |
|
|
132
|
+
|
|
133
|
+
Phenotype names are taken from the text the user typed into the legend (e.g., "Breast cancer", "Carrier status"). If the legend has no text for an entry, that visual marker is ignored.
|
|
134
|
+
|
|
135
|
+
## Multiple Pedigrees
|
|
136
|
+
|
|
137
|
+
If a session contains multiple unconnected pedigrees (common when a user draws several families on the same canvas), the converter detects each connected component via graph traversal and exports them as separate files:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
pedigree_1.ped # First family
|
|
141
|
+
pedigree_2.ped # Second family
|
|
142
|
+
pedigree_3.mped # Third family (if multi-phenotype)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Individual IDs
|
|
146
|
+
|
|
147
|
+
The converter generates individual IDs in priority order:
|
|
148
|
+
|
|
149
|
+
1. **Custom freetext label** (if the user edited the numbering label)
|
|
150
|
+
2. **First line of annotation text** (the text below each shape)
|
|
151
|
+
3. **Pedigree Studio internal ID** (fallback, e.g. `p-3`)
|
|
152
|
+
|
|
153
|
+
IDs are sanitised to remove whitespace and special characters.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
studio2ped/__init__.py
|
|
5
|
+
studio2ped/__main__.py
|
|
6
|
+
studio2ped/cli.py
|
|
7
|
+
studio2ped/converter.py
|
|
8
|
+
studio2ped.egg-info/PKG-INFO
|
|
9
|
+
studio2ped.egg-info/SOURCES.txt
|
|
10
|
+
studio2ped.egg-info/dependency_links.txt
|
|
11
|
+
studio2ped.egg-info/entry_points.txt
|
|
12
|
+
studio2ped.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
studio2ped
|