Skip to content

zombi2.joint

Two levels that cannot be run in order, grown in one run. A pair must be run jointly when neither level can be grown first — a trait that drives speciation, or gene content that does. When the driver can be grown first, the run is conditioned instead: two ordinary runs in order, the finished driver handed to the second as an object (in Python) or as its written log (across two commands). See Dependent runs and Joint runs.

A level joined to itself stays on that level's own function, with joint=True: simulate_genomes_family for a gene family driving the rest of its genome, and simulate_traits for two traits that depend on each other.

zombi2.joint.simulate

simulate(*participants, tree=None, genomes=None, seed=None, record: bool = False, max_lineages=100000) -> JointResult

Simulate two levels at once, because neither can be finished before the other starts (SPEC §2–4).

Each participant is a process spec, and you give what you are not simulating::

# the tree is one of the two, so it comes out of the run
joint.simulate(species.birth_death(birth=faster_if_large, death=0.2, n_extant=100),
               traits.discrete(name="size", states=["small", "large"], switch=0.1), seed=1)

joint.simulate(species.birth_death(birth=faster_with_toxin, n_extant=100),
               genomes.genome(origination=0.2, loss=0.1, families=[family("toxin")]), seed=1)

A rate reads the other participant by name, "<level>:<handle>""traits:size" for a named trait, "genomes:toxin" for a declared family, "genomes:count" for a lineage's whole gene count. A run holding one unnamed trait also answers to "trait".

~zombi2.traits.continuous is a participant too, and it is the one driver that does not race exactly: a diffusion moves at every instant, so the run holds it fixed across a step= written on the connection and releases it at each boundary.

The species tree is an output exactly when ~zombi2.species.birth_death is one of the participants; otherwise tree supplies it. A level driving itself does not come here at all — that is one level and one result, so it stays on that level's own function with joint=True.

Returns a JointResult. Deterministic given seed.

Source code in zombi2/joint/__init__.py
def simulate(*participants, tree=None, genomes=None, seed=None, record: bool = False,
             max_lineages=100_000) -> JointResult:
    """Simulate two levels **at once**, because neither can be finished before the other starts
    (SPEC §2–4).

    Each participant is a process spec, and you **give what you are not simulating**::

        # the tree is one of the two, so it comes out of the run
        joint.simulate(species.birth_death(birth=faster_if_large, death=0.2, n_extant=100),
                       traits.discrete(name="size", states=["small", "large"], switch=0.1), seed=1)

        joint.simulate(species.birth_death(birth=faster_with_toxin, n_extant=100),
                       genomes.genome(origination=0.2, loss=0.1, families=[family("toxin")]), seed=1)

    A rate reads the other participant by **name**, ``"<level>:<handle>"`` — ``"traits:size"`` for a
    named trait, ``"genomes:toxin"`` for a declared family, ``"genomes:count"`` for a lineage's whole
    gene count. A run holding one unnamed trait also answers to ``"trait"``.

    `~zombi2.traits.continuous` is a participant too, and it is the one driver that does not race
    exactly: a diffusion moves at every instant, so the run holds it fixed across a ``step=`` written
    on the connection and releases it at each boundary.

    The species tree is an output exactly when `~zombi2.species.birth_death` is one of the
    participants; otherwise ``tree`` supplies it. A level driving **itself** does not come here at
    all — that is one level and one result, so it stays on that level's own function with
    ``joint=True``.

    Returns a `JointResult`. Deterministic given ``seed``.
    """
    kinds = _classify(participants)
    n_species, n_traits, n_genomes = (len(kinds[k]) for k in ("species", "traits", "genomes"))
    if kinds["sequences"]:
        return _traits_and_sequences(kinds, tree=tree, genomes=genomes, seed=seed,
                                     record=record)
    if record:
        raise ValueError(
            "record= keeps the SEQUENCE level's own history, and only a run holding that level has "
            "one to keep. The trait and genome levels record theirs always, in trait.events and "
            "genome.events.")
    if genomes is not None:
        raise ValueError(
            "genomes= hands over a finished genome run, and only a sequence participant needs one — "
            "a sequence lives on the gene trees it produced. Drop it, or add "
            "sequences.gene(name=..., model=..., length=...).")
    if n_species == 0:
        return _on_a_given_tree(kinds, tree=tree, seed=seed)
    if tree is not None:
        raise ValueError(
            "the species tree is one of the things this run simulates, so it comes out rather than "
            "going in: drop tree=, or drop species.birth_death(...) and hand the tree over.")
    if n_species > 1:
        raise ValueError("give one species.birth_death(...) — a run grows one tree.")
    if n_traits + n_genomes != 1:
        raise ValueError(
            "give exactly one level for the tree to be simulated with: traits.discrete(...) or "
            f"genomes.genome(...). Got {n_traits} trait(s) and {n_genomes} genome(s).")
    spec = kinds["species"][0]
    driver = kinds["traits"][0] if n_traits else kinds["genomes"][0]
    return _simulate_joint(birth=spec.birth, death=spec.death,
                          n_extant=spec.n_extant, total_time=spec.total_time,
                          seed=seed, max_lineages=max_lineages,
                          **({"trait": driver} if n_traits else {"genome": driver}))

zombi2.joint.JointResult dataclass

