pedview 1.0.0__py3-none-any.whl

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.
pedview/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """pedview package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "1.0.0"
pedview/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
pedview/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .layout import build_family_layout
8
+ from .parser import PedigreeFormatError, parse_pedigree
9
+ from .render import render_report
10
+ from .validate import summarize_pedigree, validate_pedigree
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="pedview",
16
+ description="Build interactive pedigree reports from pedigree-style family files.",
17
+ )
18
+ subparsers = parser.add_subparsers(dest="command", required=True)
19
+
20
+ validate_parser = subparsers.add_parser(
21
+ "validate", help="Validate a pedigree file."
22
+ )
23
+ validate_parser.add_argument(
24
+ "input_path", help="Path to a pedigree file (.ped, .fam)."
25
+ )
26
+
27
+ preview_parser = subparsers.add_parser(
28
+ "preview", help="Summarize pedigree contents."
29
+ )
30
+ preview_parser.add_argument(
31
+ "input_path", help="Path to a pedigree file (.ped, .fam)."
32
+ )
33
+
34
+ build_parser_ = subparsers.add_parser(
35
+ "build", help="Generate a standalone HTML report."
36
+ )
37
+ build_parser_.add_argument(
38
+ "input_path", help="Path to a pedigree file (.ped, .fam)."
39
+ )
40
+ build_parser_.add_argument(
41
+ "-o",
42
+ "--output",
43
+ help="Path to the output HTML file. Defaults to <input>.html.",
44
+ )
45
+ build_parser_.add_argument(
46
+ "--family",
47
+ help="Optional family ID filter. Only render the requested family.",
48
+ )
49
+ build_parser_.add_argument(
50
+ "--title",
51
+ help="Optional report title. Defaults to a title derived from the input filename.",
52
+ )
53
+ build_parser_.add_argument(
54
+ "--ancestor-inbreeding",
55
+ type=float,
56
+ help="Global Wright ancestor inbreeding coefficient (f_a). Defaults to 0.0 when omitted.",
57
+ )
58
+ build_parser_.add_argument(
59
+ "--variant-name",
60
+ help="Optional label or name of the candidate variant being analyzed (e.g. SCN1A:c.1234G>A).",
61
+ )
62
+
63
+ return parser
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ parser = build_parser()
68
+ args = parser.parse_args(argv)
69
+
70
+ if args.command == "validate":
71
+ return run_validate(args.input_path)
72
+ if args.command == "preview":
73
+ return run_preview(args.input_path)
74
+ if args.command == "build":
75
+ return run_build(
76
+ args.input_path,
77
+ args.output,
78
+ args.family,
79
+ args.title,
80
+ args.ancestor_inbreeding,
81
+ args.variant_name,
82
+ )
83
+ parser.error("Unknown command.")
84
+ return 2
85
+
86
+
87
+ def run_validate(input_path: str) -> int:
88
+ pedigree, messages = load_with_messages(input_path)
89
+ print_validation_result(pedigree.source_path, messages)
90
+ return 1 if any(message.severity == "error" for message in messages) else 0
91
+
92
+
93
+ def run_preview(input_path: str) -> int:
94
+ pedigree, messages = load_with_messages(input_path)
95
+ summaries = summarize_pedigree(pedigree)
96
+ print(f"Source: {pedigree.source_path}")
97
+ print(f"Families: {len(summaries)}")
98
+ print(f"Individuals: {pedigree.people_count()}")
99
+ for summary in summaries:
100
+ print(
101
+ f"- {summary.family_id}: {summary.people_count} individuals, "
102
+ f"{summary.founders_count} founders, "
103
+ f"{summary.relationship_count} parent references, "
104
+ f"{summary.generation_count} generations"
105
+ )
106
+ if messages:
107
+ print()
108
+ print_validation_messages(messages)
109
+ return 1 if any(message.severity == "error" for message in messages) else 0
110
+
111
+
112
+ def run_build(
113
+ input_path: str,
114
+ output_path: str | None,
115
+ family_filter: str | None,
116
+ title: str | None,
117
+ ancestor_inbreeding: float | None,
118
+ variant_name: str | None = None,
119
+ ) -> int:
120
+ pedigree, messages = load_with_messages(input_path)
121
+ if family_filter:
122
+ if family_filter not in pedigree.families:
123
+ print(
124
+ f"Family '{family_filter}' was not found in the input.", file=sys.stderr
125
+ )
126
+ return 1
127
+ pedigree.families = {family_filter: pedigree.families[family_filter]}
128
+ if any(message.severity == "error" for message in messages):
129
+ print_validation_result(pedigree.source_path, messages)
130
+ return 1
131
+
132
+ summaries = summarize_pedigree(pedigree)
133
+ family_layouts = {
134
+ summary.family_id: build_family_layout(pedigree.families[summary.family_id])
135
+ for summary in summaries
136
+ }
137
+ ancestor_inbreeding_value = (
138
+ ancestor_inbreeding if ancestor_inbreeding is not None else 0.0
139
+ )
140
+ output = (
141
+ Path(output_path)
142
+ if output_path
143
+ else default_output_path(input_path, family_filter)
144
+ )
145
+ report_title = title or f"pedview report: {Path(input_path).stem}"
146
+ html = render_report(
147
+ pedigree=pedigree,
148
+ family_layouts=family_layouts,
149
+ family_summaries=summaries,
150
+ messages=[message for message in messages if message.severity == "warning"],
151
+ title=report_title,
152
+ ancestor_inbreeding=ancestor_inbreeding_value,
153
+ used_default_ancestor_inbreeding=ancestor_inbreeding is None,
154
+ variant_name=variant_name,
155
+ )
156
+ output.write_text(html, encoding="utf-8")
157
+ if ancestor_inbreeding is None:
158
+ print(
159
+ "Using Wright ancestor inbreeding coefficient f_a = 0.0 (default). "
160
+ "Pass --ancestor-inbreeding to override."
161
+ )
162
+ else:
163
+ print(
164
+ f"Using Wright ancestor inbreeding coefficient f_a = {ancestor_inbreeding_value:.4f}."
165
+ )
166
+ print(f"Wrote {output}")
167
+ return 0
168
+
169
+
170
+ def load_with_messages(input_path: str):
171
+ try:
172
+ pedigree = parse_pedigree(input_path)
173
+ except PedigreeFormatError as exc:
174
+ print(f"[ERROR] {exc}", file=sys.stderr)
175
+ raise SystemExit(1) from exc
176
+ messages = [*pedigree.messages, *validate_pedigree(pedigree)]
177
+ return pedigree, messages
178
+
179
+
180
+ def default_output_path(input_path: str, family_filter: str | None) -> Path:
181
+ input_file = Path(input_path)
182
+ suffix = f".{family_filter}" if family_filter else ""
183
+ return input_file.with_suffix(f"{suffix}.html")
184
+
185
+
186
+ def print_validation_result(source_path: str, messages) -> None:
187
+ print(f"Validation result for {source_path}")
188
+ if not messages:
189
+ print("No validation issues found.")
190
+ return
191
+ print_validation_messages(messages)
192
+ error_count = sum(message.severity == "error" for message in messages)
193
+ warning_count = sum(message.severity == "warning" for message in messages)
194
+ print(f"{error_count} error(s), {warning_count} warning(s)")
195
+
196
+
197
+ def print_validation_messages(messages) -> None:
198
+ for message in messages:
199
+ print(message.format_for_cli())
pedview/insights.py ADDED
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cache
4
+
5
+ from .layout import assign_generations
6
+ from .models import Family
7
+ from .phenotypes import affected_status_from_metadata
8
+ from .segregation import analyze_family_segregation
9
+
10
+
11
+ def build_family_insights(
12
+ family: Family, variant_name: str | None = None
13
+ ) -> dict[str, object]:
14
+ family.ensure_relationships()
15
+ generations = assign_generations(family)
16
+ order_index = family.order_index()
17
+ people = family.members
18
+ person_ids = list(family.order)
19
+ founder_ids = [
20
+ person_id
21
+ for person_id, person in people.items()
22
+ if person.father_id not in people and person.mother_id not in people
23
+ ]
24
+ founder_id_set = set(founder_ids)
25
+ affected_status = {
26
+ person_id: affected_status_from_metadata(person.metadata)
27
+ for person_id, person in people.items()
28
+ }
29
+ parent_map = {
30
+ person_id: (
31
+ person.father_id if person.father_id in people else None,
32
+ person.mother_id if person.mother_id in people else None,
33
+ )
34
+ for person_id, person in people.items()
35
+ }
36
+ children_by_parent = {person_id: [] for person_id in person_ids}
37
+ partners_by_person = {person_id: set() for person_id in person_ids}
38
+ for relationship in family.relationships:
39
+ if relationship.father_id in people and relationship.mother_id in people:
40
+ partners_by_person[relationship.father_id].add(relationship.mother_id)
41
+ partners_by_person[relationship.mother_id].add(relationship.father_id)
42
+ for parent_id in relationship.parent_ids:
43
+ if parent_id in people:
44
+ children_by_parent[parent_id].extend(relationship.child_ids)
45
+
46
+ for parent_id, child_ids in children_by_parent.items():
47
+ children_by_parent[parent_id] = sorted(
48
+ set(child_ids),
49
+ key=lambda child_id: order_index.get(child_id, 0),
50
+ )
51
+
52
+ @cache
53
+ def descendants(person_id: str) -> tuple[str, ...]:
54
+ found: set[str] = set()
55
+ for child_id in children_by_parent.get(person_id, ()):
56
+ if child_id in found:
57
+ continue
58
+ found.add(child_id)
59
+ found.update(descendants(child_id))
60
+ return tuple(sorted(found, key=lambda child_id: order_index.get(child_id, 0)))
61
+
62
+ def summarize_people(
63
+ target_ids: list[str] | tuple[str, ...] | set[str],
64
+ ) -> dict[str, int]:
65
+ ids = list(target_ids)
66
+ return {
67
+ "total": len(ids),
68
+ "affected": sum(
69
+ affected_status.get(person_id) == "affected" for person_id in ids
70
+ ),
71
+ "unaffected": sum(
72
+ affected_status.get(person_id) == "unaffected" for person_id in ids
73
+ ),
74
+ "unknown_status": sum(
75
+ affected_status.get(person_id) == "unknown" for person_id in ids
76
+ ),
77
+ "male": sum(people[person_id].sex == "male" for person_id in ids),
78
+ "female": sum(people[person_id].sex == "female" for person_id in ids),
79
+ "unknown_sex": sum(
80
+ people[person_id].sex not in {"male", "female"} for person_id in ids
81
+ ),
82
+ }
83
+
84
+ generation_stats: list[dict[str, int]] = []
85
+ for generation in sorted(set(generations.values())):
86
+ ids = [
87
+ person_id
88
+ for person_id in person_ids
89
+ if generations.get(person_id) == generation
90
+ ]
91
+ stats = summarize_people(ids)
92
+ stats["generation"] = generation
93
+ generation_stats.append(stats)
94
+
95
+ founder_stats = summarize_people(founder_ids)
96
+
97
+ def sibling_ids_for(person_id: str) -> list[str]:
98
+ father_id, mother_id = parent_map[person_id]
99
+ sibling_ids: set[str] = set()
100
+ for parent_id in (father_id, mother_id):
101
+ if parent_id is None:
102
+ continue
103
+ sibling_ids.update(children_by_parent.get(parent_id, ()))
104
+ sibling_ids.discard(person_id)
105
+ return sorted(
106
+ sibling_ids, key=lambda sibling_id: order_index.get(sibling_id, 0)
107
+ )
108
+
109
+ person_summaries: dict[str, dict[str, object]] = {}
110
+ family_flags: list[dict[str, object]] = []
111
+ flags_by_person = {person_id: [] for person_id in person_ids}
112
+
113
+ def add_flag(
114
+ *,
115
+ code: str,
116
+ severity: str,
117
+ message: str,
118
+ person_id: str,
119
+ related_ids: list[str] | None = None,
120
+ ) -> None:
121
+ flag = {
122
+ "code": code,
123
+ "severity": severity,
124
+ "message": message,
125
+ "person_id": person_id,
126
+ "related_ids": related_ids or [],
127
+ }
128
+ flags_by_person[person_id].append(flag)
129
+ family_flags.append(flag)
130
+
131
+ for person_id in person_ids:
132
+ father_id, mother_id = parent_map[person_id]
133
+ parent_ids = [parent_id for parent_id in (father_id, mother_id) if parent_id]
134
+ sibling_ids = sibling_ids_for(person_id)
135
+ child_ids = children_by_parent.get(person_id, [])
136
+ descendant_ids = list(descendants(person_id))
137
+ partner_ids = sorted(
138
+ partners_by_person.get(person_id, set()),
139
+ key=lambda partner_id: order_index.get(partner_id, 0),
140
+ )
141
+ first_degree_ids = sorted(
142
+ set(parent_ids) | set(sibling_ids) | set(child_ids),
143
+ key=lambda relative_id: order_index.get(relative_id, 0),
144
+ )
145
+
146
+ person_summaries[person_id] = {
147
+ "partners": partner_ids,
148
+ "parents": {
149
+ "ids": parent_ids,
150
+ **summarize_people(parent_ids),
151
+ },
152
+ "siblings": {
153
+ "ids": sibling_ids,
154
+ **summarize_people(sibling_ids),
155
+ },
156
+ "children": {
157
+ "ids": child_ids,
158
+ **summarize_people(child_ids),
159
+ },
160
+ "descendants": {
161
+ "ids": descendant_ids,
162
+ **summarize_people(descendant_ids),
163
+ },
164
+ "first_degree": {
165
+ "ids": first_degree_ids,
166
+ **summarize_people(first_degree_ids),
167
+ },
168
+ }
169
+
170
+ person_status = affected_status.get(person_id)
171
+ explicit_parent_statuses = [
172
+ affected_status[parent_id]
173
+ for parent_id in parent_ids
174
+ if affected_status.get(parent_id) in {"affected", "unaffected"}
175
+ ]
176
+ if (
177
+ person_status == "affected"
178
+ and len(parent_ids) == 2
179
+ and explicit_parent_statuses == ["unaffected", "unaffected"]
180
+ ):
181
+ add_flag(
182
+ code="affected_with_unaffected_parents",
183
+ severity="warning",
184
+ message="Affected individual has two recorded unaffected parents.",
185
+ person_id=person_id,
186
+ related_ids=parent_ids,
187
+ )
188
+ if (
189
+ person_status == "unaffected"
190
+ and len(parent_ids) == 2
191
+ and explicit_parent_statuses == ["affected", "affected"]
192
+ ):
193
+ add_flag(
194
+ code="unaffected_with_affected_parents",
195
+ severity="warning",
196
+ message="Unaffected individual has two recorded affected parents.",
197
+ person_id=person_id,
198
+ related_ids=parent_ids,
199
+ )
200
+ if (
201
+ person_status == "affected"
202
+ and parent_ids
203
+ and not any(
204
+ affected_status.get(relative_id) == "affected"
205
+ for relative_id in first_degree_ids
206
+ )
207
+ ):
208
+ add_flag(
209
+ code="isolated_affected_case",
210
+ severity="notice",
211
+ message="Affected individual has no affected first-degree relatives recorded.",
212
+ person_id=person_id,
213
+ )
214
+
215
+ branch_summaries: list[dict[str, object]] = []
216
+ for relationship in family.relationships:
217
+ if relationship.parent_ids and not all(
218
+ parent_id in founder_id_set for parent_id in relationship.parent_ids
219
+ ):
220
+ continue
221
+ branch_member_ids = set(relationship.parent_ids)
222
+ branch_descendant_ids: set[str] = set()
223
+ for child_id in relationship.child_ids:
224
+ branch_descendant_ids.add(child_id)
225
+ branch_descendant_ids.update(descendants(child_id))
226
+ branch_member_ids.update(branch_descendant_ids)
227
+ member_ids = sorted(
228
+ branch_member_ids, key=lambda person_id: order_index.get(person_id, 0)
229
+ )
230
+ descendant_ids = sorted(
231
+ branch_descendant_ids,
232
+ key=lambda person_id: order_index.get(person_id, 0),
233
+ )
234
+ label = (
235
+ " + ".join(relationship.parent_ids)
236
+ if relationship.parent_ids
237
+ else relationship.relationship_id
238
+ )
239
+ branch_summaries.append(
240
+ {
241
+ "label": label,
242
+ "root_ids": list(relationship.parent_ids),
243
+ "member_count": len(member_ids),
244
+ "descendant_count": len(descendant_ids),
245
+ "affected_count": sum(
246
+ affected_status.get(member_id) == "affected"
247
+ for member_id in member_ids
248
+ ),
249
+ "affected_descendant_count": sum(
250
+ affected_status.get(descendant_id) == "affected"
251
+ for descendant_id in descendant_ids
252
+ ),
253
+ }
254
+ )
255
+
256
+ branch_summaries.sort(
257
+ key=lambda item: (
258
+ -int(item["affected_descendant_count"]),
259
+ -int(item["descendant_count"]),
260
+ str(item["label"]),
261
+ )
262
+ )
263
+
264
+ segregation = analyze_family_segregation(family, variant_name=variant_name)
265
+ if segregation:
266
+ for err in segregation.get("mendelian_errors", []):
267
+ flag = {
268
+ "severity": "warning",
269
+ "person_id": err["person_id"],
270
+ "message": f"Mendelian Inconsistency: {err['message']}",
271
+ }
272
+ family_flags.append(flag)
273
+ flags_by_person[err["person_id"]].append(flag)
274
+ for dn_id in segregation.get("de_novo_ids", []):
275
+ flag = {
276
+ "severity": "notice",
277
+ "person_id": dn_id,
278
+ "message": "Candidate De Novo variant: mutation observed in child but neither biological parent.",
279
+ }
280
+ family_flags.append(flag)
281
+ flags_by_person[dn_id].append(flag)
282
+
283
+ family_flags.sort(
284
+ key=lambda flag: (
285
+ {"warning": 0, "notice": 1}.get(str(flag["severity"]), 2),
286
+ order_index.get(str(flag["person_id"]), 0),
287
+ str(flag["message"]),
288
+ )
289
+ )
290
+
291
+ for person_id in person_ids:
292
+ person_summaries[person_id]["flags"] = flags_by_person[person_id]
293
+
294
+ return {
295
+ "generation_stats": generation_stats,
296
+ "founder_stats": founder_stats,
297
+ "branch_summaries": branch_summaries,
298
+ "family_flags": family_flags,
299
+ "person_summaries": person_summaries,
300
+ "segregation": segregation,
301
+ }