phytreon 0.1.1__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.
phytreon/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """phytreon -- phylogenetic trees and publication figures in Python.
2
+
3
+ Quick start
4
+ -----------
5
+ >>> import phytreon as pt
6
+ >>> tr = pt.datasets.primates()
7
+ >>> (pt.TreeFigure(tr)
8
+ ... .tip_labels()
9
+ ... .support_labels()).save("tree.pdf")
10
+
11
+ The package is layered:
12
+
13
+ * ``phytreon.core`` -- ``Tree`` / ``Node`` data model and I/O
14
+ * ``phytreon.layout`` -- topology -> display coordinates
15
+ * ``phytreon.infer`` -- distance-based inference (NJ / UPGMA), ML, parsimony
16
+ * ``phytreon.comparative`` -- ancestral state reconstruction
17
+ * ``phytreon.plot`` -- the ``TreeFigure`` builder + matplotlib/plotly backends
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from . import core, layout, infer, comparative, plot, datasets, treeops
22
+ from .core import Tree, Node
23
+ from .plot import TreeFigure
24
+ from .infer import (
25
+ neighbor_joining, upgma, tree_from_alignment, distance_matrix,
26
+ Alignment, align, read_fasta, trim, bootstrap_support, build_tree, infer_ml,
27
+ ml_tree, log_likelihood, model_finder, parsimony_tree, parsimony_score,
28
+ )
29
+ from .comparative import ace_parsimony, ace_ml, ace_continuous, stochastic_map
30
+ from .treeops import (
31
+ rotate, flip, swap_children, ladderize, collapse_low_support,
32
+ scale_clade, cut_tree, midpoint_root, group_clade, group_otu,
33
+ robinson_foulds,
34
+ )
35
+
36
+ __version__ = "0.1.1"
37
+
38
+ __all__ = [
39
+ "core", "layout", "infer", "comparative", "plot", "datasets",
40
+ "Tree", "Node",
41
+ "TreeFigure",
42
+ "neighbor_joining", "upgma", "tree_from_alignment", "distance_matrix",
43
+ "Alignment", "align", "read_fasta", "trim", "bootstrap_support",
44
+ "build_tree", "infer_ml", "ml_tree", "log_likelihood", "model_finder",
45
+ "parsimony_tree", "parsimony_score", "robinson_foulds",
46
+ "ace_parsimony", "ace_ml", "ace_continuous", "stochastic_map",
47
+ "rotate", "flip", "swap_children", "ladderize", "collapse_low_support",
48
+ "scale_clade", "cut_tree", "midpoint_root", "group_clade", "group_otu",
49
+ ]
@@ -0,0 +1,5 @@
1
+ """Comparative methods (ancestral state reconstruction, stochastic mapping)."""
2
+ from .ace import ace_parsimony, ace_ml, ace_continuous
3
+ from .stochastic_mapping import stochastic_map
4
+
5
+ __all__ = ["ace_parsimony", "ace_ml", "ace_continuous", "stochastic_map"]
@@ -0,0 +1,223 @@
1
+ """Ancestral character estimation.
2
+
3
+ Two discrete-trait reconstructions are provided:
4
+
5
+ * :func:`ace_parsimony` -- Fitch parsimony (fast, model-free).
6
+ * :func:`ace_ml` -- marginal maximum likelihood under an equal-rates
7
+ Mk model (Felsenstein pruning for the likelihood, an up/down pass for
8
+ the marginal posterior at every internal node, single rate estimated
9
+ by ML).
10
+
11
+ Both write results onto ``node.data`` so the plotting layer can map them
12
+ to aesthetics (e.g. pie charts / coloured node points at ancestors).
13
+ Continuous-trait reconstruction (PIC / REML) is a documented extension
14
+ point -- see :func:`ace_continuous`.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Dict, List, Optional
19
+
20
+ from ..core.tree import Node, Tree
21
+
22
+
23
+ # --------------------------------------------------------------------------
24
+ # Fitch parsimony
25
+ # --------------------------------------------------------------------------
26
+ def ace_parsimony(tree: Tree, trait: Dict[str, str]) -> Dict[str, object]:
27
+ """Fitch parsimony reconstruction of a discrete trait.
28
+
29
+ ``trait`` maps tip name -> state. Tips missing from the dict are
30
+ treated as fully ambiguous. Each node gets ``data['ace_state']``;
31
+ the function returns ``{"score": int, "states": [...]}``.
32
+ """
33
+ states = sorted({v for v in trait.values()})
34
+ full = set(states)
35
+
36
+ # down-pass: state sets + score
37
+ score = 0
38
+ for node in tree.traverse("postorder"):
39
+ if node.is_leaf:
40
+ s = trait.get(node.name)
41
+ node.data["_pset"] = {s} if s is not None else set(full)
42
+ else:
43
+ sets = [c.data["_pset"] for c in node.children]
44
+ inter = set.intersection(*sets) if sets else set(full)
45
+ if inter:
46
+ node.data["_pset"] = inter
47
+ else:
48
+ node.data["_pset"] = set.union(*sets)
49
+ score += 1
50
+
51
+ # up-pass: resolve, preferring the parent's chosen state
52
+ for node in tree.traverse("preorder"):
53
+ pset = node.data.pop("_pset")
54
+ if node.is_root:
55
+ chosen = sorted(pset)[0]
56
+ else:
57
+ parent_state = node.parent.data["ace_state"]
58
+ chosen = parent_state if parent_state in pset else sorted(pset)[0]
59
+ node.data["ace_state"] = chosen
60
+
61
+ return {"score": score, "states": states}
62
+
63
+
64
+ # --------------------------------------------------------------------------
65
+ # Marginal ML under an equal-rates Mk model
66
+ # --------------------------------------------------------------------------
67
+ def mk_q_builder(k: int, model: str = "ER"):
68
+ """Return ``(build_Q, n_params, init)`` for an Mk rate matrix.
69
+
70
+ ``model``: ``"ER"`` (equal rates, 1 param), ``"SYM"`` (symmetric,
71
+ k(k-1)/2 params), ``"ARD"`` (all rates different, k(k-1) params).
72
+ """
73
+ import numpy as np
74
+ if model == "ER":
75
+ def build(p):
76
+ Q = np.full((k, k), p[0], dtype=float)
77
+ np.fill_diagonal(Q, 0.0)
78
+ for i in range(k):
79
+ Q[i, i] = -Q[i].sum()
80
+ return Q
81
+ return build, 1, [1.0]
82
+ if model == "SYM":
83
+ ut = [(i, j) for i in range(k) for j in range(i + 1, k)]
84
+
85
+ def build(p):
86
+ Q = np.zeros((k, k))
87
+ for v, (i, j) in zip(p, ut):
88
+ Q[i, j] = Q[j, i] = v
89
+ for i in range(k):
90
+ Q[i, i] = -Q[i].sum()
91
+ return Q
92
+ return build, len(ut), [1.0] * len(ut)
93
+ if model == "ARD":
94
+ pairs = [(i, j) for i in range(k) for j in range(k) if i != j]
95
+
96
+ def build(p):
97
+ Q = np.zeros((k, k))
98
+ for v, (i, j) in zip(p, pairs):
99
+ Q[i, j] = v
100
+ for i in range(k):
101
+ Q[i, i] = -Q[i].sum()
102
+ return Q
103
+ return build, len(pairs), [1.0] * len(pairs)
104
+ raise ValueError(f"unknown Mk model {model!r}; use ER/SYM/ARD")
105
+
106
+
107
+ def ace_ml(tree: Tree, trait: Dict[str, str], model: str = "ER",
108
+ default_length: float = 1.0) -> Dict[Node, Dict[str, float]]:
109
+ """Marginal ML ancestral states under an Mk model (``ER``/``SYM``/``ARD``).
110
+
111
+ Returns ``{node: {state: posterior_prob}}`` for internal nodes and writes
112
+ ``data['ace_probs']`` / ``data['ace_state']`` onto every node. Fitted
113
+ rates are stored on ``tree.root.data['_ace_model']``.
114
+ """
115
+ import numpy as np
116
+ from scipy.linalg import expm
117
+ from scipy.optimize import minimize, minimize_scalar
118
+
119
+ states = sorted({v for v in trait.values()})
120
+ k = len(states)
121
+ idx = {s: i for i, s in enumerate(states)}
122
+ pi = np.full(k, 1.0 / k)
123
+ nodes = tree.nodes("postorder")
124
+ build_Q, nparams, init = mk_q_builder(k, model)
125
+
126
+ def branch_p(params, t):
127
+ return expm(build_Q(params) * t)
128
+
129
+ def down_partials(params) -> Dict[Node, "np.ndarray"]:
130
+ D: Dict[Node, np.ndarray] = {}
131
+ for node in nodes:
132
+ if node.is_leaf:
133
+ vec = np.zeros(k)
134
+ s = trait.get(node.name)
135
+ if s is None:
136
+ vec[:] = 1.0
137
+ else:
138
+ vec[idx[s]] = 1.0
139
+ D[node] = vec
140
+ else:
141
+ vec = np.ones(k)
142
+ for c in node.children:
143
+ vec = vec * (branch_p(params, c.length or default_length) @ D[c])
144
+ D[node] = vec
145
+ return D
146
+
147
+ def neg_log_lik(params) -> float:
148
+ if np.any(np.asarray(params) <= 0):
149
+ return 1e18
150
+ D = down_partials(params)
151
+ return -np.log(float(pi @ D[tree.root]) + 1e-300)
152
+
153
+ if nparams == 1:
154
+ r = float(minimize_scalar(lambda x: neg_log_lik([x]),
155
+ bounds=(1e-4, 100.0), method="bounded").x)
156
+ params = [r]
157
+ else:
158
+ params = list(minimize(neg_log_lik, init, method="Nelder-Mead",
159
+ options={"xatol": 1e-3, "fatol": 1e-3,
160
+ "maxiter": 400}).x)
161
+
162
+ D = down_partials(params)
163
+ U: Dict[Node, np.ndarray] = {tree.root: pi.copy()}
164
+ for node in tree.nodes("preorder"):
165
+ if node.is_root:
166
+ continue
167
+ p = node.parent
168
+ msg = U[p].copy()
169
+ for sib in p.children:
170
+ if sib is node:
171
+ continue
172
+ msg = msg * (branch_p(params, sib.length or default_length) @ D[sib])
173
+ U[node] = branch_p(params, node.length or default_length).T @ msg
174
+
175
+ result: Dict[Node, Dict[str, float]] = {}
176
+ for node in tree.traverse():
177
+ post = D[node] * U[node]
178
+ total = post.sum()
179
+ post = post / total if total > 0 else np.full(k, 1.0 / k)
180
+ probs = {states[i]: float(post[i]) for i in range(k)}
181
+ node.data["ace_probs"] = probs
182
+ node.data["ace_state"] = max(probs, key=probs.get)
183
+ if not node.is_leaf:
184
+ result[node] = probs
185
+
186
+ tree.root.data["_ace_model"] = {
187
+ "model": model, "rates": [float(p) for p in params], "states": states,
188
+ "rate": float(params[0]) if nparams == 1 else None,
189
+ }
190
+ return result
191
+
192
+
193
+ # --------------------------------------------------------------------------
194
+ # Continuous traits -- extension point
195
+ # --------------------------------------------------------------------------
196
+ def ace_continuous(tree: Tree, trait: Dict[str, float]) -> Dict[Node, float]:
197
+ """Ancestral states for a continuous trait via Felsenstein's
198
+ independent-contrasts weighted averaging (Brownian motion).
199
+
200
+ Returns ``{node: estimated_value}`` and writes ``data['ace_value']``.
201
+ """
202
+ # postorder: weighted average of children, weights = 1 / (branch len + accumulated var)
203
+ var: Dict[Node, float] = {}
204
+ val: Dict[Node, float] = {}
205
+ for node in tree.traverse("postorder"):
206
+ if node.is_leaf:
207
+ if node.name not in trait:
208
+ raise KeyError(f"missing trait value for tip {node.name!r}")
209
+ val[node] = float(trait[node.name])
210
+ var[node] = 0.0
211
+ else:
212
+ ws, xs, vs = [], [], []
213
+ for c in node.children:
214
+ v = (c.length or 1.0) + var[c]
215
+ ws.append(1.0 / v)
216
+ xs.append(val[c])
217
+ vs.append(v)
218
+ wsum = sum(ws)
219
+ val[node] = sum(w * x for w, x in zip(ws, xs)) / wsum
220
+ var[node] = 1.0 / wsum
221
+ for node in tree.traverse():
222
+ node.data["ace_value"] = val[node]
223
+ return val
@@ -0,0 +1,134 @@
1
+ """Stochastic character mapping.
2
+
3
+ Simulates discrete-trait histories along the branches under an Mk model,
4
+ conditioned on the observed tip states (Nielsen 2002; Huelsenbeck 2003):
5
+
6
+ 1. fit the rate by ML and compute the down-pass conditional likelihoods,
7
+ 2. for each replicate, sample internal node states (preorder, conditioned)
8
+ then simulate the CTMC path along every branch (rejection sampling),
9
+ 3. summarise over replicates: per-node posterior state probabilities and
10
+ per-branch average dwell-time fractions.
11
+
12
+ Results are written to ``node.data`` (``ace_probs``, ``ace_state``,
13
+ ``paint_segments``) so :meth:`TreeFigure.painted_branches` can paint the
14
+ branches and :meth:`TreeFigure.node_pies` can chart node posteriors.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Dict, Optional
19
+
20
+ from ..core.tree import Tree
21
+
22
+
23
+ def stochastic_map(tree: Tree, trait: Dict[str, str], n: int = 200,
24
+ model: str = "ER", rate: Optional[float] = None,
25
+ seed: Optional[int] = None) -> Tree:
26
+ """Stochastic character map a discrete ``trait`` ({tip: state}) over ``tree``.
27
+
28
+ ``model`` is the Mk model (``ER``/``SYM``/``ARD``); ``n`` is the number of
29
+ simulated histories (default 200 -- raise for smoother posteriors).
30
+ """
31
+ import numpy as np
32
+ from scipy.linalg import expm
33
+ from scipy.optimize import minimize, minimize_scalar
34
+ from .ace import mk_q_builder
35
+
36
+ states = sorted({v for v in trait.values()})
37
+ k = len(states)
38
+ idx = {s: i for i, s in enumerate(states)}
39
+ pi = np.full(k, 1.0 / k)
40
+ post = tree.nodes("postorder")
41
+ build_Q, nparams, init = mk_q_builder(k, model)
42
+
43
+ def down(params):
44
+ Q = build_Q(params)
45
+ D = {}
46
+ for node in post:
47
+ if node.is_leaf:
48
+ v = np.zeros(k)
49
+ s = trait.get(node.name)
50
+ if s is None:
51
+ v[:] = 1.0
52
+ else:
53
+ v[idx[s]] = 1.0
54
+ D[id(node)] = v
55
+ else:
56
+ v = np.ones(k)
57
+ for c in node.children:
58
+ v = v * (expm(Q * max(c.length or 0.0, 1e-6)) @ D[id(c)])
59
+ D[id(node)] = v
60
+ return D
61
+
62
+ if rate is not None:
63
+ params = [rate] if nparams == 1 else [rate] * nparams
64
+ else:
65
+ def negll(params):
66
+ if np.any(np.asarray(params) <= 0):
67
+ return 1e18
68
+ return -np.log(pi @ down(params)[id(tree.root)] + 1e-300)
69
+ if nparams == 1:
70
+ params = [float(minimize_scalar(lambda r: negll([r]),
71
+ bounds=(1e-4, 100.0),
72
+ method="bounded").x)]
73
+ else:
74
+ params = list(minimize(negll, init, method="Nelder-Mead",
75
+ options={"maxiter": 400}).x)
76
+
77
+ D = down(params)
78
+ Q = build_Q(params)
79
+ rate = float(params[0])
80
+ P = {id(n): expm(Q * max(n.length or 0.0, 1e-6))
81
+ for n in tree.traverse() if not n.is_root}
82
+ rng = np.random.default_rng(seed)
83
+
84
+ counts = {id(n): np.zeros(k) for n in tree.traverse()}
85
+ dwell = {id(n): np.zeros(k) for n in tree.traverse() if not n.is_root}
86
+
87
+ def sim_branch(a, b, t):
88
+ for _ in range(50):
89
+ segs, cur, rem = [], a, t
90
+ while True:
91
+ q = -Q[cur, cur]
92
+ dt = rng.exponential(1.0 / q) if q > 0 else rem
93
+ if dt >= rem:
94
+ segs.append((cur, rem))
95
+ break
96
+ segs.append((cur, dt))
97
+ rem -= dt
98
+ pr = Q[cur].copy()
99
+ pr[cur] = 0.0
100
+ cur = int(rng.choice(k, p=pr / pr.sum()))
101
+ if cur == b:
102
+ return segs
103
+ return [(a, t / 2), (b, t / 2)] if a != b else [(a, t)]
104
+
105
+ pre = tree.nodes("preorder")
106
+ for _ in range(n):
107
+ s = {}
108
+ pr = pi * D[id(tree.root)]
109
+ s[id(tree.root)] = int(rng.choice(k, p=pr / pr.sum()))
110
+ counts[id(tree.root)][s[id(tree.root)]] += 1
111
+ for node in pre:
112
+ if node.is_root:
113
+ continue
114
+ sp = s[id(node.parent)]
115
+ pr = P[id(node)][sp] * D[id(node)]
116
+ si = int(rng.choice(k, p=pr / pr.sum()))
117
+ s[id(node)] = si
118
+ counts[id(node)][si] += 1
119
+ for st, dt in sim_branch(sp, si, max(node.length or 0.0, 1e-6)):
120
+ dwell[id(node)][st] += dt
121
+
122
+ for node in tree.traverse():
123
+ c = counts[id(node)]
124
+ node.data["ace_probs"] = {states[i]: float(c[i] / n) for i in range(k)}
125
+ node.data["ace_state"] = states[int(c.argmax())]
126
+ for node in tree.traverse():
127
+ if node.is_root:
128
+ continue
129
+ d = dwell[id(node)]
130
+ tot = d.sum() or 1.0
131
+ node.data["paint_segments"] = [(states[i], float(d[i] / tot))
132
+ for i in range(k) if d[i] > 0]
133
+ tree.data["stochastic_map"] = {"rate": rate, "states": states, "n": n}
134
+ return tree
@@ -0,0 +1,5 @@
1
+ """Core data model and I/O."""
2
+ from .tree import Node, Tree
3
+ from . import io
4
+
5
+ __all__ = ["Node", "Tree", "io"]
phytreon/core/io.py ADDED
@@ -0,0 +1,177 @@
1
+ """Tree input/output.
2
+
3
+ We reuse Biopython's robust parsers/writers for the standard formats
4
+ (Newick, Nexus, PhyloXML, NeXML) and convert to/from our :class:`Tree`.
5
+ A tiny self-contained Newick parser is also provided so that the package
6
+ has a zero-config fast path for the common case (and so the data model
7
+ can be used even if Biopython is unavailable).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import io as _io
12
+ import re
13
+ from typing import Optional
14
+
15
+ from .tree import Node, Tree
16
+
17
+ _BIO_FORMATS = {"newick", "nexus", "phyloxml", "nexml", "cdao"}
18
+
19
+
20
+ # --------------------------------------------------------------------------
21
+ # Biopython bridge
22
+ # --------------------------------------------------------------------------
23
+ def from_biopython(bp_tree) -> Tree:
24
+ """Convert a ``Bio.Phylo`` tree into a phytreon :class:`Tree`."""
25
+ def convert(clade) -> Node:
26
+ node = Node(
27
+ name=clade.name,
28
+ length=clade.branch_length,
29
+ support=getattr(clade, "confidence", None),
30
+ comment=getattr(clade, "comment", None),
31
+ )
32
+ for child in clade.clades:
33
+ node.add_child(convert(child))
34
+ return node
35
+
36
+ root = convert(bp_tree.root)
37
+ return Tree(root=root, name=getattr(bp_tree, "name", None))
38
+
39
+
40
+ def to_biopython(tree: Tree):
41
+ from Bio.Phylo.BaseTree import Clade, Tree as BioTree
42
+
43
+ def convert(node: Node) -> Clade:
44
+ clade = Clade(
45
+ branch_length=node.length,
46
+ name=node.name,
47
+ confidence=node.support,
48
+ )
49
+ clade.clades = [convert(c) for c in node.children]
50
+ return clade
51
+
52
+ return BioTree(root=convert(tree.root), name=tree.name)
53
+
54
+
55
+ def read(path: str, fmt: str = "newick") -> Tree:
56
+ if fmt not in _BIO_FORMATS:
57
+ raise ValueError(f"unsupported format {fmt!r}; choose from {_BIO_FORMATS}")
58
+ from Bio import Phylo
59
+ return from_biopython(Phylo.read(path, fmt))
60
+
61
+
62
+ def write(tree: Tree, path: Optional[str] = None, fmt: str = "newick") -> Optional[str]:
63
+ """Write to ``path``; if ``path`` is ``None`` return the string."""
64
+ if fmt == "newick" and path is None:
65
+ return to_newick(tree)
66
+ from Bio import Phylo
67
+ bp = to_biopython(tree)
68
+ if path is None:
69
+ buf = _io.StringIO()
70
+ Phylo.write(bp, buf, fmt)
71
+ return buf.getvalue()
72
+ Phylo.write(bp, path, fmt)
73
+ return None
74
+
75
+
76
+ # --------------------------------------------------------------------------
77
+ # Lightweight self-contained Newick parser / writer
78
+ # --------------------------------------------------------------------------
79
+ _TOKEN = re.compile(r"\s*([(),;])\s*|\s*([^(),;]+)\s*")
80
+
81
+
82
+ def parse_newick(newick: str) -> Tree:
83
+ """Parse a Newick string into a :class:`Tree` (no external deps).
84
+
85
+ Supports branch lengths (``name:0.1``), internal labels / support
86
+ values, and quoted names. Square-bracket comments (e.g. NHX) are
87
+ captured verbatim onto ``node.comment``.
88
+ """
89
+ s = newick.strip()
90
+ if not s.endswith(";"):
91
+ s += ";"
92
+
93
+ pos = 0
94
+ n = len(s)
95
+
96
+ def parse_clade() -> Node:
97
+ nonlocal pos
98
+ node = Node()
99
+ if s[pos] == "(":
100
+ pos += 1 # consume '('
101
+ while True:
102
+ node.add_child(parse_clade())
103
+ if s[pos] == ",":
104
+ pos += 1
105
+ continue
106
+ if s[pos] == ")":
107
+ pos += 1
108
+ break
109
+ _parse_label(node)
110
+ return node
111
+
112
+ def _parse_label(node: Node) -> None:
113
+ nonlocal pos
114
+ # optional name/support
115
+ start = pos
116
+ while pos < n and s[pos] not in "():,;[":
117
+ pos += 1
118
+ label = s[start:pos].strip().strip("'\"")
119
+ if label:
120
+ if node.is_leaf:
121
+ node.name = label
122
+ else:
123
+ # internal label is usually a support value
124
+ try:
125
+ node.support = float(label)
126
+ except ValueError:
127
+ node.name = label
128
+ # optional comment [...]
129
+ if pos < n and s[pos] == "[":
130
+ depth, cstart = 0, pos + 1
131
+ while pos < n:
132
+ if s[pos] == "[":
133
+ depth += 1
134
+ elif s[pos] == "]":
135
+ depth -= 1
136
+ if depth == 0:
137
+ node.comment = s[cstart:pos]
138
+ pos += 1
139
+ break
140
+ pos += 1
141
+ # optional branch length
142
+ if pos < n and s[pos] == ":":
143
+ pos += 1
144
+ start = pos
145
+ while pos < n and s[pos] not in "():,;[":
146
+ pos += 1
147
+ try:
148
+ node.length = float(s[start:pos])
149
+ except ValueError:
150
+ pass
151
+
152
+ root = parse_clade()
153
+ return Tree(root=root)
154
+
155
+
156
+ def to_newick(tree: Tree, with_support: bool = True) -> str:
157
+ """Serialise a :class:`Tree` back to a Newick string."""
158
+ def fmt(node: Node) -> str:
159
+ if node.is_leaf:
160
+ label = node.name or ""
161
+ else:
162
+ inner = ",".join(fmt(c) for c in node.children)
163
+ sup = ""
164
+ if with_support and node.support is not None:
165
+ sup = _num(node.support)
166
+ elif node.name:
167
+ sup = node.name
168
+ label = f"({inner}){sup}"
169
+ if node.length is not None:
170
+ label += f":{_num(node.length)}"
171
+ return label
172
+
173
+ return fmt(tree.root) + ";"
174
+
175
+
176
+ def _num(x: float) -> str:
177
+ return f"{x:g}"