JointResult(species: SpeciesResult, seed: int | None, trait: TraitsResult | None = None, genome: FamilyGenomesResult | None = None, sequences: 'Any | None' = None)

What simulate() returns — both simulated levels of a joint run. species is the grown tree (a SpeciesResult: complete_tree, extant_tree, the speciation/extinction events); the driver level that grew with it is either trait (a TraitsResult, for a trait→speciation run) or genome (a FamilyGenomesResult, for a gene-content→speciation run) — exactly one is set. The tree is an output, grown by the driver it carries, so the levels share one complete_tree.

events property

events: list

The species events (speciation / extinction). The driver level's own events are trait.events / genome.events.

summary

summary() -> dict

What this run produced, as a plain dict — the payload of joint_summary.json.

A joint run grew two levels at once, so this holds both of their summaries under one roof rather than inventing a third vocabulary: species is the tree that came out, and exactly one of trait / genome is the driver that shaped it. The tree is an output here, which is the whole point of the command, so its realised birth and death rates are the numbers worth reading — they are what the driver did.

Source code in zombi2/joint/__init__.py
def summary(self) -> dict:
    """What this run produced, as a plain dict — the payload of ``joint_summary.json``.

    A joint run grew two levels at once, so this holds both of their summaries under one roof
    rather than inventing a third vocabulary: ``species`` is the tree that came out, and exactly
    one of ``trait`` / ``genome`` is the driver that shaped it. The tree is an *output* here, which
    is the whole point of the command, so its realised birth and death rates are the numbers worth
    reading — they are what the driver did."""
    out = {"level": "joint", "seed": self.seed,
           "driver": "trait" if self.trait is not None else "genome",
           "species": self.species.summary()}
    if self.sequences is not None:
        out["sequences"] = self.sequences.summary()
    if self.trait is not None:
        out["trait"] = self.trait.summary()
    if self.genome is not None:
        out["genome"] = self.genome.summary()
    return out

write

write(directory, outputs=_WRITE_OUTPUTS, *, flat: bool = False) -> None

Write both levels to directory (created if needed), each exactly as its own command writes it: "species" → the SpeciesResult files (species_complete.nwk / species_extant.nwk / species_events.tsv / species_fates.tsv / species_summary.json); "driver" → the level that grew with it, a trait's trait_values.tsv / trait_events.tsv / trait_tree.nwk / trait_summary.json or a genome's genome_events.tsv / profiles.tsv / genomes.tsv / initial_genome.tsv / gene_trees/ / genome_summary.json; "summary"joint_summary.json, the one file that is the joint run's own.

The tokens are the two levels, not their files, because each is written with that level's own default — which is what makes a joint run's directory the two runs it stands in for. Pick files within a level through the level itself: result.species.write(d, outputs=…), result.trait.write(d, outputs=…). flat is passed to the driver level, the only one of the two with a many-files-per-run output.

Both levels land in the one directory named here; zombi2 joint groups them under species/ and traits/ / genomes/ instead, and writes the same files.

Source code in zombi2/joint/__init__.py
def write(self, directory, outputs=_WRITE_OUTPUTS, *, flat: bool = False) -> None:
    """Write both levels to ``directory`` (created if needed), each exactly as its own command
    writes it: ``"species"`` → the `SpeciesResult` files (``species_complete.nwk`` /
    ``species_extant.nwk`` / ``species_events.tsv`` / ``species_fates.tsv`` /
    ``species_summary.json``); ``"driver"`` → the level that grew with it, a trait's
    ``trait_values.tsv`` / ``trait_events.tsv`` / ``trait_tree.nwk`` / ``trait_summary.json`` or
    a genome's ``genome_events.tsv`` / ``profiles.tsv`` / ``genomes.tsv`` /
    ``initial_genome.tsv`` / ``gene_trees/`` / ``genome_summary.json``; ``"summary"`` →
    ``joint_summary.json``, the one file that is the joint run's own.

    The tokens are the two **levels**, not their files, because each is written with that level's
    own default — which is what makes a joint run's directory the two runs it stands in for. Pick
    files *within* a level through the level itself: ``result.species.write(d, outputs=…)``,
    ``result.trait.write(d, outputs=…)``. ``flat`` is passed to the driver level, the only one of
    the two with a many-files-per-run output.

    Both levels land in the one directory named here; ``zombi2 joint`` groups them under
    ``species/`` and ``traits/`` / ``genomes/`` instead, and writes the same files."""
    unknown = [o for o in outputs if o not in _WRITE_OUTPUTS]
    if unknown:
        raise ValueError(f"unknown write outputs {unknown}; choose from {list(_WRITE_OUTPUTS)}")
    d = pathlib.Path(directory)
    d.mkdir(parents=True, exist_ok=True)
    if "summary" in outputs:
        write_summary(d / "joint_summary.json", self.summary())
    if "species" in outputs:
        self.species.write(d)
    if "driver" in outputs:
        # two independent tests, not an if/else: a result carrying neither writes neither, rather
        # than reaching for `.write` on None
        if self.trait is not None:
            self.trait.write(d)
        if self.genome is not None:
            self.genome.write(d, flat=flat)
        if self.sequences is not None:
            self.sequences.write(d, flat=flat)