studio2ped 0.2.0__tar.gz → 0.2.1__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.
Files changed (32) hide show
  1. {studio2ped-0.2.0 → studio2ped-0.2.1}/CHANGELOG.md +21 -0
  2. {studio2ped-0.2.0/studio2ped.egg-info → studio2ped-0.2.1}/PKG-INFO +11 -9
  3. {studio2ped-0.2.0 → studio2ped-0.2.1}/README.md +10 -8
  4. {studio2ped-0.2.0 → studio2ped-0.2.1}/pyproject.toml +1 -1
  5. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped/__init__.py +1 -1
  6. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped/converter.py +40 -22
  7. {studio2ped-0.2.0 → studio2ped-0.2.1/studio2ped.egg-info}/PKG-INFO +11 -9
  8. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped_standalone.py +42 -24
  9. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/test_studio2ped.py +57 -4
  10. {studio2ped-0.2.0 → studio2ped-0.2.1}/LICENSE +0 -0
  11. {studio2ped-0.2.0 → studio2ped-0.2.1}/MANIFEST.in +0 -0
  12. {studio2ped-0.2.0 → studio2ped-0.2.1}/setup.cfg +0 -0
  13. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped/__main__.py +0 -0
  14. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped/cli.py +0 -0
  15. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped.egg-info/SOURCES.txt +0 -0
  16. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped.egg-info/dependency_links.txt +0 -0
  17. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped.egg-info/entry_points.txt +0 -0
  18. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped.egg-info/requires.txt +0 -0
  19. {studio2ped-0.2.0 → studio2ped-0.2.1}/studio2ped.egg-info/top_level.txt +0 -0
  20. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/cf-cousins.studio.json +0 -0
  21. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/expected/cf-cousins.mped +0 -0
  22. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/expected/features.mped +0 -0
  23. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/expected/lynch.mped +0 -0
  24. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/expected/okafor.mped +0 -0
  25. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/expected/price.mped +0 -0
  26. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/features.studio.json +0 -0
  27. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/lynch.studio.json +0 -0
  28. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/okafor.studio.json +0 -0
  29. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/data/price.studio.json +0 -0
  30. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/test_standalone.py +0 -0
  31. {studio2ped-0.2.0 → studio2ped-0.2.1}/tests/test_version.py +0 -0
  32. {studio2ped-0.2.0 → studio2ped-0.2.1}/tools/make_standalone.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - A parent marked G is a gestational carrier, and is not written as a genetic
6
+ parent. Since the 2025 correction to the pedigree nomenclature (Steinhaus
7
+ French, Bennett and Resta, *J Genet Couns* 2025;34(2):e2020), G marks a
8
+ gestational carrier, and Pedigree Studio draws G where it used to draw S.
9
+ S, which older drawings use, still means the same.
10
+ - The note says "gestational carrier" and gives the mark (G or S), not
11
+ "surrogate": the corrected nomenclature keeps "surrogate" for a woman who
12
+ gives the egg and carries the pregnancy, who is a genetic parent.
13
+ - A symbol divided into thirds, which Pedigree Studio now draws for three
14
+ conditions, gives each of its colours as a condition the person has, as
15
+ quarters do.
16
+ - The "no children" and "infertility" symbols no longer count when a row is
17
+ numbered. The nomenclature numbers individuals (Bennett et al., *J Genet
18
+ Couns* 2022;31(6):1238-48) and these symbols are not people, so Pedigree
19
+ Studio now gives them no number, and studio2ped numbers only people too:
20
+ someone to the right of one of these symbols in the same row has a number
21
+ one lower than before (II-3 rather than II-4), and a row with nothing but
22
+ these symbols is not a generation.
23
+
3
24
  ## 0.2.0
4
25
 
5
26
  - People are now identified by their pedigree numbers (I-1, II-3 and so on),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: studio2ped
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Convert Pedigree Studio session JSON to PED/MPED pedigree files
5
5
  Author-email: Tim Hearn <tjh70@cam.ac.uk>
6
6
  License-Expression: MIT
@@ -99,11 +99,12 @@ The converter reads a Pedigree Studio session file and:
99
99
  4. **Gives genetic parents only.** The father and mother columns of a PED
100
100
  file are genetic parents. A child joined to a couple by a dashed line (an
101
101
  adoption) gets 0 for that couple, and so does the place of a parent marked
102
- S (a gestational carrier), with a note in each case; a parent marked D (a
103
- donor) is a genetic parent. studio2ga4gh's PED-style pedigree gives the
104
- same.
102
+ G (a gestational carrier), with a note in each case; a parent marked D (a
103
+ donor) is a genetic parent. Drawings made before the 2025 correction to the
104
+ pedigree nomenclature mark a gestational carrier S, which is read the same
105
+ way. studio2ga4gh's PED-style pedigree gives the same.
105
106
  5. **Extracts phenotypes** from the colour key: solid fills, half fills,
106
- quartered fills and shading patterns are all recognised.
107
+ symbols in thirds or quarters, and shading patterns are all recognised.
107
108
  6. **Detects carrier notation**: centre dots (●) and half-filled symbols mark
