zombi2.genomes¶
Level 2: genomes evolving along the species tree. The level has three resolutions — family ⊂ ordered ⊂ nucleotide — and one entry point each; more detail costs more compute, so the resolution is a dial.
zombi2.genomes.simulate_genomes_family
¶
simulate_genomes_family(tree, *, duplication=0.0, transfer=0.0, loss=0.0, origination=0.0, transfer_to='uniform', replacement=False, self_transfer=False, initial_families=100, families=None, max_family_size=10, joint=False, seed=None, parallel=False, stream_to=None, outputs=None, progress=False, **retired) -> 'FamilyGenomesResult | StreamedRun'
Evolve a multiset of gene families along a species tree by duplication, transfer, loss, and origination.
tree is the complete species tree (a Tree, or a
SpeciesResult whose complete_tree is used). Genomes evolve on
every lineage, extant and extinct alike, so the true gene-tree history is complete and a
transfer can arrive "from the dead"; the observed genomes are the extant tips.
Rates (each a scope(base) × modifiers spec): duplication/transfer/loss default
per copy, origination per lineage. When a transfer fires it moves a copy from a
uniformly-chosen donor copy to a recipient lineage alive at that instant, chosen by
transfer_to — "uniform" (any other contemporaneous lineage), "distance" /
Distance(decay=) (closer relatives likelier), Clades({...}, Between({...})) (weighted by
the donor's and recipient's named clade, so transfer can run between two clades — see below),
or Recipients().weighted_by(driver, mapping) (weighted by an evolved value; see below). replacement=True
overwrites a homologous
copy in the recipient (additive fallback if it has none); self_transfer=True lets a lineage
donate to itself. The root starts with initial_families families of one copy each, recorded
as originations at the origin. families=[family("toxin")] additionally declares named families —
each gets a normal (integer) family id, but its name is remembered in result.family_names so
you can track a specific family (result.has_family(node, "toxin")); this is the handle a joint
scaled_by("genomes:toxin", …) reads. Deterministic given seed.
A family with rates of its own. families=[family("IS1", transfer=PerCopy(1.5), loss=0.02)]
declares a named family and gives it its own rates, so one family can behave nothing like the rest
of the genome — a mobile element that transfers constantly, a core gene that is almost never lost.
Whatever a family leaves out falls back to the run's rate for that event, so
families=[family("toxin")] is a family declared for its name alone and is exactly
families=[family("toxin")]. family() also takes origin= and module=, which are
origins= and modules= said on the family itself. Origination takes no per-family value:
when it is read the family does not exist yet to have one.
A written rate is that family's rate, in the rate's own units. The run's rate does not reach
it, and neither does a varying_among('families', …) draw meant to vary the run's rate among
families (SPEC §5's argument for set_by, one level down). Two things are refused for now: a
family's rate carrying a verb of its own, and a family's rate beside a driven run rate; both
arrive with the joint step. So does the per-family engine — parallel= and stream_to=
build one set of rates for the whole run, so they refuse a family that writes its own.
A family placed by hand. origins=[("n5", 0.4)] originates a family on lineage n5 at
time 0.4 — the same event the origination rate produces, at a point you choose rather
than one that is drawn. It adds to the run: whatever initial_families and origination
give you is still there, so origins with both of those at 0 is a tree carrying exactly the
families you placed, and origins beside them is an ordinary genome with one family planted
where you want it. The time is the run's own clock and must fall inside that lineage's life
(None puts it at the branch's start); a placed family is an ordinary family from that instant
on, so it duplicates, transfers and is lost like any other, and gets its gene tree the same way.
The ids come straight after the initial and named ones, in the order you wrote the origins, on
either engine — so the family you placed is initial_families + len(family_names). See
resolve_origins.
Conditioning (a trait drives a rate). Any of the four rates may be driven by another level —
loss = PerCopy(0.25).scaled_by("trait_events.tsv", {"aquatic": 3.0, "terrestrial": 1.0}) scales each
lineage's loss by the habitat on that branch, read from a driver file grown first
(traits.simulate_discrete(...).write(dir, outputs=("events",)), which writes
trait_events.tsv). A driven rate is then per-lineage: it is summed over the living lineages (each with its own copy count and driver
value), the affected lineage is drawn weighted by its rate, and the Gillespie steps at every
mid-branch switch of the driver (SPEC §2). For transfer the affected
lineage is the donor, so a driven transfer says how often a lineage donates.
Conditioning (a trait drives who receives). transfer_to =
Recipients().weighted_by(driver, mapping) is
the other half, and a different model: the mapping's numbers are per-candidate weights, not
rate multipliers, so they leave the total amount of transfer alone and only redistribute it
(SPEC §5, a weight, not a rate). Candidate lineage k gets weight mapping(driver value on k now)
and receives with probability w_k / Σw — five candidates at weight 1 and five at weight 2 send
two thirds of transfers to the weight-2 group. Weight 0 means "cannot receive"; when every
candidate weighs 0 the transfer does not happen (see _do_transfer()). The two driven arguments are
independent and may be used together or apart.
parallel opts into a separate engine that evolves the families concurrently, one per worker
process — the families are independent (a transfer roams a copy across lineages but never mixes two
families), so it enumerates every family's origination first and then evolves each on its own. It is
worker-count invariant (each family draws from its own stream spawned off seed) but gives a
different-though-equally-valid draw than the serial default for a given seed. False (default)
runs the serial loop; True uses every core; an int sets the worker count. A driven rate
or a weighted transfer_to runs on the per-family engine too — conditioning does not couple
families, so nothing here forces a fallback. The gain is real but modest (a merge over the whole event log stays
serial), so a handful of workers is the sweet spot; unlike the sequences level it does not scale far.
Because it spawns processes, a calling script must guard its entry with if __name__ ==
"__main__": (the zombi2 CLI already does).
stream_to=DIR takes the same engine to the many-families regime: each family is written straight
to disk as it finishes — no whole-run merge, no run held in memory (a run that fills gigabytes in
memory streams in tens of megabytes) — and a light StreamedRun handle comes
back instead of a FamilyGenomesResult. outputs= picks which files, as
FamilyGenomesResult.write() takes them minus summary (a streamed run writes no
genome_summary.json); the default is all six. It is the per-family engine, and
outputs without stream_to is an error.
Source code in zombi2/genomes/family.py
969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 | |
zombi2.genomes.simulate_genomes_ordered
¶
simulate_genomes_ordered(tree, *, duplication=0.0, transfer=0.0, loss=0.0, origination=0.0, inversion=0.0, transposition=0.0, translocation=0.0, chromosomes=1, topology='circular', fission=0.0, fusion=0.0, chromosome_origination=0.0, chromosome_loss=0.0, duplication_extent=None, loss_extent=None, transfer_extent=None, inversion_extent=None, transposition_extent=None, translocation_extent=None, inversion_probability=0.0, transfer_to='uniform', replacement=False, self_transfer=False, initial_families=100, families=None, joint=False, max_family_size=10, seed=None, progress=False, **retired) -> OrderedGenomesResult
Evolve ordered genomes — genes with a position and an orientation, on chromosomes — along a species tree, by the D/T/L/O core plus segmental rearrangements and the chromosome events.
Every gene-level event acts on an extent — a run of consecutive genes (the ZOMBI1 model):
duplication copies the run in tandem, loss removes it, transfer sends it to a
contemporaneous recipient as a block, inversion reverses it (flipping strands), transposition
relocates it elsewhere on the same chromosome, and translocation moves it to a different
chromosome. The run's extent is drawn per event from <event>_extent (a distribution,
default Geometric(mean=1) — usually a single gene; dial the mean up for larger blocks).
origination is the exception: a family is born once, a single gene, no extent.
transposition and translocation land the moved block inverted with probability
inversion_probability.
Where a run stops is set by the chromosome's topology. A run goes rightwards from the gene
it starts at. On a "circular" chromosome there are no ends, so a run that reaches the last
gene continues from the first, and only the whole chromosome bounds it; on a "linear" one the
run stops at the last gene. So on a circular chromosome every gene is covered by segmental events
at the same rate, and the nominal mean extent is the realised one.
Scopes follow the cross-level grammar, which counts an event per the thing it acts on: the
gene-level events — duplication/transfer/loss and the rearrangements
inversion/transposition/translocation — are per copy, since each acts on a run of
genes that starts at one of them; the chromosome rates fission/fusion/chromosome_loss
are per chromosome; and the two events that make something from nothing,
origination/chromosome_origination, are per lineage. The
run starts with chromosomes chromosomes of the given topology, across which the
initial_families founding genes are dealt round-robin; families=[family("toxin")] additionally
declares named families (remembered in result.family_names for result.has_family(node,
"toxin")), as in the family core; replacement / self_transfer behave as in the family
core. So does transfer_to, which chooses who receives — "uniform",
"distance" / Distance(decay=) (closer relatives likelier), Clades({...}, Between({...}))
(weight by the donor's and recipient's named clade) or Recipients().weighted_by(driver, mapping) (weight by
another level; see below). What moves is a block of genes rather than a single copy, and the block
arrives whole, so the rule chooses the recipient lineage exactly as it does at the family
resolution.
The chromosome events change chromosome number: fission (split), fusion (merge,
between two chromosomes of the same topology — the reticulation; a ring and a molecule with
two ends cannot become one molecule, so a genome of one of each never fuses),
chromosome_origination (a de-novo replicon), chromosome_loss (a whole
chromosome and its genes die; never the genome's last). Chromosomes carry identity — re-minted at
every event that reshapes them — so chromosome_events is the true reticulating chromosome
genealogy, rooted at the initial and de-novo originations. Deterministic given seed.
A family placed by hand. origins=[("n5", 0.4)] originates a family on lineage n5 at
time 0.4 — the ordinary origination event, at a point you choose rather than one that is
drawn, and here too the founding gene lands on a uniformly-chosen chromosome and position. It
adds to whatever initial_families and origination already give you. See
resolve_origins.
Conditioning (a trait drives a rate). Any rate here may be driven by another level —
inversion = PerCopy(0.3).scaled_by(habitat, {"host": 4.0, "free": 1.0}) scales each lineage's
inversion rate by the habitat on that branch, read from a trait grown first (the finished
TraitsResult, or the trait_events.tsv it wrote). A driven rate is then per lineage: it is
summed over the living lineages, each read with its own gene count, chromosome count and driver
value; the lineage an event lands on is drawn with those same weights, and the gene inside it
uniformly, because the gene count is already in the weight. The Gillespie steps at every
mid-branch switch of the driver rather than averaging over a branch (SPEC §2). For transfer
the driven lineage is the donor, so a driven transfer says how often a lineage donates.
Conditioning (a trait drives who receives). transfer_to =
Recipients().weighted_by(driver, mapping) is
the other half, and a different model: the mapping's numbers are per-candidate weights, not
rate multipliers, so they leave the total amount of transfer alone and only redistribute it
(SPEC §5, a weight, not a rate). Candidate lineage k gets weight mapping(driver value on k now)
and receives with probability w_k / Σw. Weight 0 means "cannot receive"; when every candidate
weighs 0 the transfer does not happen at all, and the donor's chromosome is left untouched. A
Between({...}) mapping reads the donor's value too, so transfer can be steered between
guilds; Clades({...}, Between({...})) is the same steering by named clade, read off the tree
instead of a driver. Because a weight is not a rate, a driven transfer_to adds no Gillespie
breakpoint and composes freely with a driven transfer rate.
Conditioning (a trait drives an extent). An extent takes the same modifiers a rate does
(SPEC §6) — inversion_extent = Extent(4).scaled_by(habitat, {"host": 3.0, "free": 1.0}) makes a
host-restricted lineage invert longer runs of genes, which is a different statement from raising
its inversion rate. An extent's modifier is read at the instant an event fires, so it changes how
much a run takes and never how often one starts, and it adds no Gillespie breakpoint.
a per-family draw and a driven rate cannot be set in the same run: one weights lineages by a driver and the other weights the segment by what it covers.
Source code in zombi2/genomes/ordered.py
1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 | |
zombi2.genomes.simulate_genomes_nucleotide
¶
simulate_genomes_nucleotide(tree, *, inversion=0.0, inversion_extent=50.0, translocation=0.0, translocation_extent=50.0, transposition=0.0, transposition_extent=50.0, inversion_probability=0.0, loss=0.0, loss_extent=50.0, deletion=0.0, deletion_extent=5.0, insertion=0.0, insertion_extent=5.0, duplication=0.0, duplication_extent=50.0, transfer=0.0, transfer_extent=50.0, transfer_to='uniform', self_transfer=False, origination=0.0, origination_extent=50.0, fission=0.0, fusion=0.0, chromosome_origination=0.0, chromosome_loss=0.0, chromosomes=1, root_length=1000, topology='circular', genes=0, gene_length=100, gff=None, fasta=None, modules=None, trim_overlaps=False, seed=None, progress=False) -> NucleotideGenomesResult
Evolve a nucleotide genome along a species tree by inversion, translocation, transposition,
loss, duplication, transfer, origination, and the chromosome events.
The run starts from a karyotype — chromosomes replicons, each its own source: an int
N gives N equal replicons of root_length/topology, or pass a list of (length,
topology) for heterogeneous sizes and shapes. Each lineage inherits a copy of its parent's
karyotype at speciation, with every chromosome re-minted (the chromosome network), and evolves:
inversion(per lineage) reverses a geometric-length (meaninversion_extent) arc of a length-weighted chromosome.translocation(per lineage) moves a geometric-length (meantranslocation_extent) arc to a different chromosome;transposition(per lineage, meantransposition_extent) moves one within its chromosome. Both land inverted with probabilityinversion_probability, keep source coordinates, and are rearrangements, not edges.loss(per lineage) deletes a geometric-length (meanloss_extent) arc — an ancestry-changing event (a death), recorded inevents. Never empties a chromosome.deletion(per lineage, meandeletion_extent) removes an arc as an indel: the same material goes, but no copy lineage ends, so it is recorded indeletionsand not in the genealogy, and its breakpoints do not cut the root partition. The pair divides like this —losschanges what a lineage has (a copy dies, a gene can go whole),deletionchanges how much of a surviving copy it carries. In practice that is a difference of scale: hundreds to thousands of base pairs against ones to tens.insertion(per lineage, meaninsertion_extent) lays down a run of novel spacer at a legal position — the twin ofdeletion, andoriginationwithout the gene. Novel DNA descends from nothing, so it arrives on a fresh source under a fresh copy lineage and is recorded ineventsas a root; the one breakpoint it makes to open a gap for itself is indel-made, so the block it landed inside is not cut in two. The pair divides the same way:originationbrings a new gene family into the run,insertionbrings sequence.duplication(per lineage) copies a geometric-length (meanduplication_extent) arc in tandem — an ancestry-changing birth, recorded inevents.transfer(per lineage) copies a geometric-length (meantransfer_extent) arc into a contemporaneous recipient (transfer_to:"uniform","distance"/ aDistance,Clades({...}, Between({...}))orRecipients().weighted_by(driver, mapping)— see below;self_transferallows the donor itself) — a horizontal birth, additive (the donor keeps its copy). This is what needs the global timeline.origination(per lineage) lays down a new gene on a fresh source (geometric length, meanorigination_extent) — a birth of a wholly new family, indivisible from birth.fission(per chromosome) splits a chromosome in two (a bifurcation);fusion(per chromosome) merges two chromosomes of the same topology (the reticulation).chromosome_origination(per lineage) adds a de-novo circular replicon (a plasmid, a network root) carrying one new gene;chromosome_loss(per chromosome) kills a whole chromosome (its material dies as a loss; never the last one) — a network leaf. All record a chromosome-network edge.
A chromosome never exists without a gene. A replicon is born with one, and any event that would strip a chromosome of its last gene — a loss, a translocation carrying it away, a fission splitting off a geneless half — simply does not happen. (Vacuous when no genes are declared.)
The engine runs a global-timeline Gillespie: all lineages alive at once evolve along one clock
(every segmental event is per lineage — the rate says how often a lineage does it, the extent how
much it touches, so a bigger genome does not get proportionally more events; the chromosome rates
are per chromosome), so a transfer couples two contemporaries. With loss, the strong invariant
weakens: every node carries a subset of the initial sequence (each ancestral position at most
once, monotonically down every path); origination further adds fresh sources beyond the root.
Deterministic given seed. (Transfer is always additive.)
Driving out of the genome. A gene declared by a GFF is named, and a named gene answers is it
in this lineage, right now? — result.presence("dnaA"), the driver the other two resolutions
already hand out. modules={"flagellum": ["flgA", "flgB"]} groups declared genes, so
result.completion("flagellum") gives the fraction of the group a lineage carries; a module
changes nothing about how the genome evolves.
Conditioning (a trait drives who receives). transfer_to =
Recipients().weighted_by(driver, mapping)
weights the candidate recipients by another level, and the numbers are weights, not rate
multipliers: they are normalised across the candidates, so they leave the total amount of transfer
alone and only redistribute it (SPEC §5, a weight, not a rate). Weight 0 means "cannot receive"; when
every candidate weighs 0 the transfer does not happen. A Between({...}) mapping reads the
donor's value too, so transfer can be steered between guilds rather than merely into one, and
Clades({...}, Between({...})) is the same steering by named clade, read off the tree instead
of a driver. Since a transfer here is additive, steering changes only which lineage the arc lands
on — never how much DNA moves, and never anything about the donor.
Source code in zombi2/genomes/nucleotide.py
2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 | |
Results¶
One result type per resolution, each carrying the true history behind the dataset: the gene trees, the event log, and the genomes themselves.
zombi2.genomes.FamilyGenomesResult
dataclass
¶
FamilyGenomesResult(complete_tree: Tree, node_genomes: dict[int, tuple[GeneCopy, ...]], edges: list[GeneEdge], seed: int | None, family_names: dict[str, int] = dict(), modules: dict[str, tuple[str, ...]] = dict(), initial_genome: tuple[GeneCopy, ...] = (), max_family_size: int | None = None)
What simulate_genomes_family returns: the complete_tree it ran on, the final
node_genomes at every node (extant and extinct), the events log (the compact source of
truth), and the seed. The observed dataset is the extant tips, genomes. The phyletic
profiles are derived from those tips on access, and write materialises the chosen outputs
to disk.
genomes
property
¶
The observed dataset — the genome at each extant tip, keyed by the tip name the
tree writes: n5.
Keyed by name because the only thing anyone does with this is join it to the tree, or to
another level grown on that tree, and both name their tips. genomes.tsv on disk is
keyed by name too, so what you get in Python and what you get from a file are the same
dataset — they used to share no keys at all, and nothing said so. This is the trait level's
TraitsResult.values for gene content.
node_genomes is the run's own record: every node, extant and extinct and internal alike,
keyed by node id. Use that one to join against complete_tree.nodes or the event log.
profiles
cached
property
¶
The phyletic profiles — each gene family's copy count in each extant species — derived
from the observed genomes (the classic comparative-genomics matrix). See profiles.
events
cached
property
¶
The genome events — one per row of genome_events.tsv, the same objects the writer
formats.
edges is the finer record this is grouped from: one entry per gene-tree edge, so a
duplication is two of them and a transfer likewise. That is the shape a gene tree is built
out of, and it used to be what this attribute returned — which meant counting duplications in
Python gave twice the file's number, and a filter on kind == "transfer" matched everything
here and nothing there. One word, one meaning: an event is what the log has a row for.
gene_trees
cached
property
¶
{family id: GeneTree} — each family's true genealogy inside the complete tree,
derived from the event log. Each GeneTree exposes .complete and .extant. See
gene_trees.
family_counts
¶
A multiset view of one node's genome: family id → copy count.
completion
¶
A module's completion as a conditioning driver — ModuleCompletion, a number in
[0, 1]: the fraction of the module's families a lineage carries.
Read it with a Curve, the way any continuous driver is read; a threshold goes there rather
than here (lambda f: 8.0 if f > 0.8 else 1.0).
Source code in zombi2/genomes/family.py
presence
¶
The named family's presence as a conditioning driver — GenePresence.
has_family answers for one node; this answers for every lineage at every instant, which
is what a driven rate needs::
switch=PerLineage(0.1).scaled_by(g.presence("tox"), {"present": 5.0, "absent": 1.0})
Source code in zombi2/genomes/family.py
has_family
¶
Whether the named family name (declared via families=) is present — has ≥ 1 copy — in
the genome at node_id. The presence a joint scaled_by("genomes:<name>", …) reads as its driver.
Source code in zombi2/genomes/family.py
summary
¶
What this run produced, as a plain dict — the payload of genome_summary.json.
Every count here is one per event, which is also what genome_events.tsv is now one row
of. They are counted from the GeneEdge objects, and those are one per gene-tree edge: a
duplication, a transfer and a speciation each end one gene and start two, so counting edges
inflates them exactly 2×. A duplication's two edges share the parent gene they descend
from, and a gene ends at exactly one event, so distinct parents are the events.
loss counts every gene that died, which under replacement is more than the log's
loss rows: a copy displaced by an arriving transfer has no row of its own — it is the
second parent of that transfer_replacing row, because its death and the transfer are one
event. It is a loss of the gene tree all the same, and that is what this counts.
The family counts are the other thing nobody could reconcile: gene_trees/ holds a file pair
per family that ever existed, while the run's summary line counts the ones that survived, so
"96 gene families" sat next to 213 files with nothing to explain the gap. Both numbers are
here, named.
origination counts only the families that arose during the run: the initial genome is
logged as origination at the root's own start time, so a bare count of that kind is de-novo
arrivals plus initial_families, which is a number nobody asked for. They are separate here.
And the cap, which was invisible. When max_family_size binds it discards duplications and
arriving transfers, so realised rates fall below the declared ones — so this reports which
families are sitting at it, because that is the signal a reader can act on.
empty_genomes is the other end of the same story. There is no floor at this
resolution: loss is counted per gene copy and the last copy is a copy like any other, so a
high loss rate can strip a lineage of every gene it has. That is a real outcome of the model,
not a failure — but it is invisible in the outputs, because a genome with no genes writes no
row in profiles.tsv and leaves no gene tree for a sequence to run down. This is the
number that says it happened, before a reader wonders why the matrix is short. (The ordered
and nucleotide resolutions do have a floor, but it is a statement about what a chromosome is
— a loss never takes a chromosome below its last gene — not a bound on genome size.)
Source code in zombi2/genomes/family.py
write
¶
write(directory, outputs=('events', 'profiles', 'genomes', 'initial_genome', 'gene_trees', 'species_tree', 'summary'), *, flat: bool = False) -> None
Materialise chosen outputs to directory (created if needed):
"events"→genome_events.tsv, the event log (the source of truth)."profiles"→profiles.tsv, the family × extant-species copy-count matrix."genomes"→genomes.tsv, every node's gene content, one row per gene copy — ancestors included, whereprofiles.tsvcounts only the extant tips."initial_genome"→initial_genome.tsv, the genome the run started with. Its own file, not a row ingenomes.tsv, because it belongs to no node: it sits at the start of the root branch, and everylineagein that table is a node at the end of one.-
"gene_trees"→gene_tree_fam<family>_{complete,extant}.nwkundergene_trees/, each family's true genealogy. A family with no surviving copy writes no_extantfile. -
"species_tree"→species_complete.nwk, the tree the run evolved along. Written because a directory of gene trees with no species tree is not a dataset anyone can use: every one of these outputs is indexed by that tree's node labels, and the truth a gene tree is compared against is the species tree it grew inside. A run written from Python used to leave it out entirely, so the quickstart handed back gene trees with nothing to compare them to and said nothing about it. The gene trees are two files per family, so they get a subdirectory rather than burying the tables above;flat=Truewrites everything intodirectoryinstead.
Source code in zombi2/genomes/family.py
zombi2.genomes.OrderedGenomesResult
dataclass
¶
OrderedGenomesResult(complete_tree: Tree, node_genomes: dict[int, tuple[Chromosome, ...]], edges: list[GeneEdge], rearrangements: list[Inversion | Transposition | Translocation], chromosome_events: list[ChromosomeEvent], seed: int | None, family_names: dict[str, int] = dict(), modules: dict[str, tuple[str, ...]] = dict(), event_positions: list[EventPosition] = list(), initial_genome: tuple[Chromosome, ...] = ())
What simulate_genomes_ordered() returns: the complete_tree it ran on, the final
genomes at every node as tuples of Chromosome\ s, the shared gene-genealogy
events log, the rearrangements (inversions, transpositions and translocations) and
chromosome_events (the chromosome genealogy) logs, and the seed. The observed genomes are
the extant tips; profiles and gene_trees are derived from the (position-blind) genealogy
exactly as for the family core; gene_order reads a node's layout, and write materialises
the chosen outputs.
genomes
property
¶
The observed dataset — the genome at each extant tip, keyed by the tip name the
tree writes: n5.
Keyed by name because the only thing anyone does with this is join it to the tree, or to
another level grown on that tree, and both name their tips. The written output is keyed by
name too, so what you get in Python and what you get from a file are the same dataset.
This is the trait level's TraitsResult.values for gene content.
node_genomes is the run's own record: every node, extant and extinct and internal alike,
keyed by node id. Use that one to join against complete_tree.nodes or the event log.
profiles
cached
property
¶
The phyletic profiles — each gene family's copy count in each extant species — derived
from the observed genomes, flattening across chromosomes (position does not enter). See
profiles.
events
cached
property
¶
The genome events — one per row of genome_events.tsv, the same objects the writer
formats.
edges is the finer record this is grouped from: one entry per gene-tree edge, so a
duplication is two of them and a transfer likewise. That is the shape a gene tree is built
out of, and it used to be what this attribute returned — which meant counting duplications in
Python gave twice the file's number, and a filter on kind == "transfer" matched everything
here and nothing there. One word, one meaning: an event is what the log has a row for.
gene_trees
cached
property
¶
{family id: GeneTree} — each family's true genealogy inside the complete tree, derived
from the (position-blind) event log exactly as for the family core. See gene_trees.
family_counts
¶
A multiset view of one node's genome: family id → copy count (across all chromosomes).
Source code in zombi2/genomes/ordered.py
completion
¶
A module's completion as a conditioning driver — ModuleCompletion, a number in
[0, 1]: the fraction of the module's families a lineage carries.
Read it with a Curve, the way any continuous driver is read; a threshold goes there rather
than here (lambda f: 8.0 if f > 0.8 else 1.0).
Source code in zombi2/genomes/ordered.py
presence
¶
The named family's presence as a conditioning driver — GenePresence.
has_family answers for one node; this answers for every lineage at every instant, which
is what a driven rate needs::
switch=PerLineage(0.1).scaled_by(g.presence("tox"), {"present": 5.0, "absent": 1.0})
Source code in zombi2/genomes/ordered.py
has_family
¶
Whether the named family name (declared via family_names=) has ≥ 1 copy in the genome at
node_id (across all chromosomes).
Source code in zombi2/genomes/ordered.py
gene_order
¶
One node's layout as (chromosome, position, strand, family, gene id) rows, chromosome
by chromosome and left to right within each — the ordered analogue of family_counts.
Source code in zombi2/genomes/ordered.py
write
¶
write(directory, outputs=('events', 'profiles', 'gene_order', 'initial_genome', 'gene_trees', 'chromosome_events', 'species_tree', 'summary'), *, flat: bool = False) -> None
Materialise chosen outputs to directory (created if needed):
"events"→ two tables, because a run does two different things to a genome.genome_events.tsvis the gene genealogy — one row per event, in the format every resolution writes — with where each event happened beside it.rearrangement_events.tsvis the ancestry-neutral rearrangements: an inversion, a transposition or a translocation begins and ends no gene lineage, so it has no parents and no children and nothing to say in those columns. The two used to be one table, which meant nine columns empty on every rearrangement row and six on every genealogy row. Together withgene_orderthey are enough to replay the run."profiles"→profiles.tsv, the family × extant-species copy-count matrix."gene_order"→gene_order.tsv, every node's layout (one row per gene), ancestors included — so a branch's rearrangements can be replayed from its parent's genome."initial_genome"→initial_genome.tsv, the layout the run started with. Its own file, not a row ingene_order.tsv, because it belongs to no node: it sits at the start of the root branch, and everylineagein that table is a node at the end of one."chromosome_events"→chromosome_events.tsv, the chromosome genealogy edges. The one log kept apart: it is a network over chromosome ids, with list-valued parents and children, joined on a different key from everything above."gene_trees"→gene_tree_fam<family>_{complete,extant}.nwkundergene_trees/, each family's true genealogy — unchanged from the family resolution, position being orthogonal to it.
The gene trees are two files per family, so they get a subdirectory rather than burying the
tables above; flat=True writes everything into directory instead.
Source code in zombi2/genomes/ordered.py
summary
¶
What this run produced, as a plain dict — the payload of genome_summary.json.
The corrected event counts are the reason this exists. genome_events.tsv's loss
rows undercount real losses whenever replacement is on, because a copy displaced by an
arriving transfer has no row of its own; the migration guide names that as the change most
likely to hand a returning user a plausible wrong number, and points them here. This file used
to be written only at the family resolution — so the advice was sound and the remedy was
absent at the two resolutions where the gap is larger (64% at ordered, measured).
event_counts is shared with the other two resolutions, so the three cannot drift. The rest
is what this resolution has and the family core does not: where the genes sit, and what moved
them.
Source code in zombi2/genomes/ordered.py
zombi2.genomes.NucleotideGenomesResult
dataclass
¶
NucleotideGenomesResult(complete_tree: Tree, node_genomes: dict[int, NucleotideGenome], events: list[Origination | Loss | Duplication | Transfer | Speciation], rearrangements: list[Inversion | Translocation | Transposition], chromosome_events: list[ChromosomeEvent], seed: int | None, gene_spans: dict[int, tuple[int, int, int]] = dict(), gene_names: dict[str, int] = dict(), modules: dict[str, tuple[str, ...]] = dict(), gene_strands: dict[int, int] = dict(), initial_genome: NucleotideGenome = (lambda: NucleotideGenome([]))(), initial_sequence: dict[int, str] = dict(), deletions: list[Deletion] = list())
What simulate_genomes_nucleotide() returns: the complete_tree it ran on, the final
nucleotide genomes (karyotypes) at every node, the copy-lineage genealogy events
(origination, loss, duplication, transfer, speciation — carrying the copy ids
the gene-tree recovery reads), the ancestry-neutral rearrangements (inversion, translocation,
transposition), the chromosome_events (the chromosome network), and the seed. mosaic
/ trace_back / ancestry read a node's genome.
genomes
property
¶
The observed dataset — the genome at each extant tip, keyed by the tip name the
tree writes: n5.
Keyed by name because the only thing anyone does with this is join it to the tree, or to
another level grown on that tree, and both name their tips. The written output is keyed by
name too, so what you get in Python and what you get from a file are the same dataset.
This is the trait level's TraitsResult.values for gene content.
node_genomes is the run's own record: every node, extant and extinct and internal alike,
keyed by node id. Use that one to join against complete_tree.nodes or the event log.
indels
property
¶
How many indels this run fired — deletions plus insertions. Both leave a lineage carrying
part of a root block rather than all of it, which is the thing assembly() cannot yet put
back together, so they are counted together and asked about in one place.
root_blocks
property
¶
The recovered root partition: (source, start, end) for each maximal never-cut
interval that some node still carries — one per block_trees entry (by index).
Cut at the breakpoints of every node's genome, not only the extant leaves', which is what
lets any node be reconstructed (assembly()) rather than the survivors alone.
block_trees
property
¶
{root-block index: GeneTree} — a tree for every recovered root block, spacer as
well as genes, keyed by its index in root_blocks.
gene_trees covers the declared genes; this covers the whole genome. A block never
splits, so its size is fixed and its genealogy is in the event log just as a gene's is — the
recovery is the same one, pointed at every block instead of a chosen few. That is what makes
an ancestral genome reconstructable at any node rather than only at the loci you declared.
A gene's tree here has the same topology and branch lengths as its gene_trees one,
but not the same g<id> leaf labels: segment ids are handed out as the recovery walks its
targets, and walking every block numbers them differently from walking three. Use one accessor
or the other within a piece of analysis — they are the same genealogy under different names.
gene_trees
property
¶
{family: GeneTree} — the recovered gene trees.
With genes declared, one tree per gene, keyed by its gene family id (see
gene_spans); the intergenic root-blocks keep their block ancestry in the log but are not
built into trees. With no genes declared the whole genome is one big intergene, so every
recovered root-block is a family in its own right and the key is its index in
root_blocks. Every node votes on the partition, so a gene surviving only in lineages
that died still gets a tree — a complete one, with no extant tree to go with it. Only a gene
lost from every node has no root-block and no tree.
genealogy
property
¶
The run's genealogy as GeneEdge — the same table the family
and ordered resolutions write, and what lands in genome_events.tsv.
A nucleotide run's own record, events, is interval-shaped: a copy lineage covers an
extent, an event covers a sub-extent, and a duplication there mints a child without ending
the parent (a split is not a birth). That is the right model for sequence, and the wrong shape
for a gene tree. This is the translation onto the root-block partition, where a copy either
covers a block in full or does not touch it — so a duplication is a bifurcation, writes two
rows sharing a parent, and the ids are gene ids: the ones the gene trees, the alignments
and the homology tables use. It is what the recovery already builds to derive
gene_trees; writing it costs nothing extra.
describe
¶
One node's genome written out block by block, for reading by eye.
mosaic is the same information as data — (source, start, end, strand) per block — and
this is it as text, one line per block, each labelled with the gene it is or intergene:
n2, chromosome 2
[ 0, 600) + 600 bp intergene
[ 600,1000) + 400 bp gene 1
A block is a stretch of DNA with one unbroken ancestry, written as the interval it came from on the initial sequence and the strand it is read on, so a gene keeps its coordinates wherever it turns up and whichever way it points. A gene is always one block, because nothing may cut one; a run of intergene lines is spacer that events have cut apart.
Genes declared with a name (from a GFF) are named; the rest are numbered by family.
Source code in zombi2/genomes/nucleotide.py
completion
¶
A module's completion as a conditioning driver — ModuleCompletion, a number in
[0, 1]: the fraction of the module's genes a lineage carries.
Read it with a Curve, the way any continuous driver is read; a threshold goes there rather
than here (lambda f: 8.0 if f > 0.8 else 1.0).
Source code in zombi2/genomes/nucleotide.py
presence
¶
The named gene's presence as a conditioning driver — GenePresence, the same reader
the other two resolutions hand out, read off the gene's own recovered tree::
switch=PerLineage(0.1).scaled_by(g.presence("dnaA"), {"present": 5.0, "absent": 1.0})
A gene is named here by the GFF that declared it (its ID / Name); the evenly-spaced
genes= layout lays its genes down unnamed.
Source code in zombi2/genomes/nucleotide.py
block_of
¶
The index in root_blocks of the block a declared gene family occupies — the join
between the two numbering schemes this resolution has.
They are both plain ints over overlapping ranges, so mixing them up is silent: gene_spans
and gene_trees are keyed by gene family id, while root_blocks, block_trees and
everything a sequence run produces here are keyed by block index (every block evolves, and
spacer has no family). block_of is how you get from a gene to its sequences::
r.alignments[g.block_of(g.gene_names["dnaA"])] # that gene's alignment
Raises KeyError for a family that was never declared, and LookupError for one declared
but surviving nowhere at all — it has no recovered block, so there is nothing to point at.
The reverse lookup is one line: {span: fam for fam, span in g.gene_spans.items()} read at
root_blocks[i].
Source code in zombi2/genomes/nucleotide.py
assembly
¶
How this node's genome is built out of the recovered root blocks:
{chromosome id: [(block, gene, strand, lo, hi), …]} in physical order, where block
indexes root_blocks, gene is the gene id that block's tree gives this node's copy (the
g<id> label in block_trees), strand is +1 read forward or -1
reverse-complemented, and [lo, hi) is the half-open sub-range of that block this node
carries, offset from the block's own start.
The sub-range is what an indel makes necessary and is the whole of the presence bookkeeping:
a deletion leaves a lineage holding a block minus a stretch of its middle, and an insertion
opens a gap inside one, so a piece is a part of a block rather than all of it. Without
indels every piece is the whole block — lo is 0 and hi its length — so the shape says
the same thing it always did, at one extra pair of numbers.
To reconstruct a genome: take [lo, hi) of each piece's block sequence, flip the
-1\ s, and concatenate. The sequence level does exactly that; nothing here knows about
letters. Every node works — an extinct leaf and the root as readily as a surviving tip —
which is what makes the whole history recoverable rather than only its leaves.
initial_assembly() does the same for the genome the run started with.
Every node votes on where the partition is cut (see _root_block_partition()), so a node's
every event breakpoint is in it and no piece ever straddles two blocks. What a block is cut
into is one piece per root block it spans — and on a reversed block those come out in
descending coordinate order, since physical order runs down the source. Only an indel
breakpoint, deliberately absent from the partition, makes a piece narrower than the block it
indexes.
Source code in zombi2/genomes/nucleotide.py
initial_assembly
¶
assembly() for initial_genome: {chromosome id: [(block, strand, lo, hi), …]}.
No gene id here, unlike assembly(), and that is the honest shape rather than a saving.
The initial genome sits at the start of the root branch, before any event, so each of its
blocks has exactly one sequence — the founding draw the sequence level records as
founding[block] — and there is no copy to disambiguate. A gene id would in fact be wrong
here: the one assembly() gives is the last gene a copy held, and for an initial copy
that is at the far end of the stem. A loss on the stem can even end it, which is the same
thing said louder.
Source code in zombi2/genomes/nucleotide.py
write
¶
write(directory, outputs=('events', 'genes', 'blocks', 'initial_genome', 'initial_sequence', 'gene_trees', 'chromosome_events', 'gff', 'bed', 'species_tree', 'summary'), *, flat: bool = False) -> None
Materialise chosen outputs to directory (created if needed):
"events"→ three tables, because a nucleotide run records three different things.genome_events.tsvis the genealogy (genealogy) in the format every resolution writes, so one reader serves them all: one row per event, its participants namedn<species>_g<copy>.block_events.tsvis this resolution's own record — the copy-lineage log over ancestral intervals, one row per interval an event touched, so an event spanning several blocks writes several rows sharing atimeandkind.rearrangement_events.tsvis the ancestry-neutral rearrangements, which begin and end no lineage and so have nothing to put inparentsandchildren. The last two are whatread_nucleotide_genomes()replays."blocks"→blocks.tsv, every node's genome as its block mosaic (ancestors included, as for the ordered resolution'sgene_order). The one big file here: blocks are not kept maximal during a run, so a rearrangement-heavy genome carries far more of them than it has distinct ancestral runs, and this grows with their number × every node."genes"→genes.tsv, the declared genes and where they sit in root coordinates. Header-only for a run that declared none."initial_genome"→initial_genome.tsv, the block mosaic the run started with. Its own file, not a row inblocks.tsv, because it belongs to no node: it sits at the start of the root branch, and everylineagein that table is a node at the end of one."chromosome_events"→chromosome_events.tsv, the chromosome network's edges. The one log kept apart: it is a network over chromosome ids, with list-valued parents and children, joined on a different key from everything above."gene_trees"→gene_tree_fam<family>_{complete,extant}.nwk, one recovered genealogy per family some node still carries; the_extantfile only where the family has a surviving copy."initial_sequence"→initial_sequence.fasta, the initial DNA the run was given (fasta=), one>source<n>record per replicon. Written only when a FASTA was supplied — it is what lets a separatezombi2 sequencesrun found its blocks from the real sequence."gff"→genome_<lineage>.gffundergff/, that genome's genes, in its own coordinates: the annotation to read beside the sequence level'sgenome_<lineage>.fasta."bed"→genome_<lineage>.bedunderbed/, that genome's blocks — every piece, spacer included, named by the ancestral interval it descends from. The ancestry as a browser track.
Both name their sequences <lineage>_chr<c>, exactly as the FASTA records are named, so a
genome and its annotation join without renaming anything. Written for every node and for the
initial genome, so there are two files per genome.
gene_trees, gff and bed are one file per family or per node — thousands, on a real
genome times a real tree — so each gets a subdirectory rather than burying the tables above;
flat=True writes everything into directory instead.
Source code in zombi2/genomes/nucleotide.py
1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 | |
summary
¶
What this run produced, as a plain dict — the payload of genome_summary.json.
The same correction as the other two resolutions: a GeneEdge is one gene-tree edge, so a
duplication, a transfer and a speciation each end one gene and start two, and counting edges
inflates them exactly 2×; this is the corrected count. (A transfer here is always additive,
with no replacement option, so no displaced copy to fold in.) event_counts is shared
with them, reading genealogy — the GeneEdge translation every resolution writes — so the
three agree by construction rather than by three separate implementations agreeing by luck.
The unit here is the base pair, so there are no phyletic profiles and no per-family copy counts to report; what this resolution has instead is how much sequence there is and how it is divided.
Source code in zombi2/genomes/nucleotide.py
zombi2.genomes.StreamedRun
dataclass
¶
A genome run written straight to disk, family by family — what stream_to= returns, for a
scale where a whole FamilyGenomesResult would not fit in memory. Thin by design:
the outputs are the files and the disk is the handoff (the sequences level reads them back), so
this carries where they are and how big the run was, not the run itself.
path
¶
The path of a written top-level file — e.g. path("events") → …/genome_events.tsv.
Gene trees are not a single file; they live one pair per family under gene_trees/.
Source code in zombi2/genomes/_perfamily.py
Reading a run back¶
A written run is a genome run too: read_run reopens one from its directory, and the sequence level
accepts a directory or a StreamedRun wherever it accepts a result.
zombi2.genomes.read_run
¶
Reopen a genome run written to directory — the run object, from its files.
Takes a run directory in either layout (out/ with a genomes/ inside it, or a flat=True
directory), or a StreamedRun handle. The event log is the source of truth, so gene_trees,
events and the whole genealogy come back exactly; genomes and initial_genome come from
their own tables when the run wrote them, and are empty otherwise.
This is what simulate_sequences calls when handed a path, so the two-command CLI pipeline and
a two-step Python one are the same pipeline::
st = genomes.simulate_genomes_family(sp, ..., stream_to="run/")
sequences.simulate_sequences(st, model=hky85(), length=1000, seed=1) # reads it back
The seed of a reopened run is the seed recorded on the handle when there is one, and None
from a bare directory: the files are the run, and a number that did not produce them would be a
lie. It is in the run's own run.zombi2 / genomes.log either way.
Source code in zombi2/genomes/read.py
Gene trees¶
zombi2.genomes.GeneTree
dataclass
¶
One gene family's true genealogy. complete is the whole tree (lost and extinct-species
lineages included); extant is it pruned to the genes surviving at the extant tips (degree-two
nodes suppressed), or None if the family left no extant gene. to_newick serialises either.
origination is when the family was founded — the exact time of its origination event, or the
root lineage's start for a family declared by initial_families. A GeneNode records when
it ended, so this is the one time the tree cannot derive: it is where the root's branch begins.
to_newick
¶
to_newick(which: str = 'extant', *, annotate: bool = True, names: 'dict[int, str] | None' = None, labels: str = 'events') -> str | None
Newick of the "extant" (default) or "complete" tree; None if it is empty.
Leaves are n<species>_g<copy> — the copy and the branch it sits on, the same name the
alignment FASTA records and the homology tables use, so a tip needs no translation to say
which genome it came from. With annotate internal nodes carry <kind>_n<species>;
branch lengths are time differences.
names is the run's node names (Tree.labels()), which is how a tree writes
e<species> for a gene sitting on a lineage that died. The extant tree needs them too,
which is not obvious: its leaves are all in living species, but a transfer node sits on the
donor's branch, and a transfer out of a lineage that later died survives into the extant
tree whenever the copy that moved has extant descendants. Without names that node was
written n<species> for a species the extant tree does not contain and the complete tree
calls e<species> — a label that resolved in neither file, on exactly the transfers whose
donor is invisible.
labels picks what internal nodes are called. "events" (default) is the
annotated form above. "copies" names every internal node the way the leaves are
named, n<species>_g<copy> — the key the alignment and ancestral tables use — so a
per-copy quantity (an ancestral sequence, a composition) can be attached to every
branch of the tree, not only its tips. Event kinds are then not in the labels; they
remain on the GeneNode objects.
The root carries one too, running from origination to where the root gene ended — the
stem of the family, real time in which that founding gene existed. On the extant tree the
root may be a node whose ancestors were suppressed; its branch still starts at origination
and so absorbs them, exactly as the species tree's extant root absorbs its own.
Source code in zombi2/genomes/gene_trees.py
zombi2.genomes.GeneCopy
dataclass
¶
One gene copy: a member of family family, identified by a globally-unique id. Its
birth/death times and parentage live in the event log (the source of truth); the copy carries
only what a genome snapshot needs to be self-describing — who it is and which family it is in. A
genome may hold several copies sharing a family (that family's copy count).
Who receives a transfer¶
transfer_to chooses who receives a horizontal transfer. It redistributes the transfers without
changing how many happen. A weight of 0 means "cannot receive", and when every candidate weighs 0 the
transfer does not fire. The numbers are weights, not rate multipliers, so a driven rule is written
from its own entry point, transfer_to = Recipients().weighted_by(driver, mapping), with no base in
front of it — a rate written there is an error.
zombi2.genomes.Distance
dataclass
¶
A transfer_to weighting by relatedness: a recipient at patristic distance d from the
donor gets weight exp(-decay × d / depth), where depth is the tree's mean root-to-tip
time — so decay is scale-free (in units of tree depth), meaning the same across trees of
different absolute timescales. transfer_to="distance" is Distance(decay=1.0).
zombi2.genomes.Clades
dataclass
¶
A transfer_to weighting by named clades — the topological, donor-conditioned sibling of
Distance. Each group is a clade of the species tree, and a
Between kernel weights a candidate recipient by the pair (donor's
clade, recipient's clade), so a transfer can be steered to run between two clades rather than
within them — which the per-recipient weight of a Driven cannot
express::
transfer_to = Clades({"A": ["n12", "n27"], "B": 40},
Between({("A", "B"): 1.0, ("B", "A"): 1.0}, default=0.0))
A clade is named either by a set of tips (a list — the clade is the subtree below their MRCA) or
by a single node id (an int, or an "n<id>" label — the clade is that node's whole subtree).
Groups must be disjoint; a lineage in none of them is in the implicit group "rest", usable as a
kernel key. Membership is read from the tree (a clade is a fact about the tree, not another
level), so this is a topological rule like "distance", resolved once per run — not a
weighted_by reading another level, and needing no driver file.
zombi2.genomes.Between
¶
A weight over ordered (donor-group, recipient-group) pairs — the 2-D kernel of the transfer
choice of who receives (SPEC §5), the donor-conditioned sibling of Table::
Between({("A", "B"): 1.0, ("B", "A"): 1.0}, default=0.0) # A↔B only, nothing else receives
Between({("A", "B"): 3.0}) # A→B 3× baseline, every other pair 1×
A Table weights a candidate recipient by that candidate's state alone; a Between
weights it by the pair — the donor's group and the recipient's — which is what lets a transfer
be steered to run between two groups rather than within them. It is therefore not a
Mapping (a Mapping.multiplier reads one value): its weight() reads two, and the
engine passes both. It is used in transfer_to — on its own as the kernel of a
Clades rule (groups from the tree), or as the mapping of a
Recipients().weighted_by(...) (groups from a trait). It is not a rate multiplier:
a rate has no donor to condition on, so a Between on a rate is refused.
Keys are (from_group, to_group) pairs matched by string form, exactly like Table's
states, so an integer-labelled group still finds its entry. default (1.0) is the weight for any
pair not named — default=0.0 gives the "only the flows I name can happen" idiom, reusing the
rule that a weight of 0 means the donor cannot send to that recipient group;
when every candidate weighs 0 the transfer has nowhere to land and does not fire.
Source code in zombi2/params/mapping.py
weight
¶
The weight for a transfer from a from_group donor to a to_group recipient — the
named pair's weight, or default if the pair is unnamed.
Source code in zombi2/params/mapping.py
groups
¶
Every group named on either side of a pair — what a fires-check tests against the groups that actually occur, so a kernel naming only absent groups (a typo) can be caught.