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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ """Allow running as `python -m studio2ped`."""
2
+ import sys
3
+ from .cli import main
4
+ sys.exit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ studio2ped = studio2ped.cli:main
@@ -0,0 +1 @@
1
+ studio2ped