108
109
  carriers.
109
110
  7. **Chooses the format**:
@@ -136,9 +137,10 @@ would be `0`, the person is given their pedigree number instead, and a note
136
137
  says so.
137
138
 
138
139
  Pedigree numbers follow the drawing, as in Pedigree Studio: a new generation
139
- starts 80 pixels or more below the first symbol of the generation above, and
140
- the "no children" and "infertility" symbols are counted in their row, even
141
- though they are not written to the file.
140
+ starts 80 pixels or more below the first person of the generation above, and
141
+ each generation is numbered from the left. Only people are numbered: the "no
142
+ children" and "infertility" symbols are not people, so they have no number
143
+ and do not count in their row.
142
144
 
143
145
  ## Output formats
144
146
 
@@ -199,7 +201,7 @@ colour key:
199
201
  | Full shading (stripes/dots) | `shading:pattern:full` | Affected (2) |
200
202
  | Half colour fill (one side only) | `#rrggbb` | Carrier (3) |
201
203
  | Half shading | `shading:pattern:half-left` | Carrier (3) |
202
- | Quartered fill (any quarter) | `#rrggbb` | Affected (2) |
204
+ | Thirds or quarters (any part) | `#rrggbb` | Affected (2) |
203
205
  | Centre dot (●) | — | Carrier (3) |
204
206
  | No fill | — | Unaffected (1) |
205
207
 
@@ -76,11 +76,12 @@ The converter reads a Pedigree Studio session file and:
76
76
  4. **Gives genetic parents only.** The father and mother columns of a PED
77
77
  file are genetic parents. A child joined to a couple by a dashed line (an
78
78
  adoption) gets 0 for that couple, and so does the place of a parent marked
79
- S (a gestational carrier), with a note in each case; a parent marked D (a
80
- donor) is a genetic parent. studio2ga4gh's PED-style pedigree gives the
81
- same.
79
+ G (a gestational carrier), with a note in each case; a parent marked D (a
80
+ donor) is a genetic parent. Drawings made before the 2025 correction to the
81
+ pedigree nomenclature mark a gestational carrier S, which is read the same
82
+ way. studio2ga4gh's PED-style pedigree gives the same.
82
83
  5. **Extracts phenotypes** from the colour key: solid fills, half fills,
83
- quartered fills and shading patterns are all recognised.
84
+ symbols in thirds or quarters, and shading patterns are all recognised.
84
85
  6. **Detects carrier notation**: centre dots (●) and half-filled symbols mark
85
86
  carriers.
86
87
  7. **Chooses the format**:
@@ -113,9 +114,10 @@ would be `0`, the person is given their pedigree number instead, and a note
113
114
  says so.
114
115
 
115
116
  Pedigree numbers follow the drawing, as in Pedigree Studio: a new generation
116
- starts 80 pixels or more below the first symbol of the generation above, and
117
- the "no children" and "infertility" symbols are counted in their row, even
118
- though they are not written to the file.
117
+ starts 80 pixels or more below the first person of the generation above, and
118
+ each generation is numbered from the left. Only people are numbered: the "no
119
+ children" and "infertility" symbols are not people, so they have no number
120
+ and do not count in their row.
119
121
 
120
122
  ## Output formats
121
123
 
@@ -176,7 +178,7 @@ colour key:
176
178
  | Full shading (stripes/dots) | `shading:pattern:full` | Affected (2) |
177
179
  | Half colour fill (one side only) | `#rrggbb` | Carrier (3) |
178
180
  | Half shading | `shading:pattern:half-left` | Carrier (3) |
179
- | Quartered fill (any quarter) | `#rrggbb` | Affected (2) |
181
+ | Thirds or quarters (any part) | `#rrggbb` | Affected (2) |
180
182
  | Centre dot (●) | — | Carrier (3) |
181
183
  | No fill | — | Unaffected (1) |
182
184
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "studio2ped"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Convert Pedigree Studio session JSON to PED/MPED pedigree files"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  """studio2ped — Convert Pedigree Studio session JSON to PED/MPED pedigree files."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
4
4
 
5
5
  from .converter import ( # noqa: E402
6
6
  ID_CHOICES, ConversionResult, convert, convert_file, individual_ids, parse_session, pedigree_numbers,
@@ -37,6 +37,12 @@ SHAPE_TO_SEX = {
37
37
  # couple, so they have to be taken out before the family is written.
38
38
  MARKERS = {"no-offspring": "no children", "infertile": "infertility"}
39
39
 
40
+ # The marks inside a symbol that make someone a gestational carrier, who carried a
41
+ # child but is not their genetic parent: G since the 2025 correction to the pedigree
42
+ # nomenclature, and S, which drawings from before it use (a woman who gives the egg
43
+ # and carries the pregnancy is a donor, D, and so a genetic parent)
44
+ GESTATIONAL_CARRIER = ("G", "S")
45
+
40
46
  # The kinds of identifier --ids offers (see individual_ids)
41
47
  ID_CHOICES = ("numbers", "drawing", "studio", "text")
42
48
 
@@ -52,13 +58,14 @@ class Person:
52
58
  shape: str
53
59
  sex: int
54
60
  text: str # annotation text (may contain name, dob, etc.)
55
- fill_mode: str # 'none'|'solid'|'half'|'quartered'|'shading'
61
+ fill_mode: str # 'none'|'solid'|'half'|'thirds'|'quartered'|'shading'
56
62
  fill_color: Optional[str] # hex colour for solid fill
57
63
  halves: list # [left_colour|None, right_colour|None]
58
64
  quarters: list # [TR, BR, BL, TL] colours or None
65
+ thirds: list # [top right, bottom, top left] colours or None
59
66
  shading_pattern: str # 'stripes'|'dots'
60
67
  shading_coverage: str # 'full'|'half-left'|'half-right'
61
- center_text: str # 'S'|'P'|'D'|'dot'|''
68
+ center_text: str # 'G'|'S'|'P'|'D'|'dot'|'line'|''
62
69
  deceased: bool
63
70
  custom_label: str
64
71
  father_id: Optional[str] = None # the genetic parents, resolved during graph analysis
@@ -142,6 +149,7 @@ def parse_session(data: dict) -> tuple[dict[str, Person], list[Partnership], lis
142
149
  fill_color=pd.get("fillColor"),
143
150
  halves=pd.get("halves", [None, None]),
144
151
  quarters=pd.get("quarters", [None, None, None, None]),
152
+ thirds=pd.get("thirds") or [None, None, None],
145
153
  shading_pattern=pd.get("shadingPattern", "stripes"),
146
154
  shading_coverage=pd.get("shadingCoverage", "full"),
147
155
  center_text=pd.get("centerText", ""),
@@ -259,11 +267,13 @@ def resolve_parents(family: Family) -> list:
259
267
  ``drawn_father_id`` and ``drawn_mother_id`` are the couple the drawing
260
268
  joins a person to. ``father_id`` and ``mother_id`` are their genetic
261
269
  parents, which the PED file gives: a dashed line (an adoption) gives no
262
- genetic parents, and a parent marked S, a gestational carrier (surrogate),
263
- is not a genetic parent, as in studio2ga4gh's PED-style pedigree.
270
+ genetic parents, and a parent marked G, a gestational carrier, is not a
271
+ genetic parent, as in studio2ga4gh's PED-style pedigree. Drawings from
272
+ before the 2025 correction to the pedigree nomenclature mark a gestational
273
+ carrier S, which is read the same way.
264
274
 
265
275
  Returns what was not written as a parent, as ``("adopted", child, (a, b))``
266
- and ``("surrogate", child, carrier, "mother" or "father")``.
276
+ and ``("gestational carrier", child, carrier, "mother" or "father")``.
267
277
  """
268
278
  pship_map = {p.id: p for p in family.partnerships}
269
279
  left_out = []
@@ -297,8 +307,8 @@ def resolve_parents(family: Family) -> list:
297
307
  left_out.append(("adopted", child.id, (p1.id, p2.id)))
298
308
  continue
299
309
  for parent, role in ((father, "father"), (mother, "mother")):
300
- if parent.center_text == "S":
301
- left_out.append(("surrogate", child.id, parent.id, role))
310
+ if parent.center_text in GESTATIONAL_CARRIER:
311
+ left_out.append(("gestational carrier", child.id, parent.id, role))
302
312
  elif role == "father":
303
313
  child.father_id = parent.id
304
314
  else:
@@ -426,7 +436,7 @@ def classify_person_phenotypes(
426
436
  - Full shading matching a shading phenotype → affected (2)
427
437
  - Half fill with one half matching a colour phenotype → carrier (3)
428
438
  - Half shading matching a shading phenotype with half coverage → carrier (3)
429
- - Quartered fill with any quarter matching a colour phenotype → affected (2)
439
+ - Thirds or quarters with any part matching a colour phenotype → affected (2)
430
440
  - Centre dot ('dot') → add carrier status for all phenotypes that person has
431
441
  - No matching fill → unaffected (1)
432
442
  """
@@ -451,11 +461,12 @@ def classify_person_phenotypes(
451
461
  else:
452
462
  is_half_colour[c] = False
453
463
 
454
- elif person.fill_mode == "quartered":
455
- for c in (person.quarters or []):
464
+ elif person.fill_mode in ("quartered", "thirds"):
465
+ # a symbol in thirds or quarters: one part for each condition, each affected
466
+ for c in ((person.quarters if person.fill_mode == "quartered" else person.thirds) or []):
456
467
  if c:
457
468
  person_colours.add(c)
458
- is_half_colour[c] = False # quartered = affected
469
+ is_half_colour[c] = False
459
470
 
460
471
  # Match colour phenotypes
461
472
  for ph in phenotypes:
@@ -520,12 +531,13 @@ def has_carrier_notation(persons: dict[str, Person]) -> bool:
520
531
  def drawn_rows(persons: dict[str, Person]) -> list[list[Person]]:
521
532
  """The rows (generations) of the drawing as Pedigree Studio numbers them.
522
533
 
523
- Everyone is sorted from top to bottom, the "no children" and "infertile"
524
- symbols included; a new row starts 80 px or more below the first symbol of
525
- the row above, and each row is read from left to right.
534
+ The people are sorted from top to bottom; a new row starts 80 px or more
535
+ below the first person of the row above, and each row is read from left to
536
+ right. The "no children" and "infertile" symbols are not people, so they
537
+ are left out and do not count in their row.
526
538
  """
527
539
  rows: list[list[Person]] = []
528
- for p in sorted(persons.values(), key=lambda q: q.y):
540
+ for p in sorted((q for q in persons.values() if not q.is_marker), key=lambda q: q.y):
529
541
  if rows and p.y - rows[-1][0].y < 80:
530
542
  rows[-1].append(p)
531
543
  else:
@@ -539,7 +551,8 @@ def _generation_label(index: int) -> str:
539
551
 
540
552
  def pedigree_numbers(persons: dict[str, Person]) -> dict[str, str]:
541
553
  """Pedigree numbers as Pedigree Studio shows them: the generation in Roman
542
- numerals and the position from the left, such as ``II-3``."""
554
+ numerals and the position from the left, such as ``II-3``. Only people are
555
+ numbered: the "no children" and "infertile" symbols have no number."""
543
556
  out = {}
544
557
  for gi, row in enumerate(drawn_rows(persons)):
545
558
  for pi, p in enumerate(row):
@@ -556,14 +569,17 @@ def _letters(n: int) -> str:
556
569
 
557
570
 
558
571
  def drawing_labels(persons: dict[str, Person], numbering_mode: str = "numbers") -> dict[str, str]:
559
- """The label each symbol shows in the drawing under its numbering setting:
572
+ """The label each person shows in the drawing under its numbering setting:
560
573
  pedigree numbers (``numbers``, and ``off``), letters (``letters``: II-A;
561
574
  ``lettersonly``: A, B, C ...) or the labels typed in (``freetext``, where a
562
- symbol without one shows its pedigree number)."""
575
+ person without one shows their pedigree number). The "no children" and
576
+ "infertile" symbols have no label."""
563
577
  numbers = pedigree_numbers(persons)
564
578
  if numbering_mode == "freetext":
565
579
  out = {}
566
580
  for pid, p in persons.items():
581
+ if p.is_marker:
582
+ continue
567
583
  label = (p.custom_label or "").strip()
568
584
  out[pid] = label if label and label != "Lbl" else numbers[pid]
569
585
  return out
@@ -598,8 +614,9 @@ def individual_ids(
598
614
  * ``text``: version 0.1.0's identifiers (see :func:`make_individual_id`),
599
615
  which are often names.
600
616
 
601
- ``persons`` should include the "no children" and "infertile" symbols,
602
- because Pedigree Studio counts them when it numbers a row. Identifiers must
617
+ ``persons`` may include the "no children" and "infertile" symbols: they
618
+ are not people, so they are given no identifier and, as in Pedigree
619
+ Studio, do not count when a row is numbered. Identifiers must
603
620
  be unique, so anyone whose identifier would be shared with someone else, or
604
621
  would be empty or ``0`` (which PED reads as "no parent"), is given their
605
622
  pedigree number instead, and a note says so.
@@ -900,8 +917,9 @@ def convert(
900
917
  f"parents are not genetic parents, so they are not written as {id_map[child]}'s parents")
901
918
  else:
902
919
  _, child, carrier, role = item
903
- notes.append(f"{id_map[carrier]} carried {id_map[child]} as a surrogate (S): a gestational carrier is "
904
- f"not a genetic parent, so {id_map[child]}'s {role} is written as 0")
920
+ mark = fam.persons[carrier].center_text
921
+ notes.append(f"{id_map[carrier]} carried {id_map[child]} as a gestational carrier ({mark}), and a "
922
+ f"gestational carrier is not a genetic parent, so {id_map[child]}'s {role} is written as 0")
905
923
  assign_generations(fam)
906
924
 
907
925
  # Extract phenotypes from legend
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: studio2ped
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Convert Pedigree Studio session JSON to PED/MPED pedigree files
5
5
  Author-email: Tim Hearn <tjh70@cam.ac.uk>
6
6
  License-Expression: MIT
@@ -99,11 +99,12 @@ The converter reads a Pedigree Studio session file and:
99
99
  4. **Gives genetic parents only.** The father and mother columns of a PED
100
100
  file are genetic parents. A child joined to a couple by a dashed line (an
101
101
  adoption) gets 0 for that couple, and so does the place of a parent marked
102
- S (a gestational carrier), with a note in each case; a parent marked D (a
103
- donor) is a genetic parent. studio2ga4gh's PED-style pedigree gives the
104
- same.
102
+ G (a gestational carrier), with a note in each case; a parent marked D (a
103
+ donor) is a genetic parent. Drawings made before the 2025 correction to the
104
+ pedigree nomenclature mark a gestational carrier S, which is read the same
105
+ way. studio2ga4gh's PED-style pedigree gives the same.
105
106
  5. **Extracts phenotypes** from the colour key: solid fills, half fills,
106
- quartered fills and shading patterns are all recognised.
107
+ symbols in thirds or quarters, and shading patterns are all recognised.
107
108
  6. **Detects carrier notation**: centre dots (●) and half-filled symbols mark
108
109
  carriers.
109
110
  7. **Chooses the format**:
@@ -136,9 +137,10 @@ would be `0`, the person is given their pedigree number instead, and a note
136
137
  says so.
137
138
 
138
139
  Pedigree numbers follow the drawing, as in Pedigree Studio: a new generation
139
- starts 80 pixels or more below the first symbol of the generation above, and
140
- the "no children" and "infertility" symbols are counted in their row, even
141
- though they are not written to the file.
140
+ starts 80 pixels or more below the first person of the generation above, and
141
+ each generation is numbered from the left. Only people are numbered: the "no
142
+ children" and "infertility" symbols are not people, so they have no number
143
+ and do not count in their row.
142
144
 
143
145
  ## Output formats
144
146
 
@@ -199,7 +201,7 @@ colour key:
199
201
  | Full shading (stripes/dots) | `shading:pattern:full` | Affected (2) |
200
202
  | Half colour fill (one side only) | `#rrggbb` | Carrier (3) |
201
203
  | Half shading | `shading:pattern:half-left` | Carrier (3) |
202
- | Quartered fill (any quarter) | `#rrggbb` | Affected (2) |
204
+ | Thirds or quarters (any part) | `#rrggbb` | Affected (2) |
203
205
  | Centre dot (●) | — | Carrier (3) |
204
206
  | No fill | — | Unaffected (1) |
205
207
 
@@ -2,7 +2,7 @@
2
2
  """
3
3
  studio2ped_standalone.py - Convert Pedigree Studio session JSON to PED/MPED files.
4
4
 
5
- Standalone single-file script: studio2ped 0.2.0 in one file, so no
5
+ Standalone single-file script: studio2ped 0.2.1 in one file, so no
6
6
  installation is required - it needs only Python 3.9+.
7
7
 
8
8
  Usage:
@@ -23,7 +23,7 @@ from collections import Counter, defaultdict
23
23
  from dataclasses import dataclass, field
24
24
  from typing import Optional
25
25
 
26
- __version__ = "0.2.0"
26
+ __version__ = "0.2.1"
27
27
 
28
28
  # ── PED phenotype codes ───────────────────────────────────────────────────
29
29
  PHENO_UNKNOWN = 0
@@ -43,6 +43,12 @@ SHAPE_TO_SEX = {
43
43
  # couple, so they have to be taken out before the family is written.
44
44
  MARKERS = {"no-offspring": "no children", "infertile": "infertility"}
45
45
 
46
+ # The marks inside a symbol that make someone a gestational carrier, who carried a
47
+ # child but is not their genetic parent: G since the 2025 correction to the pedigree
48
+ # nomenclature, and S, which drawings from before it use (a woman who gives the egg
49
+ # and carries the pregnancy is a donor, D, and so a genetic parent)
50
+ GESTATIONAL_CARRIER = ("G", "S")
51
+
46
52
  # The kinds of identifier --ids offers (see individual_ids)
47
53
  ID_CHOICES = ("numbers", "drawing", "studio", "text")
48
54
 
@@ -58,13 +64,14 @@ class Person:
58
64
  shape: str
59
65
  sex: int
60
66
  text: str # annotation text (may contain name, dob, etc.)
61
- fill_mode: str # 'none'|'solid'|'half'|'quartered'|'shading'
67
+ fill_mode: str # 'none'|'solid'|'half'|'thirds'|'quartered'|'shading'
62
68
  fill_color: Optional[str] # hex colour for solid fill
63
69
  halves: list # [left_colour|None, right_colour|None]
64
70
  quarters: list # [TR, BR, BL, TL] colours or None
71
+ thirds: list # [top right, bottom, top left] colours or None
65
72
  shading_pattern: str # 'stripes'|'dots'
66
73
  shading_coverage: str # 'full'|'half-left'|'half-right'
67
- center_text: str # 'S'|'P'|'D'|'dot'|''
74
+ center_text: str # 'G'|'S'|'P'|'D'|'dot'|'line'|''
68
75
  deceased: bool
69
76
  custom_label: str
70
77
  father_id: Optional[str] = None # the genetic parents, resolved during graph analysis
@@ -148,6 +155,7 @@ def parse_session(data: dict) -> tuple[dict[str, Person], list[Partnership], lis
148
155
  fill_color=pd.get("fillColor"),
149
156
  halves=pd.get("halves", [None, None]),
150
157
  quarters=pd.get("quarters", [None, None, None, None]),
158
+ thirds=pd.get("thirds") or [None, None, None],
151
159
  shading_pattern=pd.get("shadingPattern", "stripes"),
152
160
  shading_coverage=pd.get("shadingCoverage", "full"),
153
161
  center_text=pd.get("centerText", ""),
@@ -265,11 +273,13 @@ def resolve_parents(family: Family) -> list:
265
273
  ``drawn_father_id`` and ``drawn_mother_id`` are the couple the drawing
266
274
  joins a person to. ``father_id`` and ``mother_id`` are their genetic
267
275
  parents, which the PED file gives: a dashed line (an adoption) gives no
268
- genetic parents, and a parent marked S, a gestational carrier (surrogate),
269
- is not a genetic parent, as in studio2ga4gh's PED-style pedigree.
276
+ genetic parents, and a parent marked G, a gestational carrier, is not a
277
+ genetic parent, as in studio2ga4gh's PED-style pedigree. Drawings from
278
+ before the 2025 correction to the pedigree nomenclature mark a gestational
279
+ carrier S, which is read the same way.
270
280
 
271
281
  Returns what was not written as a parent, as ``("adopted", child, (a, b))``
272
- and ``("surrogate", child, carrier, "mother" or "father")``.
282
+ and ``("gestational carrier", child, carrier, "mother" or "father")``.
273
283
  """
274
284
  pship_map = {p.id: p for p in family.partnerships}
275
285
  left_out = []
@@ -303,8 +313,8 @@ def resolve_parents(family: Family) -> list:
303
313
  left_out.append(("adopted", child.id, (p1.id, p2.id)))
304
314
  continue
305
315
  for parent, role in ((father, "father"), (mother, "mother")):
306
- if parent.center_text == "S":
307
- left_out.append(("surrogate", child.id, parent.id, role))
316
+ if parent.center_text in GESTATIONAL_CARRIER:
317
+ left_out.append(("gestational carrier", child.id, parent.id, role))
308
318
  elif role == "father":
309
319
  child.father_id = parent.id
310
320
  else:
@@ -432,7 +442,7 @@ def classify_person_phenotypes(
432
442
  - Full shading matching a shading phenotype → affected (2)
433
443
  - Half fill with one half matching a colour phenotype → carrier (3)
434
444
  - Half shading matching a shading phenotype with half coverage → carrier (3)
435
- - Quartered fill with any quarter matching a colour phenotype → affected (2)
445
+ - Thirds or quarters with any part matching a colour phenotype → affected (2)
436
446
  - Centre dot ('dot') → add carrier status for all phenotypes that person has
437
447
  - No matching fill → unaffected (1)
438
448
  """
@@ -457,11 +467,12 @@ def classify_person_phenotypes(
457
467
  else:
458
468
  is_half_colour[c] = False
459
469
 
460
- elif person.fill_mode == "quartered":
461
- for c in (person.quarters or []):
470
+ elif person.fill_mode in ("quartered", "thirds"):
471
+ # a symbol in thirds or quarters: one part for each condition, each affected
472
+ for c in ((person.quarters if person.fill_mode == "quartered" else person.thirds) or []):
462
473
  if c:
463
474
  person_colours.add(c)
464
- is_half_colour[c] = False # quartered = affected
475
+ is_half_colour[c] = False
465
476
 
466
477
  # Match colour phenotypes
467
478
  for ph in phenotypes:
@@ -526,12 +537,13 @@ def has_carrier_notation(persons: dict[str, Person]) -> bool:
526
537
  def drawn_rows(persons: dict[str, Person]) -> list[list[Person]]:
527
538
  """The rows (generations) of the drawing as Pedigree Studio numbers them.
528
539
 
529
- Everyone is sorted from top to bottom, the "no children" and "infertile"
530
- symbols included; a new row starts 80 px or more below the first symbol of
531
- the row above, and each row is read from left to right.
540
+ The people are sorted from top to bottom; a new row starts 80 px or more
541
+ below the first person of the row above, and each row is read from left to
542
+ right. The "no children" and "infertile" symbols are not people, so they
543
+ are left out and do not count in their row.
532
544
  """
533
545
  rows: list[list[Person]] = []
534
- for p in sorted(persons.values(), key=lambda q: q.y):
546
+ for p in sorted((q for q in persons.values() if not q.is_marker), key=lambda q: q.y):
535
547
  if rows and p.y - rows[-1][0].y < 80:
536
548
  rows[-1].append(p)
537
549
  else:
@@ -545,7 +557,8 @@ def _generation_label(index: int) -> str:
545
557
 
546
558
  def pedigree_numbers(persons: dict[str, Person]) -> dict[str, str]:
547
559
  """Pedigree numbers as Pedigree Studio shows them: the generation in Roman
548
- numerals and the position from the left, such as ``II-3``."""
560
+ numerals and the position from the left, such as ``II-3``. Only people are
561
+ numbered: the "no children" and "infertile" symbols have no number."""
549
562
  out = {}
550
563
  for gi, row in enumerate(drawn_rows(persons)):
551
564
  for pi, p in enumerate(row):
@@ -562,14 +575,17 @@ def _letters(n: int) -> str:
562
575
 
563
576
 
564
577
  def drawing_labels(persons: dict[str, Person], numbering_mode: str = "numbers") -> dict[str, str]:
565
- """The label each symbol shows in the drawing under its numbering setting:
578
+ """The label each person shows in the drawing under its numbering setting:
566
579
  pedigree numbers (``numbers``, and ``off``), letters (``letters``: II-A;
567
580
  ``lettersonly``: A, B, C ...) or the labels typed in (``freetext``, where a
568
- symbol without one shows its pedigree number)."""
581
+ person without one shows their pedigree number). The "no children" and
582
+ "infertile" symbols have no label."""
569
583
  numbers = pedigree_numbers(persons)
570
584
  if numbering_mode == "freetext":
571
585
  out = {}
572
586
  for pid, p in persons.items():
587
+ if p.is_marker:
588
+ continue
573
589
  label = (p.custom_label or "").strip()
574
590
  out[pid] = label if label and label != "Lbl" else numbers[pid]
575
591
  return out
@@ -604,8 +620,9 @@ def individual_ids(
604
620
  * ``text``: version 0.1.0's identifiers (see :func:`make_individual_id`),
605
621
  which are often names.
606
622
 
607
- ``persons`` should include the "no children" and "infertile" symbols,
608
- because Pedigree Studio counts them when it numbers a row. Identifiers must
623
+ ``persons`` may include the "no children" and "infertile" symbols: they
624
+ are not people, so they are given no identifier and, as in Pedigree
625
+ Studio, do not count when a row is numbered. Identifiers must
609
626
  be unique, so anyone whose identifier would be shared with someone else, or
610
627
  would be empty or ``0`` (which PED reads as "no parent"), is given their
611
628
  pedigree number instead, and a note says so.
@@ -906,8 +923,9 @@ def convert(
906
923
  f"parents are not genetic parents, so they are not written as {id_map[child]}'s parents")
907
924
  else:
908
925
  _, child, carrier, role = item
909
- notes.append(f"{id_map[carrier]} carried {id_map[child]} as a surrogate (S): a gestational carrier is "
910
- f"not a genetic parent, so {id_map[child]}'s {role} is written as 0")
926
+ mark = fam.persons[carrier].center_text
927
+ notes.append(f"{id_map[carrier]} carried {id_map[child]} as a gestational carrier ({mark}), and a "
928
+ f"gestational carrier is not a genetic parent, so {id_map[child]}'s {role} is written as 0")
911
929
  assign_generations(fam)
912
930
 
913
931
  # Extract phenotypes from legend
@@ -66,16 +66,19 @@ def test_people_are_given_their_pedigree_numbers():
66
66
 
67
67
 
68
68
  def test_numbers_follow_pedigree_studio():
69
- """A new generation starts 80 px or more below the first symbol of the one above; each generation is
70
- numbered from the left, counting the "no children" and "infertile" symbols, as Pedigree Studio does."""
69
+ """A new generation starts 80 px or more below the first person of the one above; each generation is
70
+ numbered from the left. The "no children" and "infertile" symbols are not people: as in Pedigree Studio,
71
+ they have no number and do not count in their row."""
71
72
  session = {"persons": [
72
73
  person("a", x=300, y=100), person("b", "female", x=100, y=150), # 50 px lower: still generation I
73
74
  person("c", x=200, y=179), # 79 px below a: still I
74
75
  person("d", "female", x=50, y=180), # 80 px below a: generation II
75
76
  person("m", "no-offspring", x=10, y=200), person("e", x=400, y=210),
77
+ person("n", "infertile", x=20, y=300), # a row with only a bar is no generation
78
+ person("f", x=30, y=390),
76
79
  ], "partnerships": [], "childLinks": []}
77
80
  numbers = studio2ped.pedigree_numbers(studio2ped.parse_session(session)[0])
78
- assert numbers == {"b": "I-1", "c": "I-2", "a": "I-3", "m": "II-1", "d": "II-2", "e": "II-3"}
81
+ assert numbers == {"b": "I-1", "c": "I-2", "a": "I-3", "d": "II-1", "e": "II-2", "f": "III-1"}
79
82
  deep = {"persons": [person("p{}".format(i), y=i * 100) for i in range(14)], "partnerships": [], "childLinks": []}
80
83
  numbers = studio2ped.pedigree_numbers(studio2ped.parse_session(deep)[0])
81
84
  assert numbers["p11"] == "XII-1" and numbers["p12"] == "13-1"
@@ -106,6 +109,26 @@ def test_ids_text_gives_version_0_1_0s_identifiers():
106
109
  assert any("'26'" in n and "IV-1 and IV-3" in n for n in result.notes)
107
110
 
108
111
 
112
+ def test_the_bars_have_no_label_in_any_numbering():
113
+ """Letters, and labels typed in, skip the "no children" and "infertile" symbols as numbers do."""
114
+ session = {"persons": [
115
+ person("f", x=100, y=0), person("m", "female", x=200, y=0),
116
+ person("bar", "no-offspring", x=0, y=150, customLabel="from an older drawing"),
117
+ person("c1", x=100, y=150, customLabel="Kid"), person("c2", "female", x=200, y=150),
118
+ ], "partnerships": [{"id": "pr", "fromId": "f", "toId": "m"}],
119
+ "childLinks": [{"id": "l1", "partnershipId": "pr", "childId": "c1"},
120
+ {"id": "l2", "partnershipId": "pr", "childId": "c2"}]}
121
+ ids = {mode: studio2ped.convert(dict(session, numberingMode=mode), ids="drawing").ids
122
+ for mode in ("numbers", "letters", "lettersonly", "freetext")}
123
+ assert ids["numbers"] == {"f": "I-1", "m": "I-2", "c1": "II-1", "c2": "II-2"}
124
+ assert ids["letters"] == {"f": "I-A", "m": "I-B", "c1": "II-A", "c2": "II-B"}
125
+ assert ids["lettersonly"] == {"f": "A", "m": "B", "c1": "C", "c2": "D"}
126
+ assert ids["freetext"] == {"f": "I-1", "m": "I-2", "c1": "Kid", "c2": "II-2"}
127
+ from studio2ped.converter import drawing_labels
128
+ everyone = studio2ped.parse_session(dict(session, numberingMode="freetext"))[0]
129
+ assert "bar" not in drawing_labels(everyone, "freetext") and "bar" not in drawing_labels(everyone, "letters")
130
+
131
+
109
132
  def test_ids_studio_and_drawing():
110
133
  session = load("okafor")
111
134
  studio_ids = studio2ped.convert(session, ids="studio").ids
@@ -180,7 +203,7 @@ def test_adoptive_and_surrogate_parents_are_not_genetic_parents():
180
203
  assert table["III-1"][2:4] == ["II-2", "0"] # II-3, the mother in the drawing, is a surrogate
181
204
  assert table["III-3"][2:4] == ["0", "0"] # adopted by II-6 and II-7
182
205
  assert table["III-2"][2:4] == ["II-5", "II-4"] # a donor (D) is a genetic parent
183
- assert any("II-3 carried III-1 as a surrogate" in n for n in features.notes)
206
+ assert any("II-3 carried III-1 as a gestational carrier (S)" in n for n in features.notes)
184
207
  assert any("II-6 and II-7 adopted III-3" in n for n in features.notes)
185
208
  ids = [r[1] for r in rows(features.files[0][1])]
186
209
  assert ids.index("III-3") > ids.index("II-7") # still listed with their generation
@@ -188,6 +211,36 @@ def test_adoptive_and_surrogate_parents_are_not_genetic_parents():
188
211
  assert {r[1]: r for r in rows(price.files[0][1])}["III-5"][2:4] == ["0", "0"]
189
212
 
190
213
 
214
+ def test_a_parent_marked_g_is_a_gestational_carrier():
215
+ """Since the 2025 correction to the nomenclature, Pedigree Studio marks a gestational carrier G: read as S was."""
216
+ session = load("features")
217
+ marked = [p for p in session["persons"] if p.get("centerText") == "S"]
218
+ assert len(marked) == 1
219
+ marked[0]["centerText"] = "G"
220
+ features = studio2ped.convert(session)
221
+ table = {r[1]: r for r in rows(features.files[0][1])}
222
+ assert table["III-1"][2:4] == ["II-2", "0"] # II-3, the mother in the drawing, is the gestational carrier
223
+ assert any("II-3 carried III-1 as a gestational carrier (G)" in n for n in features.notes)
224
+ assert not any("surrogate" in n for n in features.notes)
225
+ assert studio2ped.convert(load("features")).files == features.files # G and S give the same file
226
+
227
+
228
+ def test_a_symbol_in_thirds_has_each_condition():
229
+ """Pedigree Studio can divide a symbol into thirds, one part for each of up to three conditions (Bennett et al.
230
+ 2022): each colour in it is a condition the person has, as with quarters."""
231
+ session = {"persons": [person("p1", "male", 0, 0), person("p2", "female", 104, 0),
232
+ person("p3", "male", 52, 130, fillMode="thirds", thirds=["#c0392b", "#2980b9", None]),
233
+ person("p4", "female", 156, 130, fillMode="quartered", quarters=["#c0392b", None, None, None])],
234
+ "partnerships": [{"id": "ps1", "fromId": "p1", "toId": "p2"}],
235
+ "childLinks": [{"id": "c1", "partnershipId": "ps1", "childId": "p3"},
236
+ {"id": "c2", "partnershipId": "ps1", "childId": "p4"}],
237
+ "legendLabels": {"#c0392b": "Condition A", "#2980b9": "Condition B"}}
238
+ table = {r[1]: r for r in rows(studio2ped.convert(session).files[0][1])}
239
+ assert table["II-1"][5:] == ["2", "2"]
240
+ assert table["II-2"][5:] == ["2", "1"]
241
+ assert table["I-1"][5:] == ["1", "1"]
242
+
243
+
191
244
  def test_labels_become_identifiers_as_in_studio2ga4gh():
192
245
  """Spaces become underscores and other punctuation is dropped, the same rule studio2ga4gh uses, so a person
193
246
  has the same identifier in the PED file and the GA4GH files."""
File without changes
File without changes
File without changes
File without changes