From c0506e00145d3c90e07b4f731c477b42f16b3875 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 24 Jun 2026 16:14:37 -0700 Subject: [PATCH 1/9] switch to building slim from source a start at new metadata file version conversion works test_metadata running now various speedups, including monkey-patching metadata cacheing various other things work more things work maybe all tests passing? all tests pass except for adding old mutations more testing for add_mutation_metadata all tests pass! test that catches duplicated substitution bug in SLiM consistency passes passes all tests additional coverage code style cache-related bug docs lint . remove cacheing added passing around ts_metadata tests pass docs cleanup . --- .github/workflows/tests.yml | 25 +- CHANGELOG.rst | 47 +- docs/metadata.md | 118 +- docs/previous_versions.md | 79 +- docs/python_api.md | 36 +- docs/rapid_adaptation.slim | 2 +- docs/selection.slim | 2 +- docs/time_units.md | 4 +- docs/tutorial.md | 183 ++- docs/vignette_coalescent_diversity.md | 77 +- docs/vignette_continuing.md | 51 +- docs/vignette_parallel_phylo.md | 9 +- docs/vignette_space.md | 16 +- pyproject.toml | 3 +- pyslim/_version.py | 4 +- pyslim/methods.py | 349 +++-- pyslim/slim_metadata.py | 1373 +++++++++++++---- pyslim/slim_tree_sequence.py | 59 +- tests/__init__.py | 39 +- tests/conftest.py | 3 +- tests/recipe_specs.py | 32 +- tests/test_annotation.py | 314 +++- tests/test_metadata.py | 195 ++- tests/test_provenance.py | 338 ++-- ..._v3_tests.sh => make_old_file_versions.sh} | 16 + tests/test_recipes/recipe_WF.slim | 37 +- tests/test_recipes/recipe_WF.v5.2.trees | Bin 0 -> 36020 bytes tests/test_recipes/recipe_WF_X.v5.2.trees | Bin 0 -> 134436 bytes tests/test_recipes/recipe_WF_Y.v5.2.trees | Bin 0 -> 55716 bytes tests/test_recipes/recipe_adds_old_muts.slim | 30 + .../chromosome_A.trees | Bin 0 -> 75780 bytes .../chromosome_FL.trees | Bin 0 -> 40948 bytes .../chromosome_H.trees | Bin 0 -> 52420 bytes .../chromosome_HF.trees | Bin 0 -> 47996 bytes .../chromosome_HM.trees | Bin 0 -> 47068 bytes .../chromosome_ML.trees | Bin 0 -> 38284 bytes .../chromosome_W.trees | Bin 0 -> 40676 bytes .../chromosome_X.trees | Bin 0 -> 66364 bytes .../chromosome_Y.trees | Bin 0 -> 38644 bytes .../chromosome_Z.trees | Bin 0 -> 61932 bytes .../chromosome_nY.trees | Bin 0 -> 37900 bytes .../recipe_chromosomes_adds_muts.slim | 62 +- tests/test_recipes/recipe_no_simplify.slim | 22 + tests/test_recipes/recipe_nonWF.slim | 38 +- tests/test_recipes/recipe_nonWF.v5.2.trees | Bin 0 -> 25044 bytes tests/test_recipes/recipe_nucleotides_WF.slim | 45 +- .../recipe_nucleotides_nonWF.slim | 46 +- tests/test_recipes/recipe_with_traits.slim | 101 ++ tests/test_tree_sequence.py | 463 ++++-- uv.lock | 54 +- 50 files changed, 3234 insertions(+), 1038 deletions(-) rename tests/test_recipes/{make_v3_tests.sh => make_old_file_versions.sh} (80%) create mode 100644 tests/test_recipes/recipe_WF.v5.2.trees create mode 100644 tests/test_recipes/recipe_WF_X.v5.2.trees create mode 100644 tests/test_recipes/recipe_WF_Y.v5.2.trees create mode 100644 tests/test_recipes/recipe_adds_old_muts.slim create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_A.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_FL.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_H.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_HF.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_HM.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_ML.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_W.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_X.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_Y.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_Z.trees create mode 100644 tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_nY.trees create mode 100644 tests/test_recipes/recipe_no_simplify.slim create mode 100644 tests/test_recipes/recipe_nonWF.v5.2.trees create mode 100644 tests/test_recipes/recipe_with_traits.slim diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c41d6293..538a8735 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -93,6 +93,7 @@ jobs: git clone https://github.com/messerlab/SLiM.git mkdir -p SLiM/Release cd SLiM/windows_compat/gnulib + git checkout multitrait # <-- note multitrait branch!! touch --date="`date`" aclocal.m4 Makefile.am configure configure.ac config.h.in Makefile.in cd ../.. cd Release @@ -104,9 +105,27 @@ jobs: pip install uv uv sync --locked --group test --no-default-groups - - name: Install SLiM (macOS / Linux) - if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-24.04' - run: micromamba install slim -y + # UNCOMMENT THIS when the below is commented again + # - name: Install SLiM (macOS / Linux) + # if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-24.04' + # run: micromamba install slim -y + + - name: Install development SLiM + # This should be COMMENTED OUT for release versions, + # since this builds SLiM from github head. + # Also note that this checks out the multitrait branch!! + if: (matrix.os == 'macos-latest' || matrix.os == 'ubuntu-24.04') && steps.cache.outputs.cache-hit != 'true' + # If we want to re-build slim from a new commit to the slim repo + # we may need to bump the cache key above. + shell: bash -l {0} + run: | + git clone https://github.com/messerlab/SLiM.git + mkdir -p SLiM/Release + cd SLiM/Release + git checkout multitrait + cmake -DCMAKE_BUILD_TYPE=Release .. + make -j 2 + - name: Run tests run: | diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f61487d2..a31bb709 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,7 +2,52 @@ [1.1.2] - 2026-XX-XX ******************** -In development +**Breaking changes**: + +- The release of SLiM 6.0, changes to metadata (see below) mean that accessing + top-level metadata (e.g., `ts.metadata["SLiM"]`) more than a few times in + a script will take a long time. Scripts that previously ran quickly may take a + prohibitively long. See the documentation for simple changes that fix the problem: + https://tskit.dev/pyslim/docs/latest/previous_versions.html + +- The SLiM tree sequence file version number has changed to 1.0. Use `pyslim.update` + to convert your tree sequence file to this format. + +- Metadata for SLiM's mutations are no longer stored along with the tskit mutations, + because mutation stacking allows each tskit mutation to be associated with more + than one SLiM mutation. Now, metadata for each unique mutation is stored in + top-level metadata, under `ts.metadata["SLiM_mutation_list"]`. The recommended + way to access this information is by obtaining the SLiM ID-to-metadata dict + returned by `pyslim.mutation_metadata(ts)`. + +- Previously, `msprime.sim_mutations` with the `msprime.SLiMMutationModel` + would record SLiM metadata along with each new mutation. However, msprime + does not modify top-level metadata, and so the method `add_mutation_metadata` + should be used after adding SLiM mutations. + +- This is a SLiM change, but top-level metadata is now encoded using the `json+struct` + codec now provided by tskit (so that the mutation metadata is not too large/slow). + +- The top-level and individual metadata schemas now depend on the number of traits + in the model. The methods `slim_tree_sequence_metadata_schema` and + `slim_individual_metadata_schema` can be used to produce correct schema. + +**Bug fixes:** + +- In some previous versions, converting files produced by a yet-older version of SLiM + to the previously-current file version dropped some information from metadata: + nucleotide values for mutations, and pedigree parent IDs for individuals. This only + may have affected users using `pyslim.convert(ts)` in a previous version of pyslim + on a tree sequence `ts` with SLiM file version prior to 0.9. + +**New features**: + +- SLiM now includes in metadata information about the effects of mutations on + quantitative traits, the values of traits for individuals, and the values of + various "tags" defined in SLiM. + +- `default_slim_metadata` can now take additional arguments to modify the returned + values. ******************** [1.1.1] - 2026-03-06 diff --git a/docs/metadata.md b/docs/metadata.md index e4255d51..04c3b70b 100644 --- a/docs/metadata.md +++ b/docs/metadata.md @@ -20,7 +20,7 @@ import random random.seed(23) ts = tskit.load("example_sim.trees") -tables = ts.tables +tables = ts.dump_tables() ``` ```{eval-rst} @@ -37,7 +37,7 @@ tables = ts.tables ## Overview SLiM puts SLiM-specific information into the *metadata* for the tree sequence, -as well as for each populations, individuals, nodes and mutations. +as well as for each population, individual, node and mutation. Here is a quick reference to what information is available: see the SLiM manual for the more technical writeup. A good way to get a generic metadata example is with {func}`.default_slim_metadata`. @@ -55,6 +55,22 @@ and `ts.metadata["SLiM"]` contains information about the simulation: - `spatial_dimensionality`: for instance, `""` or `"x"` or `"xy"` (etcetera) - `spatial_periodicity`: whether space wraps around in some directions (same format as dimensionality) - `stage`: the *stage* of the life cycle at which the file was written out (either `"first"`, `"early"`, or `"late"`) +- `name`: the *name* of this species in SLiM +- `this_chromosome`: contains, for the chromosome in SLiM recorded in this tree sequence + * `id`: SLiM's ID + * `index`: the index of this chromosome in the list of chromosomes + * `symbol`: the user-assigned symbol + * `type`: specifies inheritance type, e.g., `"A"` for autosome +- `chromosomes`: (optional) a list of all chromosomes in the simulation +- `traits`: a list of information for each of the traits: + * `index`: the index of the trait in SLiM + * `name`: the name in SLiM for the trait + * `type`: additive, multiplicative, or logistic + * `baselineOffset`, `baselineAccumulation`: a value added to all traits, and whether the effect of substitutions + accumulate in that value + * `directFitnessEffect`: whether the trait has a direct effect on fitness + * `individualOffsetMean`, `individualOffsetSD`: parameters governing the individual-level offsets + (i.e., "environment" effects) **Populations:** Information about each SLiM-produced population is written to metatadata. @@ -81,27 +97,89 @@ Each individual produced by SLiM contains the following metadata: - `subpopulation`: the subpopulation within SLiM the individual was in at the time the file was written out - `sex`: the sex of the individual (either {data}`.INDIVIDUAL_TYPE_FEMALE`, {data}`.INDIVIDUAL_TYPE_MALE`, or {data}`.INDIVIDUAL_TYPE_HERMAPHRODITE`) - `flags`: additional information; currently only recording whether the individual was a "migrant" or not (see the SLiM manual) +- `tag`, `tagF`: the corresponding properties in SLiM: default values returned by pyslim + are the special values that SLiM uses to mean that the values are unset +- `tagL0`, `tagL0_set`, etcetera: again, the corresponding properties in SLiM; + the purpose of `tagLX_set` is to record whether the tag has been set in the simulation +- `per_trait`: a list of information about the trait values for this indivdual; these are in the same order + as the traits listed in top-level metadata; + * `phenotype`: the trait value + * `offset`: the individual's offset (i.e., the "environmental effect") **Nodes:** Each "node" produced by SLiM (i.e., "genome" within SLiM) has: -- 'slim_id': the unique ID associated with the genome by SLiM -- 'is_null': whether the genome is a "null" genome (in which case it isn't +- `slim_id`: the unique ID associated with the genome by SLiM +- `is_vacant`: records the genome is a "vacant" genome (in which case it isn't really there, so shouldn't have any mutations or relationships in the tree - sequence!) -- 'genome_type': the 'type' of this genome (0 for autosome, 1 for X, 2 for Y) + sequence!) - see [](sec_overview_vacant_nodes) for more explanation **Mutations:** -Each mutation's metadata is a dictionary with a single key, `"mutation_list"`, -whose entry is a *list* of metadata dictionaries corresponding to the mutations that are "stacked", -i.e., all present, in all genomes inheriting from this (tskit) mutation. -So, `ts.mutation(12).metadata["mutation_list"]` is a list, each of whose entries contains: +Prior to SLiM 6.0, mutation metadata was associated with the tskit mutation objects. +Now, this is stored in top-level metadata, under ``ts.metadata["SLiM_mutation_list"]``. +Each entry +- `mutation_id`: the numeric ID of mutation in SLiM - `mutation_type`: the numeric ID of the `MutationType` within SLiM -- `selection_coeff`: the selection coefficient - `subpopulation`: the numeric ID of the subpopulation the mutation occurred in - `slim_time`: the value of `community.tick` when the mutation occurred - `nucleotide`: either `-1` if there is no associated nucleotide, or the numeric code for the nucleotide (see {data}`.NUCLEOTIDES`) +- `per_trait`: a list of information in the same order as the traits in top-level metadata, recording for each: + * `effect_size`: the effect on the trait of this mutation + * `dominance`: its dominance coefficient + * `hemizygous_dominance`: its hemizygous dominance coefficient (see the SLiM manual) +- `padding`: this is simply empty bytes, here for byte-alignment reasons, and is always `None` + + +(sec_metadata_using_top_level)= + +## Using top-level metadata + +If you are going to be using information from top-level metadata, +it is good practice to extract the metadata as a separate python object once +and refer to that object, since otherwise you can incur runtime penalties +for decoding and copying the metadata every time you call `ts.metadata`. +This can be substantial, given the amount of mutation information +in top-level metadata. +For instance, to subtract off baseline offsets from individual's trait values, +we might do: +```{code-cell} +md = ts.metadata +traits = md["SLiM"]["traits"] +values = [ + [x['phenotype'] - y["baselineOffset"] for x, y in zip(ind.metadata['per_trait'], traits)] + for ind in ts.individuals() +] +``` +If we instead inserted ``ts.metadata["SLiM"]["traits"]`` directly into the loop, +this would become infeasibly slow. + +In some more detail: +each time python evaluates ``ts.metadata`` (e.g., using ``ts.metadata["SLiM"]``) +a new copy of the metadata dict is decoded and returned. Furthermore, a number +of pyslim functions need to look up information from metadata under the hood. +For instance, previously it was acceptable to run +``[pyslim.slim_time(ts, mut.time) for mut in ts.mutations()]``. +However, this could now easily take hours even for moderately-sized simulations. +There are several recommendations for how to mitigate this: + +- If you use information from top-level metadata, make a copy of it + and refer to that copy instead: so, ``ts_metadata = ts.metadata`` + after ``ts = tskit.load(...)`` and then use `ts_metadata`. However, + be careful that you use the correct metadata object! + +- Use a single pyslim function call rather than many. For instance, run: + ``slim_times = pyslim.slim_time(ts, ts.mutations_time)`` and extract + slim times from this vector. Similarly, use {func}`.nodes_vacant` + instead of {func}`.node_is_vacant`. + +- Some pyslim methods will accept a pre-extracted metadata dictionary + as an optional argument. If this is not provided, those methods will + extract the metadata again. The methods that now take a `ts_metadata` argument are: + {func}`.individual_ages`, + {func}`.individual_ages_at`, + {func}`.individuals_alive_at`, and + {func}`.slim_time`. (sec_metadata_tools)= @@ -110,10 +188,20 @@ So, `ts.mutation(12).metadata["mutation_list"]` is a list, each of whose entries The dictionaries describing the schema for these metadata entries are available in `pyslim.slim_metadata_schemas`. -Furthermore, this method may be useful in working with metadata: +Furthermore, these method may be useful in working with metadata: ```{eval-rst} .. autofunction:: default_slim_metadata + +.. autofunction:: slim_tree_sequence_metadata_schema + +.. autofunction:: slim_individual_metadata_schema + +.. autofunction:: slim_node_metadata_schema + +.. autofunction:: set_tree_sequence_metadata + +.. autofunction:: set_metadata_schemas ``` @@ -125,11 +213,11 @@ see {ref}`tskit's metadata documentation `. ### Top-level metadata The entries of the top-level metadata dict are *read-only*. -So, you might think that +So, although you might think that `tables.metadata["SLiM"]["model_type"] = "nonWF"` would switch the model type, -but this in fact (silently) does nothing. To modify the top-level metadata, -we must (a) work with tables (as tree sequences are immutable, and (b) +this in fact (silently) does nothing. To modify the top-level metadata, +we must (a) work with tables (as tree sequences are immutable), and (b) extract the metadata dict, modify the dict, and copy it back in. Instead, you should do ```{code-cell} diff --git a/docs/previous_versions.md b/docs/previous_versions.md index 3c1f74da..ce067fa2 100644 --- a/docs/previous_versions.md +++ b/docs/previous_versions.md @@ -16,7 +16,7 @@ kernelspec: import pyslim, tskit, msprime ts = tskit.load("example_sim.trees") -tables = ts.tables +tables = ts.dump_tables() ``` @@ -25,6 +25,83 @@ tables = ts.tables # Migrating from previous versions of pyslim +## 1.2 + +Release 1.2 goes along with SLiM v6, which introduces support for traits. +It also changes the format for storing mutation metadata: now this is stored +in top-level metadata. + +1. Each time python evaluates ``ts.metadata`` (e.g., using ``ts.metadata["SLiM"]``) +a new copy of the metadata dict is decoded and returned. In large SLiM simulations, +this can take seconds, so we should avoid doing it many times. Furthermore, a number +of pyslim functions need to look up information from metadata under the hood. +See [](sec_metadata_using_top_level) for more discussion and examples. +In particular: + + - The method {func}`.node_is_vacant` necessarily uses metadata and acts + only on a single node. This method is now deprecated; + use {func}`.nodes_vacant` instead. + + - Some pyslim methods will accept a pre-extracted metadata dictionary + as an optional ``ts_metadata`` argument; see [](sec_metadata_using_top_level). + Furthermore, {func}`.is_current_version` now accepts top-level metadata directly + as an alternative to the tree sequence. + +2. If you are using `msprime` to generate mutations, you need to use +{func}`.add_mutation_metadata` after generating mutations to add the +information about these that SLiM expects to top-level metadata. +For instance: + +```{code-cell} +next_id = pyslim.next_slim_mutation_id(ts) +ts = pyslim.add_mutation_metadata( + msprime.sim_mutations( + ts, + rate=1e-8, + model=msprime.SLiMMutationModel(type=0, next_id=next_id), + ), + mutation_type=0, +) +``` +Here the ``mutation_type`` argument to {func}`.add_mutation_metadata` +is the important one; the ``type`` argument to ``SLiMMutationModel`` +is now deprecated, and will be effectively ignored. + +3. Instead of looking up metadata for mutations in `mut.metadata`, you need +to pull this information out of top-level metadata using the SLiM ID as a key. +In brief, if `mut` is a mutation, then you should replace +`mut.metadata["mutation_list"][j]` +with `mut_metadata[int(mut.derived_state.split(",")[j])]`, +where `mut_metadata` is the output of {func}`.mutation_metadata`. +For instance, where before you might have done: + +```python +mut = ts.mutation(0) +for k, md in zip(mut.derived_state.split(","), mut.metadata["mutation_list"]): + print(f"SLiM ID: {k}") + print(f"Metadata: {md}") +``` + +Now, you would do: + +```{code-cell} +mut_metadata = pyslim.mutation_metadata(ts) +mut = ts.mutation(0) +for k in mut.derived_state.split(","): + md = mut_metadata[int(k)] + print(f"SLiM ID: {k}") + print(f"Metadata: {md}") +``` + +The function {func}`.mutation_metadata` pulls information out of +`ts.metadata["SLiM_mutation_list"]`. It is useful for two reasons: +first, it puts the information into a dict, so you can look up information +using the SLiM mutation ID instead of searching through the list to find it. +Second, it caches the information: every time you access +`ts.metadata["SLiM_mutation_list"]`, it makes a new, decoded copy +of the entire metadata dictionary. This can be **very slow** if it is done +repeatedly. + ## 1.1 Release 1.1 goes along with SLiM v5, which introduces multichromosome support. diff --git a/docs/python_api.md b/docs/python_api.md index b570cd69..51e96d23 100644 --- a/docs/python_api.md +++ b/docs/python_api.md @@ -18,7 +18,7 @@ from IPython.display import SVG import numpy as np ts = tskit.load("example_sim.trees") -tables = ts.tables +tables = ts.dump_tables() ``` ```{eval-rst} @@ -38,6 +38,7 @@ Here is a quick reference to some of the methods: .. autosummary:: recapitate + mutation_metadata annotate individuals_alive_at individual_ages @@ -47,6 +48,9 @@ Here is a quick reference to some of the methods: has_vacant_samples node_is_vacant slim_time + next_slim_mutation_id + add_mutation_metadata + add_mutation_metadata_tables convert_alleles generate_nucleotides population_size @@ -89,6 +93,11 @@ Here is a quick reference to some of the methods: .. autofunction:: set_slim_state ``` +```{eval-rst} +.. autofunction:: add_mutation_metadata +.. autofunction:: add_mutation_metadata_tables +``` + ## Summarizing tree sequences Additionally, ``pyslim`` contains the following methods: @@ -119,6 +128,10 @@ Additionally, ``pyslim`` contains the following methods: ## Utilities +```{eval-rst} +.. autofunction:: mutation_metadata +``` + ```{eval-rst} .. autofunction:: slim_time ``` @@ -131,24 +144,23 @@ Additionally, ``pyslim`` contains the following methods: .. autofunction:: has_vacant_samples ``` +```{eval-rst} +.. autofunction:: nodes_vacant +``` + ```{eval-rst} .. autofunction:: node_is_vacant ``` +```{eval-rst} +.. autofunction:: is_current_version +``` + ## Metadata -SLiM-specific metadata is made visible to the user by ``.metadata`` properties. -For instance: -```{code-cell} -ts.individual(4).metadata -``` -shows that the fifth individual in the tree sequence was given pedigree ID ``495999`` by SLiM, -had parents with pedigree IDs ``493739`` and ``494784``, -was age 10 at the time that they died (or the simulation ended), -lived in subpopulation 1, -was female (because ``sex`` matches ``pyslim.INDIVIDUAL_TYPE_FEMALE``, below), -and has no additional metadata flags. +SLiM-specific metadata is made visible to the user by ``.metadata`` properties, +described in [](sec_metadata). ### Annotation diff --git a/docs/rapid_adaptation.slim b/docs/rapid_adaptation.slim index ed2ed497..909eeecf 100644 --- a/docs/rapid_adaptation.slim +++ b/docs/rapid_adaptation.slim @@ -1,5 +1,5 @@ initialize() { - initializeTreeSeq(); + initializeTreeSeq(timeUnit="generations"); initializeMutationRate(1e-8); initializeMutationType("m1", 0.5, "e", 0.1); initializeGenomicElementType("g1", m1, 1.0); diff --git a/docs/selection.slim b/docs/selection.slim index 3c1860b4..b6173896 100644 --- a/docs/selection.slim +++ b/docs/selection.slim @@ -1,7 +1,7 @@ initialize() { initializeSLiMModelType("WF"); - initializeTreeSeq(); + initializeTreeSeq(timeUnit="generations"); initializeMutationRate(1e-6); initializeMutationType("m1", 0.5, "e", -0.1); initializeMutationType("m2", 0.5, "e", 0.5); diff --git a/docs/time_units.md b/docs/time_units.md index f997c49c..419651b1 100644 --- a/docs/time_units.md +++ b/docs/time_units.md @@ -226,7 +226,8 @@ so we expect generation time to go up at first. ```{code-cell} gts = tskit.load("generation_time.trees") -gentimes = gts.metadata["SLiM"]["user_metadata"]["generation_times"] +gts_metadata = gts.metadata +gentimes = gts_metadata["SLiM"]["user_metadata"]["generation_times"] fig, ax = plt.subplots(figsize=(12, 6), dpi=300) ax.set_xlabel("tick") @@ -266,7 +267,6 @@ Furthermore, since we already have mutations up until 100 time units ago, we need to put mutations on only previous to that time. ```{code-cell} -gentimes = gts.metadata["SLiM"]["user_metadata"]["generation_times"] gt = np.mean(gentimes[-50:]) recomb_rate = 1e-8 # per generation Ne = 1000 # generations diff --git a/docs/tutorial.md b/docs/tutorial.md index 35fc967e..52cfdf33 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -315,8 +315,8 @@ can be done with the {meth}`tskit.TreeSequence.simplify` method: ```{code-cell} import numpy as np rng = np.random.default_rng(seed=3) -alive_inds = pyslim.individuals_alive_at(rts, 0) -keep_indivs = rng.choice(alive_inds, 100, replace=False) +alive = pyslim.individuals_alive_at(rts, 0) +keep_indivs = rng.choice(alive, 100, replace=False) keep_nodes = [] for i in keep_indivs: keep_nodes.extend(rts.individual(i).nodes) @@ -364,11 +364,13 @@ This works as follows: ```{code-cell} next_id = pyslim.next_slim_mutation_id(sts) -ts = msprime.sim_mutations( +ts = pyslim.add_mutation_metadata( + msprime.sim_mutations( sts, rate=1e-8, model=msprime.SLiMMutationModel(type=0, next_id=next_id), keep=True, + ) ) print(f"The tree sequence now has {ts.num_mutations} mutations,\n" @@ -517,8 +519,8 @@ For this reason, if at this point we try to extract genotypes for all of the alive individuals, we encounter a (somewhat confusing) error: ```{code-cell} +alive = pyslim.individuals_alive_at(ts, 0) try: - alive = pyslim.individuals_alive_at(ts, 0) with open("example_snps.vcf", "w") as vcffile: ts.write_vcf(vcffile, individuals=alive) except Exception as e: @@ -537,7 +539,7 @@ using {meth}`is_sample() `: ```{code-cell} indivlist = [] -for i in pyslim.individuals_alive_at(ts, 0): +for i in alive: ind = ts.individual(i) if ts.node(ind.nodes[0]).is_sample(): indivlist.append(i) @@ -652,7 +654,7 @@ print(f"There are {ts.num_mutations} mutations across {ts.num_trees} distinct\n" ## Individual metadata -Each ``Mutation``, ``Population``, ``Node``, and ``Individual``, as well as the tree +Each ``Population``, ``Node``, and ``Individual``, as well as the tree sequence as a whole, carries additional information stored by SLiM in its ``metadata`` property. A fuller description of metadata in general is given in [](sec_metadata), but as a quick introduction, here is the information available @@ -687,7 +689,12 @@ produced by SLiM. This is described in more detail in the SLiM manual, but brief - ``flags`` holds additional information about the individual recorded by SLiM (currently, only whether the individual has migrated or not: see [](sec_constants_and_flags)). - +- the ``tag`` entries contain the correspondly-named "tags" in SLiM, + and for the logical tags ``tagLX``, the ``tagLX_set`` records whether or not + that tag was "set" (as opposed to remaining unset). + The funny values in ``tag`` and ``tagF`` are those special values that SLiM uses to + record that *those* entries were not set either. +- the ``per_trait`` entry is a list of information, one for each trait in the simulation. We can use this metadata in many ways, for example, to create an age distribution by sex: @@ -697,7 +704,8 @@ max_age = max([ind.metadata["age"] for ind in ts.individuals()]) age_table = np.zeros((max_age + 1, 2)) age_labels = { pyslim.INDIVIDUAL_TYPE_FEMALE: 'females', pyslim.INDIVIDUAL_TYPE_MALE: 'males' } -for i in pyslim.individuals_alive_at(ts, 0): +alive = pyslim.individuals_alive_at(ts, 0) +for i in alive: ind = ts.individual(i) age_table[ind.metadata["age"], ind.metadata["sex"]] += 1 @@ -728,8 +736,8 @@ This can be done using the numpy arrays returned by {func}`.individual_ages` and `.individuals_population` as follows: ```{code-cell} -alive = pyslim.individuals_alive_at(ts, 0) -adults = alive[pyslim.individual_ages(ts)[alive] > 2] +ages = pyslim.individual_ages(ts) +adults = alive[ages[alive] > 2] pops = [ [i for i in adults if ts.individual(i).metadata['subpopulation'] == k] for k in [1, 2] @@ -979,22 +987,40 @@ stored in the mutation metadata. To modify the mutations to be under selection, see [](sec_vignette_coalescent_diversity). ```{code-cell} -ts = msprime.sim_mutations( +ts = pyslim.add_mutation_metadata( + msprime.sim_mutations( ts, rate=1e-8, model=msprime.SLiMMutationModel(type=0), random_seed=9 + ) ) ``` -Now the mutations have SLiM metadata. -For instance, here's the first mutation: +The resulting mutations are in SLiM format. +Now, each `mutation` object in the tree sequence represents +some number of SLiM mutations, whose SLiM IDs are stored in the `derived_state`. +For instance, here's which SLiM mutation(s) the first mutation +in the tree sequence represents: +```{code-cell} +ds = ts.mutation(0).derived_state +print(f"SLiM IDs: {ds}") +``` +To see the information about these, we pull their information out +using {func}`.mutation_metadata`, which provides a dictionary +indexed by the SLiM IDs: ```{code-cell} :tags: ["remove-output"] -ts.mutation(0) +mut_metadata = pyslim.mutation_metadata(ts) +for sid in ds.split(","): + print(mut_metadata[int(sid)]) ``` ```{code-cell} :tags: ["remove-input"] -util.pp(ts.mutation(0)) +for sid in ds.split(","): + util.pp(mut_metadata[int(sid)]) ``` +**Important:** the {func}`.mutation_metadata`-returned dictionary +is indexed by **ints**, not strings, so be sure to convert your +SLiM IDs to ints before looking them up! Finally, we write this out to a file that can be loaded in to SLiM: ```{code-cell} @@ -1053,24 +1079,31 @@ Now, mutations have a ``nucleotide`` property in metadata that is not ``-1``: ```{code-cell} :tags: ["remove-output"] +mut_metadata = pyslim.mutation_metadata(ts) m = ts.mutation(0) +md = [mut_metadata[int(k)] for k in m.derived_state.split(",")] print(m) +for x in md: + print(x) ``` ```{code-cell} :tags: ["remove-input"] util.pp(m) +for x in md: + util.pp(x) ``` We can see which nucleotide is the derived state produced by each mutation - by indexing the {data}`.NUCLEOTIDES` object: +by indexing the {data}`.NUCLEOTIDES` object: ```{code-cell} for k in range(3): m = ts.mutation(k) print(f"Mutation {k}: position {ts.site(m.site).position}, time {m.time}") - for ml in m.metadata['mutation_list']: - print(f" nucleotide: {pyslim.NUCLEOTIDES[ml['nucleotide']]}") + for sid in m.derived_state.split(","): + md = mut_metadata[int(sid)] + print(f" nucleotide: {pyslim.NUCLEOTIDES[md['nucleotide']]}") ``` Here's a script minimally modified from the above to be nucleotide-based: @@ -1108,73 +1141,108 @@ print(f"Number of sites: {ts.num_sites}\n" ``` Note that there are more mutations than sites; -that's because some sites (looks like 24 of them) have multiple mutations. +that's because some sites have multiple mutations. The information about the mutation is put in the mutation's metadata. Here's the first mutation: ```{code-cell} :tags: ["remove-output"] +mut_metadata = pyslim.mutation_metadata(ts) m = ts.mutation(0) +md = [mut_metadata[int(k)] for k in m.derived_state.split(",")] print(m) +for x in md: + print(x) ``` + ```{code-cell} :tags: ["remove-input"] util.pp(m) +for x in md: + util.pp(x) ``` -Here, `m.site` tells us the ID of the *site* on the genome that the mutation occurred at, + +Since we haven't explicitly defined any traits in this simulation, +the only trait is fitness, and the `effect_size` listed under `per_trait` +for this mutation is simply its selection coefficient. +Furthermore, `m.site` tells us the ID of the *site* on the genome that the mutation occurred at, and we can pull up information about that with the `ts.site( )` method: + ```{code-cell} :tags: ["remove-output"] -ts.site(m.site) +s = ts.site(m.site) +md = [ + mut_metadata[int(k)] for m in s.mutations + for k in m.derived_state.split(",") +] +print(s) +for x in md: + print(x) ``` + ```{code-cell} :tags: ["remove-input"] -util.pp(ts.site(m.site)) +util.pp(s) +for x in md: + util.pp(x) ``` + This mutation occurred at position 54 along the genome (from `site.position`) which previously had no mutations (since `site.ancestral_state` is the empty string, `''`) -and was given SLiM mutation ID 1653896 (`m.derived_state`). -The metadata (`m.metadata`, a dict) tells us that -the mutation has selection coefficient 1.5597 and occurred in population 1 in generation 827, -which was 172 generations ago. +and was given SLiM mutation ID 1997358 (`m.derived_state`). +The metadata (`mut_metadata[1997358]`, a dict) tells us that +the mutation has selection coefficient -0.1129 and occurred in population 1 in generation 999, +which was 0 generations ago. This is not a nucleotide model, so the nucleotide entry is `-1`. -Note that `m.time` and `m.metadata['mutation_list'][0]['slim_time']` are in this case redundant: +Note that `m.time` and the `slim_time` entry in metadata are in this case redundant: they contain the same information, but the first is in tskit time (i.e., number of steps before the tree sequence was written out) and the second is using SLiM's internal "tick" counter. -Also note that the mutation's metadata is a *list* of metadata entries. +Also note that each mutation may have associated a *list* of SLiM mutations, +each with their own metadata. That's because of SLiM's mutation stacking feature. We know that some sites have more than one mutation, so to get an example let's pull out one such mutation. -In this case, -`m.metadata['mutation_list']` is a list of length one, -so the mutation was not stacked on top of previous ones. Let's pull out a mutation that was stacked on top of another one: + ```{code-cell} :tags: ["remove-output"] for m in ts.mutations(): if m.parent != tskit.NULL: break +pm = ts.mutation(m.parent) +md = [mut_metadata[int(k)] for k in m.derived_state.split(",")] +pmd = [mut_metadata[int(k)] for k in pm.derived_state.split(",")] + print(m) -print(ts.mutation(m.parent)) +for x in md: + print(x) +print(pm) +for x in pmd: + print(x) ``` + ```{code-cell} :tags: ["remove-input"] util.pp(m) +for x in md: + util.pp(x) util.pp(ts.mutation(m.parent)) +for x in pmd: + util.pp(x) ``` -This mutation (which is `ts.mutation(1020)` in the tree sequence) -was the result of SLiM adding a new mutation of type `m1` and selection coefficient -0.0032 -on top of an existing mutation, also of type `m1` and with selection coefficient 0.3086. -This happened at generation 999 (i.e., at tskit time 0.0 time units ago), -and the older mutation occurred at generation 274 (at tskit time 725 time units ago). -The older mutation has SLiM mutation ID 547531, -and the newer mutation had SLiM mutation ID 1998096, -so the resulting "derived state" is `'1998096,547531'`. +This mutation (which is `ts.mutation(330)` in the tree sequence) +was the result of SLiM adding a new mutation of type `m1` and selection coefficient -0.1547 +on top of an existing mutation, of type `m2` and with (whopping) selection coefficient 1.737. +This happened at generation 998 (i.e., at tskit time 1.0 time units ago), +and the older mutation occurred at generation 83 (at tskit time 916 time units ago). +The older mutation has SLiM mutation ID 1994163, +and the newer mutation had SLiM mutation ID 164833, +so the resulting "derived state" is `'1994163,164833'`. Now that we understand how SLiM mutations are stored in a tree sequence, let's look at the allele frequencies. @@ -1189,12 +1257,12 @@ print(afs.astype('int')) ``` (The `span_normalise=False` argument gives us counts rather than a density per unit length.) -This shows us that there are 4169 alleles that are found among the tree sequence's samples -that are not present in any of our 10 samples, 96 that are present in just one, etcetera. +This shows us that there are 3929 alleles that are found among the tree sequence's samples +that are not present in any of our 10 samples, 585 that are present in just one, etcetera. The surprisingly large number that are near 50% frequency are perhaps positively selected and on their way to fixation: we can check if that's true next. -You may have noticed that the sum of the allele frequency spectrum is 5243, -which is not obviously related to the number of mutations (6044) *or* the number of sites (6020). +You may have noticed that the sum of the allele frequency spectrum is 5029, +which is not obviously related to the number of mutations (5861) *or* the number of sites (5848). That's because each derived allele that is inherited by some but not all of the samples in the tree sequence is counted in the polarised allele frequency spectrum: Fixed mutations, or mutations that were entirely "overwritten" by subsequent mutations, @@ -1206,9 +1274,11 @@ afs_total = 0 for v in ts.variants(): if len(set(v.genotypes)) > 1: afs_total += len(set(v.genotypes) - set([0])) -print(afs_total) +print(afs_total, sum(afs)) ``` +These are equal, verifying our interpretation. + At time of writing, we don't have a built-in ``allele_frequency`` method, so we'll use the following snippet: @@ -1236,7 +1306,8 @@ mut_type = np.zeros(ts.num_sites) for j, s in enumerate(ts.sites()): mt = [] for m in s.mutations: - for md in m.metadata["mutation_list"]: + for sid in m.derived_state.split(","): + md = mut_metadata[int(sid)] mt.append(md["mutation_type"]) if len(set(mt)) > 1: mut_type[j] = 3 @@ -1261,32 +1332,34 @@ print(mut_afs) The first column gives the AFS among these 10 samples for the deleterious alleles, the second for the beneficial mutations; -the third column for the seven sites that had both types of mutation. +the third column for the few sites that had both types of mutation. Interestingly, there are similar numbers of both types of mutation at intermediate frequency: perhaps because beneficial mutations are sweeping linked deleterious alleles along with them. -Many fewer benefical alleles are at low frequency: -3,666 deleterious alleles are not found in our sample of 10 genomes, -while only 486 beneficial alleles are. +Many fewer benefical alleles are at low frequency, however. Finally, let's pull out information on the allele with the largest selection coefficient. ```{code-cell} :tags: ["remove-output"] sel_coeffs = np.array([ - sum(md["selection_coeff"] for md in m.metadata["mutation_list"]) + sum(mut_metadata[int(k)]["per_trait"][0]["effect_size"] + for k in m.derived_state.split(",")) for m in ts.mutations() ]) which_max = np.argmax(sel_coeffs) m = ts.mutation(which_max) +print(f"Max selection coefficient: {sel_coeffs[which_max]} for site {m.site}") ts.site(m.site) ``` + ```{code-cell} :tags: ["remove-input"] +print(f"Max selection coefficient: {sel_coeffs[which_max]} for site {m.site}") util.pp(ts.site(m.site)) ``` -This allele had a whopping selection coefficient of 4.94 -and appeared about halfway through the simulation. +This allele had a whopping selection coefficient of 5.69 +and appeared fairly late in the simulation. Let's find its frequency in the full population: ```{code-cell} @@ -1296,7 +1369,7 @@ print(f"The allele is found in {full_freqs[m.site][0]} copies\n" ``` The allele is above 50% in the population, so it is probably on its way to fixation. -Using its SLiM ID (which is shown in its derived state, ``1616148``), +Using its SLiM ID (which is shown in its derived state, ``305447``), we could reload the tree sequence into SLiM, restart the simulation, and use its ID to track its subsequent progression. @@ -1331,4 +1404,4 @@ Also known as "gotchas". 4. SLiM requires that the two nodes corresponding to the haplosomes of each individual are adjacent in the node table, and are sorted by haplosome ID. SLiM always writes out tree sequences like this, but it is possible to make - tree sequences in python that are leval otherwise but don't satisfy this requirement. + tree sequences in python that are legal otherwise but don't satisfy this requirement. diff --git a/docs/vignette_coalescent_diversity.md b/docs/vignette_coalescent_diversity.md index 589d1bd1..43dc4aa8 100644 --- a/docs/vignette_coalescent_diversity.md +++ b/docs/vignette_coalescent_diversity.md @@ -166,21 +166,26 @@ mut_map = msprime.RateMap( position=breaks, rate=[0.03e-8, 0.003e-8, 0.03e-8]) mut_model = msprime.SLiMMutationModel(type=2) -ots = msprime.sim_mutations( +ots = pyslim.add_mutation_metadata( + msprime.sim_mutations( ots, rate=mut_map, model=mut_model, keep=True, - random_seed=12) + random_seed=12), + mutation_type=2, +) print(f"The tree sequence now has {ots.num_mutations} mutations, at " f"{ots.num_sites} distinct sites.") ``` -Note the ``type=2`` argument to {class}`msprime.SLiMMutationModel`: +Note the ``type=2`` argument to {func}`.add_mutation_metadata`: this means the mutations will be of type "m2" in SLiM (and, so you must initialize that mutation type in the recipe that loads this tree sequence in). Now, we'll assign selection coefficients. +This is easier than in versions of SLiM before 6.0, +because we simply want to assign each mutation an independent selection coefficient Recall that to accomodate mutation stacking in SLiM, a mutation metadata entry is in fact a *list* of metadata entries, one for each of the SLiM mutations that are stacked at this position. @@ -193,25 +198,12 @@ SLiM mutation ID ``k``. ```{code-cell} rng = np.random.default_rng(seed=1234) -tables = ots.tables -tables.mutations.clear() -mut_map = {} -for m in ots.mutations(): - md_list = m.metadata["mutation_list"] - slim_ids = m.derived_state.split(",") - assert len(slim_ids) == len(md_list) - for sid, md in zip(slim_ids, md_list): - if sid not in mut_map: - mut_map[sid] = rng.exponential(scale=0.04) - md["selection_coeff"] = mut_map[sid] - _ = tables.mutations.append( - m.replace(metadata={"mutation_list": md_list}) - ) - -# check we didn't mess anything up -assert tables.mutations.num_rows == ots.num_mutations -print(f"The selection coefficients range from {min(mut_map.values()):0.2e}") -print(f"to {max(mut_map.values()):0.2e}.") +ts_metadata = ots.metadata +for md in ts_metadata["SLiM_mutation_list"]: + md["per_trait"][0]["effect_size"] = rng.exponential(scale=0.04) + +tables = ots.dump_tables() +tables.metadata = ts_metadata ``` @@ -222,15 +214,15 @@ We can see this with ``tables.metadata``: ```{code-cell} :tags: ['remove-output'] -tables.metadata +tables.metadata["SLiM"] ``` ```{code-cell} :tags: ['remove-input'] -util.pp(tables.metadata) +util.pp(tables.metadata["SLiM"]) ``` -We should edit this to match our planned slimulation -- particularly the ``model_type`` (WF or nonWF) and the ``tick``. +We should edit this to match our planned slimulation - +particularly the ``model_type`` (WF or nonWF) and the ``tick``. The ``tick`` tells SLiM what value to set the tick counter to once this tree sequence is loaded. In principle, it can be set to anything, independently of the times in the tree sequence, @@ -246,7 +238,6 @@ edit the metadata, let's make sure, and then we'll write the tree sequence to a file. ```{code-cell} -ts_metadata = tables.metadata ts_metadata["SLiM"]["model_type"] = "WF" tables.metadata = ts_metadata ots = tables.tree_sequence() @@ -290,7 +281,7 @@ This runs quickly, since it's only 100 generations. First, let's look at what mutations are present. ```{code-cell} ts = tskit.load("vignette_annotated.trees") -num_stacked = np.array([len(m.metadata["mutation_list"]) for m in ts.mutations()]) +num_stacked = np.array([len(m.derived_state.split(",")) for m in ts.mutations()]) init_time = ts.metadata['SLiM']['tick'] old_mut = np.array([m.time > init_time - 1 - 1e-12 for m in ts.mutations()]) assert sum(old_mut) == ots.num_mutations @@ -324,7 +315,9 @@ nodes_by_time = [ts.samples(time=t) for t in times] num_nodes = np.array([len(x) for x in nodes_by_time]) p = ts.sample_count_stat(nodes_by_time, lambda x: x/num_nodes, 2, windows='sites', strict=False, span_normalise=False, polarised=True) -s = np.array([sum([sum([md["selection_coeff"] for md in m.metadata["mutation_list"]]) +mut_metadata = pyslim.mutation_metadata(ts) +s = np.array([sum([sum([mut_metadata[int(k)]["per_trait"][0]["effect_size"] + for k in m.derived_state.split(",")]) for m in site.mutations]) for site in ts.sites()]) ``` @@ -407,12 +400,15 @@ next_id = pyslim.next_slim_mutation_id(ts) neutral_mut_model = msprime.SLiMMutationModel( type=1, next_id=next_id) -mts = msprime.sim_mutations( +mts = pyslim.add_mutation_metadata( + msprime.sim_mutations( ts, rate=neutral_mut_map, model=neutral_mut_model, keep=True, - random_seed=35) + random_seed=35), + mutation_type=1, +) print(f"The tree sequence now has {mts.num_mutations} mutations,") print(f"at {mts.num_sites} distinct sites.") ``` @@ -431,8 +427,9 @@ we'll pull out a tree that had a lot of mutations on it, and print a picture of it, with mutations labeled by their type: ```{code-cell} +mut_metadata = pyslim.mutation_metadata(mts) for t in mts.trees(): - mt = [max([u['mutation_type'] for u in m.metadata['mutation_list']]) for m in t.mutations()] + mt = [max([mut_metadata[int(k)]['mutation_type'] for k in m.derived_state.split(",")]) for m in t.mutations()] if t.num_mutations > 12: break @@ -482,28 +479,36 @@ If you wanted some other arrangement (e.g., to have m1 stack on top of m2), you could go through and modify derived states and metadata appropriately. Let's check there are any sites with stacked mutations of different types in the simulation. -There is indeed one such site: +There are indeed: ```{code-cell} :tags: ['remove-output'] for site in mts.sites(): if len(site.mutations) > 1: - types = [set([md["mutation_type"] for md in mut.metadata["mutation_list"]]) + types = [set([mut_metadata[int(k)]["mutation_type"] for k in mut.derived_state.split(",")]) for mut in site.mutations] if max(map(len, types)) > 1: print(site) + for mut in site.mutations: + print(mut) + for k in mut.derived_state.split(","): + print(mut_metadata[int(k)]) ``` ```{code-cell} :tags: ['remove-input'] for site in mts.sites(): if len(site.mutations) > 1: - types = [set([md["mutation_type"] for md in mut.metadata["mutation_list"]]) + types = [set([mut_metadata[int(k)]["mutation_type"] for k in mut.derived_state.split(",")]) for mut in site.mutations] if max(map(len, types)) > 1: util.pp(site) + for mut in site.mutations: + util.pp(mut) + for k in mut.derived_state.split(","): + util.pp(mut_metadata[int(k)]) ``` -Here, a neutral mutation has been put down on top of a selected mutation, +In each of these, a neutral mutation has been put down on top of a selected mutation, but stacked, so that any samples inheriting either of these mutations carries the selected mutation. For more discussion of how this works, see {class}`msprime.SLiMMutationModel`. diff --git a/docs/vignette_continuing.md b/docs/vignette_continuing.md index 3aff6616..8d60e211 100644 --- a/docs/vignette_continuing.md +++ b/docs/vignette_continuing.md @@ -81,17 +81,18 @@ so first we check that all the existing mutations are of a different type. rts = pyslim.recapitate(ts, ancestral_Ne=1000, recombination_rate=1e-8, random_seed=6) # check type m0 is not used: -mut_types = set([md['mutation_type'] - for mut in ts.mutations() - for md in mut.metadata['mutation_list']]) +mut_metadata = pyslim.mutation_metadata(rts) +mut_types = set([md['mutation_type'] for md in mut_metadata.values()]) print(f"Keeping {rts.num_mutations} existing mutations of type(s) {mut_types}.") assert 0 not in mut_types # add type m0 mutations next_id = pyslim.next_slim_mutation_id(rts) -rts = msprime.sim_mutations( +rts = pyslim.add_mutation_metadata( + msprime.sim_mutations( rts, rate=1e-8, random_seed=7, keep=True, model=msprime.SLiMMutationModel(type=0, next_id=next_id) + ) ) p = rts.sample_count_stat( @@ -118,22 +119,13 @@ To "continue" the simulation neutrally, we'll (remembering that this is *time ago*); we'll do this to simulate an additional 1000 generations. -This is almost what we need, but there is one more detail: -if complete coalescence occurs on any region of the genome, -msprime will stop simulating the history of that region. -This is a problem, since we need all lineages to extend back to ``end_time``. -To make sure all lineages trace back to ``end_time``, -we'll add one "fake" sample from a separate population, that *can't* coalesce with the rest, -then remove it before the next step, using the ``keep_input_roots=True`` argument to ``simplify()``. - ```{code-cell} new_time = 1000 demog_model = msprime.Demography() -demog_model.add_population(initial_size=10000, name='real') -demog_model.add_population(initial_size=10000, name='fake') +demog_model.add_population(initial_size=10000, name='pop') new_ts = msprime.sim_ancestry( - samples={'real' : 10000, 'fake' : 1}, + samples={'pop' : 10000}, demography=demog_model, end_time=new_time, sequence_length=rts.sequence_length, @@ -142,14 +134,8 @@ new_ts = msprime.sim_ancestry( new_ts = msprime.sim_mutations( new_ts, rate=1e-8, random_seed=10, keep=True, model=msprime.SLiMMutationModel(type=0) - ) -new_tables = new_ts.tables -# check that the spurious samples are 20000 and 20001 -for n in (20000, 20001): - assert n in new_ts.samples() - assert new_ts.node(n).population == 1 -new_tables.simplify(samples=np.arange(20000), keep_input_roots=True) -print(f"Remaining number of populations: {new_tables.populations.num_rows}") + ) +new_tables = new_ts.dump_tables() ``` **(2)** Now we'll pull out the IDs of the nodes from 1000 generations ago, @@ -158,13 +144,11 @@ randomly assign each to a node at the end of the SLiM simulation, and merge them. ```{code-cell} - new_nodes = np.where(new_tables.nodes.time == new_time)[0] print(f"There are {len(new_nodes)} nodes from the start of the new simulation.") -# There are 4425 nodes from the start of the new simulation. slim_nodes = rts.samples(time=0) -assert(len(slim_nodes) == 20000) +assert len(slim_nodes) == 20000 # randomly give new_nodes IDs in rts node_map = np.repeat(tskit.NULL, new_tables.nodes.num_rows) @@ -173,15 +157,15 @@ node_map[new_nodes] = np.random.choice(slim_nodes, len(new_nodes), replace=False # shift times: in nodes and mutations # since tree sequences are not mutable, we do this in the tables directly # also, unmark the nodes at the end of the SLiM simulation as samples -tables = rts.tables +tables = rts.dump_tables() tables.nodes.flags = tables.nodes.flags & ~np.uint32(tskit.NODE_IS_SAMPLE) tables.nodes.time = tables.nodes.time + new_time tables.mutations.time = tables.mutations.time + new_time # merge the two sets of tables tables.union(new_tables, node_map, - add_populations=False, - check_shared_equality=False) + add_populations=False, + check_shared_equality=False) # get back the tree sequence full_ts = tables.tree_sequence() @@ -243,3 +227,12 @@ to be identical in the two tree sequences, so ``union`` by default throws an err We don't expect that in this case, because, for instance, there could be a mutation above one of the terminal nodes in the SLiM tree sequence; this would clearly not be present in the new tree sequence. + +*Note:* sharp-eyed readers will note that the call to ``sim_mutations`` was not wrapped in +{func}`.add_mutation_metadata`. If we wanted to read this tree sequence into SLiM again +we'd need to add mutation metadata for these last mutations. +The easiest place to do this would be a call to {func}`.add_mutation_metadata_tables` +just after the ``union`` +(thus avoiding an extra conversion to tree sequence); +this will add metadata for only those mutations not already recorded. +We've left that step out of the code here for simplicity. diff --git a/docs/vignette_parallel_phylo.md b/docs/vignette_parallel_phylo.md index efa70f2f..d75eb921 100644 --- a/docs/vignette_parallel_phylo.md +++ b/docs/vignette_parallel_phylo.md @@ -110,10 +110,7 @@ f.close() Here's the result. Again, don't worry about the details, but you can see that the file encodes the phylogeny through a bunch of ``child : parent`` "rules": -```{code-cell} -:tags: ["hide-input"] -%%bash -cat sims.make +```{literalinclude} sims.make ``` With the makefile in hand, @@ -304,9 +301,7 @@ samples per population. ```{code-cell} rng = np.random.default_rng(seed=123) ind_alive = pyslim.individuals_alive_at(tsu, 0) -# TODO: this will work in the next tskit -# ind_pops = tsu.individuals_population[ind_alive] -ind_pops = np.array([tsu.node(tsu.individual(i).nodes[0]).population for i in ind_alive]) +ind_pops = tsu.individuals_population[ind_alive] subsample_indivs = [ rng.choice(ind_alive[ind_pops == pop_ids[name]], 2) for name in pops diff --git a/docs/vignette_space.md b/docs/vignette_space.md index 3c1d3ea2..4d0adb46 100644 --- a/docs/vignette_space.md +++ b/docs/vignette_space.md @@ -98,13 +98,19 @@ and so individuals may live for more than one time step (even up to age 10, it s Let's check that all these individuals are alive at either (a) today or (b) 1000 time steps ago. ```{code-cell} +slim_ts_md = slim_ts.metadata for t in [0, 1000]: - alive = pyslim.individuals_alive_at(slim_ts, t) + alive = pyslim.individuals_alive_at(slim_ts, t, ts_metadata=slim_ts_md) print(f"There were {len(alive)} individuals alive {t} time steps in the past.") ``` -And, 1242 + 1255 is 2497, the total number of individuals. -So, this all checks out. +We can add these numbers to get the total number of individuals, so this all checks out. +On a separate note: here we used the optional ``ts_metadata`` argument +to {func}`.individuals_alive_at`. +This is because {func}`.individuals_alive_at` is a function that requires top-level metadata use, +which entails overhead that can be avoided by pre-extracting the metadata and passing it in. +Here this is not important because it is only run twice, but this would be essential +if we had a longer list of times. ## Recapitation and mutation @@ -142,11 +148,13 @@ we would need to pass ``keep_input_roots=True`` to allow recapitation. ```{code-cell} recap_ts = pyslim.recapitate(slim_ts, recombination_rate=1e-8, ancestral_Ne=1000) -ts = msprime.sim_mutations( +ts = pyslim.add_mutation_metadata( + msprime.sim_mutations( recap_ts, rate=1e-8, model=msprime.SLiMMutationModel(type=0), keep=True, + ) ) ts.dump("spatial_sim.recap.trees") diff --git a/pyproject.toml b/pyproject.toml index e86faeab..0a43898e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ keywords = ["tree sequences", "tskit"] requires-python = ">=3.11" dependencies = [ "msprime>=1.0.1", - "tskit", + "tskit>=1.0.3", "numpy", ] @@ -56,6 +56,7 @@ test = [ "tskit", "msprime", "pandas", + "frozendict", ] docs = [ diff --git a/pyslim/_version.py b/pyslim/_version.py index 3b480d79..8eea699a 100644 --- a/pyslim/_version.py +++ b/pyslim/_version.py @@ -5,6 +5,6 @@ except Exception: pyslim_version = "unknown" -slim_file_version = "0.9" +slim_file_version = "1.0" # other file versions that require no modification -compatible_slim_file_versions = ["0.9"] +compatible_slim_file_versions = ["1.0"] diff --git a/pyslim/methods.py b/pyslim/methods.py index 19c12206..cf84634c 100644 --- a/pyslim/methods.py +++ b/pyslim/methods.py @@ -12,6 +12,7 @@ set_metadata_schemas, set_tree_sequence_metadata, ) +from .slim_tree_sequence import mutation_metadata from .util import unique_labels_by_group @@ -43,7 +44,7 @@ def _mark_not_samples(tables, nodes): ) -def _chromosome_index(ts): +def _chromosome_index(ts_metadata): """ For a tree sequence produced by a multichromosome simulation, returns the index of the chromosome whose information is stored in this tree sequence @@ -52,18 +53,19 @@ def _chromosome_index(ts): ``ts.metadata['SLiM']['this_chromosome']``, and provides the index of this chromosome into `ts.metadata['SLiM']['chromosomes']``, if present. - :param tskit.TreeSequence ts: The tree sequence or table collection. + :param dict ts_metadata: The top-level metadata from a tree sequence + or table collection. """ if not ( - isinstance(ts.metadata, dict) - and "SLiM" in ts.metadata - and "this_chromosome" in ts.metadata["SLiM"] + isinstance(ts_metadata, dict) + and "SLiM" in ts_metadata + and "this_chromosome" in ts_metadata["SLiM"] ): raise ValueError( "The tree sequence does not have the necessary " "information in top-level metadata." ) - k = ts.metadata["SLiM"]["this_chromosome"]["index"] + k = ts_metadata["SLiM"]["this_chromosome"]["index"] return k @@ -88,15 +90,17 @@ def _is_chrom_vacant(k, b): return (b >> i & 1) > 0 -def has_vacant_samples(ts): +def has_vacant_samples(ts, _ts_metadata=None): """ Returns whether the tree sequence has vacant sample nodes. See :meth:`remove_vacant`. :param tskit.TreeSequence ts: The tree sequence. """ + if _ts_metadata is None: + _ts_metadata = ts.metadata out = False - k = _chromosome_index(ts) + k = _chromosome_index(_ts_metadata) for n in ts.samples(): md = ts.node(n).metadata if md is not None: @@ -106,8 +110,45 @@ def has_vacant_samples(ts): return out +def nodes_vacant(ts): + """ + Evaluates which nodes in the tree sequence are vacant: returns a boolean + vector whose k-th element is True if the k-th node is labelled as *vacant* + in the node's metadata recorded by SLiM. A vacant node represents a blank + placeholder in SLiM: either a "null haplosome" (used as placeholders for + sex chromosomes and other chromosome types not of consistent ploidy in all + individuals) or simply an unused node for haploid chromosome types. See + :meth:`remove_vacant`. + + + :param tskit.TreeSequence ts: The tree sequence. + :return boolean ndarray: + """ + # not using chrom_index here because we expect people to call this on lots of nodes + k = ts.metadata["SLiM"]["this_chromosome"]["index"] + out = np.array( + [ + node.metadata is not None and _is_chrom_vacant(k, node.metadata["is_vacant"]) + for node in ts.nodes() + ], + dtype="bool", + ) + return out + + def node_is_vacant(ts, node): """ + **DEPRECATED:** use :func:`.nodes_vacant` instead. This function requires + top-level metadata access, which can be costly, so it is much better to do, + for instance: + + .. code-block:: python + + vacant = nodes_vacant(ts) + for node in ts.nodes(): + # instead of node_is_vacant(ts, node), use: + vacant[node.id] + Returns True if the node is labelled as *vacant* in the node's metadata recorded by SLiM. A vacant node represents a blank placeholder in SLiM: either a "null haplosome" (used as placeholders for sex chromosomes and other @@ -117,12 +158,19 @@ def node_is_vacant(ts, node): :param tskit.TreeSequence ts: The tree sequence. :param tskit.Node node: The node object. """ + warnings.warn( + "The node_is_vacant method is deprecated: changes in SLiM v6 " + " means that repeated use of this method will be unreasonably slow, " + "so it will be removed in a future version of pyslim: " + "obtain this information from pyslim.vacant_nodes( ) instead.", + FutureWarning, + ) # not using chrom_index here because we expect people to call this on lots of nodes k = ts.metadata["SLiM"]["this_chromosome"]["index"] return node.metadata is not None and _is_chrom_vacant(k, node.metadata["is_vacant"]) -def _record_vacant_tables(tables): +def _record_vacant_tables(tables, ts_metadata): """ Sets the NODE_IS_VACANT_SAMPLE flag for all vacant, sample nodes. See :meth:`remove_vacant`. @@ -135,7 +183,7 @@ def _record_vacant_tables(tables): "flags are being overwritten; this may mean you've already run " "remove_vacant and so don't need to run it again." ) - k = _chromosome_index(tables) + k = _chromosome_index(ts_metadata) dn = tables.nodes.asdict() dn["flags"] &= ~NODE_IS_VACANT_SAMPLE @@ -156,7 +204,7 @@ def _remove_vacant_sample_flags(tables): tables.nodes.set_columns(**dn) -def remove_vacant(ts): +def remove_vacant(ts, _ts_metadata=None): """ Remove sample flags from all vacant nodes. @@ -177,23 +225,27 @@ def remove_vacant(ts): :param tskit.TreeSequence ts: The tree sequence. """ + if _ts_metadata is None: + _ts_metadata = ts.metadata tables = ts.dump_tables() - remove_vacant_tables(tables) + remove_vacant_tables(tables, _ts_metadata) return tables.tree_sequence() -def remove_vacant_tables(tables): +def remove_vacant_tables(tables, _ts_metadata=None): """ Does the work of :meth:`remove_vacant`, modifying ``tables`` in place. :param tskit.TableCollection tables: The tables underlying a tree sequence. """ - _record_vacant_tables(tables) + if _ts_metadata is None: + _ts_metadata = tables.metadata + _record_vacant_tables(tables, _ts_metadata) is_vacant = np.where(tables.nodes.flags & NODE_IS_VACANT_SAMPLE > 0)[0] _mark_not_samples(tables, is_vacant) -def restore_vacant(ts): +def restore_vacant(ts, _ts_metadata=None): """ The inverse of :meth:`remove_vacant`. @@ -204,19 +256,23 @@ def restore_vacant(ts): :param tskit.TreeSequence ts: The tree sequence. """ + if _ts_metadata is None: + _ts_metadata = ts.metadata tables = ts.dump_tables() - restore_vacant_tables(tables) + restore_vacant_tables(tables, _ts_metadata) return tables.tree_sequence() -def restore_vacant_tables(tables): +def restore_vacant_tables(tables, _ts_metadata=None): """ Does the work of :meth:`restore_vacant`, modifying ``tables`` in place. :param tskit.TableCollection tables: The tables underlying a tree sequence. """ + if _ts_metadata is None: + _ts_metadata = tables.metadata is_vacant = np.where(tables.nodes.flags & NODE_IS_VACANT_SAMPLE > 0)[0] - k = _chromosome_index(tables) + k = _chromosome_index(_ts_metadata) for j in is_vacant: n = tables.nodes[j] if n.metadata is None: @@ -273,14 +329,15 @@ def recapitate(ts, ancestral_Ne=None, *, keep_vacant=False, **kwargs): vacant sample nodes. Default: False. :param dict kwargs: Any other arguments to :func:`msprime.sim_ancestry`. """ - is_current_version(ts, _warn=True) + ts_metadata = ts.metadata + is_current_version(ts_metadata, _warn=True) # we need to ask msprime to *not* simulate from any 'vacant' haplosomes; # which we do by marking these as not samples; note that `initial_state` # can take a TableCollection, not just a TreeSequence - has_vacant = has_vacant_samples(ts) + has_vacant = has_vacant_samples(ts, ts_metadata) if has_vacant: - ts = remove_vacant(ts) + ts = remove_vacant(ts, ts_metadata) if ancestral_Ne is not None: if "demography" in kwargs: @@ -338,11 +395,90 @@ def recapitate(ts, ancestral_Ne=None, *, keep_vacant=False, **kwargs): recap = msprime.sim_ancestry(initial_state=ts, **kwargs) if has_vacant and keep_vacant: - recap = restore_vacant(recap) + recap = restore_vacant(recap, ts_metadata) return recap +def add_mutation_metadata(ts, mutation_type=0, remove_unused=False): + """ + Returns a new tree sequence with default information added to the top-level metadata + for each mutation in the tree sequence for which that information is not already present. + To do this, this method looks for all SLiM IDs that are found in the derived + state of some mutation but are not represented in the top-level metadata + (see :func:`.mutation_metadata`). This function then adds entries to that top-level + metadata with default values (see :func:`.default_slim_metadata`), + except that (a) the ``mutation_type`` can be specified; + and (b) the ``slim_time`` is set using the ``tick`` value in top-level metadata + and the ``time`` of the oldest tskit mutation in which the SLiM mutation occurs. + + :param tskit.TreeSequence ts: The tree sequence to transform. + :param int mutation_type: The numeric ID of the mutation type in SLiM. + :param bool remove_unused: Whether to also remove from metadata information about any + mutations not seen in the derived states of the tree sequence. + """ + tables = ts.dump_tables() + add_mutation_metadata_tables( + tables, mutation_type=mutation_type, remove_unused=remove_unused + ) + return tables.tree_sequence() + + +def add_mutation_metadata_tables(tables, mutation_type=0, remove_unused=False): + """ + Modifies the tables in place to add metadata for any mutations for which it is missing; + see :func:`.add_mutation_metadata`. + + :param tskit.TableCollection tables: The table collection to be modified. + :param int mutation_type: The numeric ID of the mutation type in SLiM. + :param bool remove_unused: Whether to also remove from metadata information about any + mutations not seen in the derived states of the tree sequence. + """ + ts_metadata = tables.metadata + if ( + not isinstance(ts_metadata, dict) + or "SLiM" not in ts_metadata + or "SLiM_mutation_list" not in ts_metadata + ): + raise ValueError( + "Top-level metadata schema is not correct: " + "do you need to run pyslim.annotate()?" + ) + existing_muts = {x["mutation_id"] for x in ts_metadata["SLiM_mutation_list"]} + mut_ids = [ + (int(j), mut.time) + for mut in tables.mutations + for j in mut.derived_state.split(",") + ] + mut_ids.sort() + mut_ids = np.array(mut_ids, dtype="int") # floors times + # remove duplicate IDs, keeping the last (most recent) + keep = np.full(len(mut_ids), True, dtype="bool") + keep[np.where(np.diff(mut_ids[:, 0]) == 0)[0]] = False + mut_ids = mut_ids[keep, :] + mut_ids[:, 1] = slim_time( + tables, mut_ids[:, 1], stage="late", ts_metadata=ts_metadata + ) + # this assumes mutations were added in late(), which is what SLiM does + ts_metadata["SLiM_mutation_list"].extend( + [ + default_slim_metadata( + "mutation_list_entry", + mutation_id=int(j), + mutation_type=mutation_type, + slim_time=int(t), + ) + for j, t in mut_ids + if j not in existing_muts + ] + ) + if remove_unused and len(mut_ids) < len(ts_metadata["SLiM_mutation_list"]): + ts_metadata["SLiM_mutation_list"] = [ + x for x in ts_metadata["SLiM_mutation_list"] if x["mutation_id"] in mut_ids + ] + tables.metadata = ts_metadata + + def convert_alleles(ts): """ Returns a modified tree sequence in which alleles have been replaced by @@ -379,25 +515,28 @@ def convert_alleles(ts): # so we must guess which is the most recent, by choosing the one that # has the largest SLiM time, doesn't appear in the parent list, or has # the lagest SLiM ID. - nuc_inds = tables.mutations.metadata_vector( - ["mutation_list", 0, "nucleotide"], dtype="int" - ) - num_stacked = np.array([len(m.metadata["mutation_list"]) for m in ts.mutations()]) - for k in np.where(num_stacked > 1)[0]: + mut_metadata = mutation_metadata(ts) + mut_ids = np.array([x["mutation_id"] for x in mut_metadata.values()], dtype="int") + alleles = np.array([x["nucleotide"] for x in mut_metadata.values()], dtype="int") + # mut_inds will map from tskit-mutations to slim-mutations + mut_inds = ts.mutations_derived_state.copy() + num_stacked = np.strings.count(mut_inds, ",") + mut_inds[num_stacked > 0] = "-1" + mut_inds = mut_inds.astype("int", copy=False) + for k in np.where(num_stacked > 0)[0]: mut = ts.mutation(k) if mut.parent == tskit.NULL: pids = [] else: pids = ts.mutation(mut.parent).derived_state.split(",") x = [ - (md["slim_time"], i not in pids, int(i), j) - for j, (i, md) in enumerate( - zip(mut.derived_state.split(","), mut.metadata["mutation_list"]) - ) + (mut_metadata[int(i)]["slim_time"], i not in pids, int(i), j) + for j, i in enumerate(mut.derived_state.split(",")) ] x.sort() - j = x[-1][3] - nuc_inds[k] = mut.metadata["mutation_list"][j]["nucleotide"] + mut_inds[k] = x[-1][2] + assert np.all(mut_inds >= 0), "This should not occur: please file a bug report." + nuc_inds = alleles[np.searchsorted(mut_ids, mut_inds)] if np.any(nuc_inds == -1): raise ValueError("All mutations must be nucleotide mutations.") da = np.array(NUCLEOTIDES)[nuc_inds] @@ -405,7 +544,6 @@ def convert_alleles(ts): k = tables.sites.position.astype("int") aa = np.frombuffer(ts.reference_sequence.data.encode("utf-8"), dtype="S1")[k] tables.sites.packset_ancestral_state(aa.tobytes().decode("utf-8")) - return tables.tree_sequence() @@ -461,11 +599,11 @@ def generate_nucleotides(ts, reference_sequence=None, keep=True, seed=None): raise ValueError( "Reference sequence must be a string of A, C, G, and T only." ) - + ts_metadata = ts.metadata + mut_info = mutation_metadata(ts, _ts_metadata=ts_metadata) tables = ts.dump_tables() if reference_sequence is not None: tables.reference_sequence.data = reference_sequence - tables.mutations.clear() sets = [[k for k in range(4) if k != i] for i in range(4)] states = np.full((ts.num_mutations,), -1) k = tables.sites.position.astype("int") @@ -484,9 +622,9 @@ def generate_nucleotides(ts, reference_sequence=None, keep=True, seed=None): pa = states[mut.parent] pds = ts.mutation(mut.parent).derived_state.split(",") this_da = pa - ml = mut.metadata max_time = -np.inf - for i, md in zip(mut.derived_state.split(","), ml["mutation_list"]): + for i in mut.derived_state.split(","): + md = mut_info[int(i)] da = md["nucleotide"] if da == -1 or not keep: if i in muts: @@ -502,22 +640,26 @@ def generate_nucleotides(ts, reference_sequence=None, keep=True, seed=None): this_da = da max_time = md["slim_time"] states[mut.id] = this_da - tables.mutations.append(mut.replace(metadata=ml)) - md = tables.metadata - md["SLiM"]["nucleotide_based"] = True - tables.metadata = md + ts_metadata["SLiM"]["nucleotide_based"] = True + ts_metadata["SLiM_mutation_list"] = list(mut_info.values()) + tables.metadata = ts_metadata return tables.tree_sequence() -def individual_ages(ts): +def individual_ages(ts, ts_metadata=None): """ Returns the ages of all individuals in the tree sequence, extracted from metadata. The result is a array of length equal to the number of individuals, with k-th entry equal to ``ts.individual(k).metadata["age"]``. + :param tskit.TreeSequence ts: The tree sequence. + :param dict ts_metadata: Optionally, the top-level metadata for ``ts``. If + this does not match the actual top-level metadata, incorrect values may result. :return: An array of ages of individuals. """ - if ts.metadata["SLiM"]["model_type"] != "WF": + if ts_metadata is None: + ts_metadata = ts.metadata + if ts_metadata["SLiM"]["model_type"] != "WF": ages = ts.tables.individuals.metadata_vector("age") else: ages = np.zeros(ts.num_individuals, dtype="int") @@ -525,7 +667,13 @@ def individual_ages(ts): def individuals_alive_at( - ts, time, stage="late", remembered_stage=None, population=None, samples_only=False + ts, + time, + stage="late", + remembered_stage=None, + population=None, + samples_only=False, + ts_metadata=None, ): """ Returns an array giving the IDs of all individuals that are known to be @@ -576,26 +724,29 @@ def individuals_alive_at( population(s) with these population ID(s). :param bool samples_only: Whether to return only individuals who have at least one node marked as samples. + :param dict ts_metadata: Optionally, the top-level metadata for ``ts``. If + this does not match the actual top-level metadata, incorrect values may result. """ - is_current_version(ts, _warn=True) if stage not in ("late", "early", "first"): raise ValueError( f"Unknown stage '{stage}': should be either 'first', 'early' or 'late'." ) - + if ts_metadata is None: + ts_metadata = ts.metadata + is_current_version(ts_metadata, _warn=True) if remembered_stage is None: - remembered_stage = ts.metadata["SLiM"]["stage"] + remembered_stage = ts_metadata["SLiM"]["stage"] if remembered_stage not in ("late", "early", "first"): raise ValueError( f"Unknown remembered_stage '{remembered_stage}': " "should be either 'first', 'early' or 'late'." ) - if remembered_stage != ts.metadata["SLiM"]["stage"]: + if remembered_stage != ts_metadata["SLiM"]["stage"]: warnings.warn( f"Provided remembered_stage '{remembered_stage}' does not" " match the stage at which the tree sequence was saved" - f" ('{ts.metadata['SLiM']['stage']}'). This is not necessarily" + f" ('{ts_metadata['SLiM']['stage']}'). This is not necessarily" " an error, but mismatched stages will lead to inconsistencies:" " make sure you know what you're doing." ) @@ -615,12 +766,12 @@ def individuals_alive_at( # let x = 1 if the stage is 'first' or (is 'early' and WF) # and y = 1 if remembered stage is 'late' or (is 'early' and nonWF); # then t = time + x + y - 1 . - is_wf = ts.metadata["SLiM"]["model_type"] == "WF" + is_wf = ts_metadata["SLiM"]["model_type"] == "WF" x = stage == "first" or (stage == "early" and is_wf) y = remembered_stage == "late" or (remembered_stage == "early" and not is_wf) t = time + x + y - 1 birth_times = ts.individuals_time - ages = individual_ages(ts) + ages = individual_ages(ts, ts_metadata) if is_wf: alive_bool = birth_times == t else: @@ -643,7 +794,9 @@ def individuals_alive_at( return np.where(alive_bool)[0] -def individual_ages_at(ts, time, stage="late", remembered_stage="late"): +def individual_ages_at( + ts, time, stage="late", remembered_stage="late", ts_metadata=None +): """ Returns the `ages` of each individual at the corresponding time ago, which will be ``nan`` if the individual is either not born yet or dead. @@ -669,20 +822,24 @@ def individual_ages_at(ts, time, stage="late", remembered_stage="late"): is alive (either "early" or "late"; defaults to "late"). :param str remembered_stage: The stage in the SLiM life cycle during which individuals were Remembered. + :param dict ts_metadata: Optionally, the top-level metadata for ``ts``. If + this does not match the actual top-level metadata, incorrect values may result. """ + if ts_metadata is None: + ts_metadata = ts.metadata ages = np.repeat(np.nan, ts.num_individuals) alive = individuals_alive_at( - ts, time, stage=stage, remembered_stage=remembered_stage + ts, time, stage=stage, remembered_stage=remembered_stage, ts_metadata=ts_metadata ) # to convert individuals_time to number of ticks ago we subtract (y - 1), so - is_wf = ts.metadata["SLiM"]["model_type"] == "WF" + is_wf = ts_metadata["SLiM"]["model_type"] == "WF" y = remembered_stage == "late" or (remembered_stage == "early" and not is_wf) t = time + y - 1 ages[alive] = ts.individuals_time[alive] - t return ages -def slim_time(ts, time, stage="late"): +def slim_time(ts, time, stage="late", ts_metadata=None): """ Converts the given "tskit times" (i.e., in units of time before the end of the simulation) to SLiM times (those recorded by SLiM, usually in units @@ -707,17 +864,26 @@ def slim_time(ts, time, stage="late"): this may not return what you expect. See :ref:`sec_metadata_converting_times` for more discussion. + This method accesses top-level metadata, which may be a costly operation, + so if this method will be called many times, it is recommended to + extract this to a variable (e.g., ``ts_metadata = ts.metadata``) and pass it + to this method (as ``ts_metadata``). However, beware: if ``ts_metadata`` + is not in sync with the actual top-level metadata, incorrect values may result. + :param tskit.TreeSequence ts: A SLiM-compatible TreeSequence. :param numpy.ndarray time: An array of times to be converted. :param str stage: The stage of the SLiM life cycle that the SLiM time should be computed for. + :param dict ts_metadata: Optionally, the top-level metadata for ``ts``. """ - is_current_version(ts, _warn=True) - is_wf = ts.metadata["SLiM"]["model_type"] == "WF" - remembered_stage = ts.metadata["SLiM"]["stage"] + if ts_metadata is None: + ts_metadata = ts.metadata + is_current_version(ts_metadata, _warn=True) + is_wf = ts_metadata["SLiM"]["model_type"] == "WF" + remembered_stage = ts_metadata["SLiM"]["stage"] x = stage == "first" or (stage == "early" and is_wf) y = remembered_stage == "late" or (remembered_stage == "early" and not is_wf) - slim_time = ts.metadata["SLiM"]["tick"] - time + x + y - 1 + slim_time = ts_metadata["SLiM"]["tick"] - time + x + y - 1 return slim_time @@ -920,7 +1086,7 @@ def annotate(ts, **kwargs): :param str reference_sequence: A reference sequence of length equal to ts.sequence_length. :param bool annotate_mutations: Whether to replace mutation metadata - with defaults. (If False, the mutation table is unchanged.) + with defaults. (If False, information about mutations is unchanged.) """ tables = ts.dump_tables() annotate_tables(tables, **kwargs) @@ -965,12 +1131,15 @@ def annotate_tables( top_metadata["tick"] = tick top_metadata["cycle"] = cycle top_metadata["stage"] = stage - set_tree_sequence_metadata(tables, **top_metadata) + md = tables.metadata + if isinstance(md, dict) and "SLiM_mutation_list" in md: + top_metadata["SLiM_mutation_list"] = md["SLiM_mutation_list"] + ts_metadata = set_tree_sequence_metadata(tables, **top_metadata) set_metadata_schemas(tables) _annotate_nodes_individuals(tables, age=default_ages) _annotate_populations(tables) if annotate_mutations: - _annotate_sites_mutations(tables) + _annotate_sites_mutations(tables, ts_metadata=ts_metadata) if reference_sequence is not None: tables.reference_sequence.data = reference_sequence @@ -1109,46 +1278,46 @@ def _annotate_populations(tables): tables.populations[j] = p.replace(metadata=md) -def _annotate_sites_mutations(tables): +def _annotate_sites_mutations(tables, ts_metadata): """ Adds to a TableCollection the information relevant to mutations required - for SLiM to load in a tree sequence. This means adding to the metadata column - of the Mutation table, It will also + for SLiM to load in a tree sequence. This means adding metadata to the + SLiM_mutation_list in top-level metadata. It will also: - give SLiM IDs to each mutation - replace ancestral states with "" - This will replace any information already in the metadata or derived state - columns of the Mutation table. We set slim_time in metadata so that + This will replace any information already in the metadata, and the derived + state columns of the Mutation table. We set slim_time in metadata so that - tick = floor(tskit time) + slim_time """ - if len(tables.mutations.metadata) > 0: + if ( + isinstance(ts_metadata, dict) + and "SLiM_mutation_list" in ts_metadata + and len(ts_metadata["SLiM_mutation_list"]) > 0 + ): warnings.warn( - "The provided tree sequence already has some mutations with " + "The provided tree sequence already has top-level mutation " "metadata; this metadata will be overwritten." ) num_mutations = tables.mutations.num_rows default_mut = default_slim_metadata("mutation_list_entry") - dsb, dso = tskit.pack_bytes([str(j).encode() for j in range(num_mutations)]) - slim_time = tables.metadata["SLiM"]["tick"] - np.floor(tables.mutations.time).astype( + slim_time = ts_metadata["SLiM"]["tick"] - np.floor(tables.mutations.time).astype( "int" ) - mms = tables.mutations.metadata_schema - mutation_metadata = [ - mms.encode_row( - { - "mutation_list": [ - { - "mutation_type": default_mut["mutation_type"], - "selection_coeff": default_mut["selection_coeff"], - "subpopulation": default_mut["subpopulation"], - "slim_time": st, - "nucleotide": default_mut["nucleotide"], - } - ] - } - ) - for st in slim_time + mutation_list = [ + { + "mutation_id": j, + "mutation_type": default_mut["mutation_type"], + "per_trait": default_mut["per_trait"], + "subpopulation": default_mut["subpopulation"], + "slim_time": int(st), + "nucleotide": default_mut["nucleotide"], + "padding": None, + } + for j, st in enumerate(slim_time) ] - mdb, mdo = tskit.pack_bytes(mutation_metadata) + ts_metadata["SLiM_mutation_list"] = mutation_list + tables.metadata = ts_metadata + dsb, dso = tskit.pack_bytes([str(j).encode() for j in range(num_mutations)]) tables.mutations.set_columns( site=tables.mutations.site, node=tables.mutations.node, @@ -1156,8 +1325,6 @@ def _annotate_sites_mutations(tables): derived_state=dsb, derived_state_offset=dso, parent=tables.mutations.parent, - metadata=mdb, - metadata_offset=mdo, ) tables.sites.set_columns( position=tables.sites.position, diff --git a/pyslim/slim_metadata.py b/pyslim/slim_metadata.py index 0fb4144b..e515f559 100644 --- a/pyslim/slim_metadata.py +++ b/pyslim/slim_metadata.py @@ -1,6 +1,8 @@ +import copy import json import warnings +import numpy as np import tskit from ._version import * # noqa F403 @@ -38,227 +40,309 @@ def is_vacant_num_bytes(num_chromosomes): _raw_slim_metadata_schemas = { "tree_sequence": { "$schema": "http://json-schema.org/schema#", - "codec": "json", - "examples": [ - { - "SLiM": { - "file_version": "0.9", - "name": "fox", - "description": "foxes on Catalina island", - "cycle": 123, - "tick": 123, - "model_type": "WF", - "this_chromosome": { - "id": 1, - "index": 0, - "symbol": "1", - "name": "autosome_1", - "type": "A", - }, - "chromosomes": [ - {"id": 1, "symbol": "1", "name": "autosome_1", "type": "A"}, - {"id": 35, "symbol": "MT", "name": "mtDNA", "type": "HF"}, - ], - "nucleotide_based": False, - "separate_sexes": True, - "spatial_dimensionality": "xy", - "spatial_periodicity": "x", + "codec": "json+struct", + "json": { + "codec": "json", + "description": "SLiM schema for JSON top-level metadata.", + "examples": [ + { + "SLiM": { + "chromosomes": [ + {"id": 1, "name": "autosome_1", "symbol": "1", "type": "A"}, + {"id": 35, "name": "mtDNA", "symbol": "MT", "type": "HF"}, + ], + "cycle": 123, + "description": "foxes on Catalina island", + "file_version": "1.0", + "model_type": "WF", + "name": "fox", + "nucleotide_based": False, + "separate_sexes": True, + "spatial_dimensionality": "xy", + "spatial_periodicity": "x", + "this_chromosome": { + "id": 1, + "index": 0, + "name": "autosome_1", + "symbol": "1", + "type": "A", + }, + "tick": 123, + "traits": [ + {"index": 0, "name": "simT", "type": "multiplicative"} + ], + } } - } - ], - "properties": { - "SLiM": { - "description": "Top-level metadata for a SLiM tree sequence, file format version 0.9", - "properties": { - "file_version": { - "description": "The SLiM 'file format version' of this tree sequence.", - "type": "string", - }, - "name": { - "description": "The SLiM species name represented by this tree sequence.", - "type": "string", - }, - "description": { - "description": "A user-configurable description of the species represented by this tree sequence.", - "type": "string", - }, - "cycle": { - "description": "The 'SLiM cycle' counter when this tree sequence was recorded.", - "type": "integer", - }, - "tick": { - "description": "The 'SLiM tick' counter when this tree sequence was recorded.", - "type": "integer", - }, - "model_type": { - "description": "The model type used for the last part of this simulation (WF or nonWF).", - "enum": ["WF", "nonWF"], - "type": "string", - }, - "this_chromosome": { - "description": "The chromosome represented by the tree sequence in this file.", - "properties": { - "id": { - "description": "An integer identifier for the chromosome, unique within this set of tree sequences; often the chromosome number in the organism being represented, such as 1.", - "type": "integer", - }, - "index": { - "description": "The (zero-based) index of this chromosome in the chromosomes metadata array (if present), which should match the information given here.", - "type": "integer", - }, - "symbol": { - "description": 'A short string symbol for the chromosome, unique within this set of tree sequences, such as "1" or "MT".', - "type": "string", - }, - "name": { - "description": "A user-specified name for the chromosome, such as an accession identifier.", - "type": "string", - }, - "type": { - "description": "The type of chromosome, as specified by SLiM.", - "type": "string", + ], + "properties": { + "SLiM": { + "description": "Top-level metadata for a SLiM tree sequence, file format version 1.0", + "properties": { + "chromosomes": { + "description": "The chromosomes represented by the collection of tree sequences, of which this tree sequence is one member.", + "items": { + "properties": { + "id": { + "description": "An integer identifier for the chromosome, unique within this set of tree sequences; often the chromosome number in the organism being represented, such as 1.", + "type": "integer", + }, + "name": { + "description": "A user-specified name for the chromosome, such as an accession identifier.", + "type": "string", + }, + "symbol": { + "description": 'A short string symbol for the chromosome, unique within this set of tree sequences, such as "1" or "MT".', + "type": "string", + }, + "type": { + "description": "The type of chromosome, as specified by SLiM.", + "type": "string", + }, + }, + "required": ["id", "symbol", "type"], + "type": "object", }, + "type": "array", }, - "required": ["id", "index", "symbol", "type"], - "type": "object", - }, - "chromosomes": { - "description": "The chromosomes represented by the collection of tree sequences, of which this tree sequence is one member.", - "items": { + "cycle": { + "description": "The 'SLiM cycle' counter when this tree sequence was recorded.", + "type": "integer", + }, + "description": { + "description": "A user-configurable description of the species represented by this tree sequence.", + "type": "string", + }, + "file_version": { + "description": "The SLiM 'file format version' of this tree sequence.", + "type": "string", + }, + "model_type": { + "description": "The model type used for the last part of this simulation (WF or nonWF).", + "enum": ["WF", "nonWF"], + "type": "string", + }, + "name": { + "description": "The SLiM species name represented by this tree sequence.", + "type": "string", + }, + "nucleotide_based": { + "description": "Whether the simulation was nucleotide-based.", + "type": "boolean", + }, + "separate_sexes": { + "description": "Whether the simulation had separate sexes.", + "type": "boolean", + }, + "spatial_dimensionality": { + "description": "The spatial dimensionality of the simulation.", + "enum": ["", "x", "xy", "xyz"], + "type": "string", + }, + "spatial_periodicity": { + "description": "The spatial periodicity of the simulation.", + "enum": ["", "x", "y", "z", "xy", "xz", "yz", "xyz"], + "type": "string", + }, + "stage": { + "description": "The stage of the SLiM life cycle when this tree sequence was recorded.", + "type": "string", + }, + "this_chromosome": { + "description": "The chromosome represented by the tree sequence in this file.", "properties": { "id": { "description": "An integer identifier for the chromosome, unique within this set of tree sequences; often the chromosome number in the organism being represented, such as 1.", "type": "integer", }, - "symbol": { - "description": 'A short string symbol for the chromosome, unique within this set of tree sequences, such as "1" or "MT".', - "type": "string", + "index": { + "description": "The (zero-based) index of this chromosome in the chromosomes metadata array (if present), which should match the information given here.", + "type": "integer", }, "name": { "description": "A user-specified name for the chromosome, such as an accession identifier.", "type": "string", }, + "symbol": { + "description": 'A short string symbol for the chromosome, unique within this set of tree sequences, such as "1" or "MT".', + "type": "string", + }, "type": { "description": "The type of chromosome, as specified by SLiM.", "type": "string", }, }, - "required": ["id", "symbol", "type"], + "required": ["id", "index", "symbol", "type"], "type": "object", }, - "type": "array", - }, - "nucleotide_based": { - "description": "Whether the simulation was nucleotide-based.", - "type": "boolean", - }, - "separate_sexes": { - "description": "Whether the simulation had separate sexes.", - "type": "boolean", - }, - "spatial_dimensionality": { - "description": "The spatial dimensionality of the simulation.", - "enum": ["", "x", "xy", "xyz"], - "type": "string", - }, - "spatial_periodicity": { - "description": "The spatial periodicity of the simulation.", - "enum": ["", "x", "y", "z", "xy", "xz", "yz", "xyz"], - "type": "string", - }, - "stage": { - "description": "The stage of the SLiM life cycle when this tree sequence was recorded.", - "type": "string", - }, - }, - "required": [ - "model_type", - "tick", - "file_version", - "spatial_dimensionality", - "spatial_periodicity", - "this_chromosome", - "separate_sexes", - "nucleotide_based", - ], - "type": "object", - } - }, - "required": ["SLiM"], - "type": "object", - }, - "edge": None, - "site": None, - "mutation": { - "$schema": "http://json-schema.org/schema#", - "additionalProperties": False, - "codec": "struct", - "description": "SLiM schema for mutation metadata.", - "examples": [ - { - "mutation_list": [ - { - "mutation_type": 1, - "nucleotide": 3, - "selection_coeff": -0.2, - "slim_time": 243, - "subpopulation": 0, - } - ] - } - ], - "properties": { - "mutation_list": { - "items": { - "additionalProperties": False, - "properties": { - "mutation_type": { - "binaryFormat": "i", - "description": "The index of this mutation's mutationType.", - "index": 1, - "type": "integer", - }, - "nucleotide": { - "binaryFormat": "b", - "description": "The nucleotide for this mutation (0=A , 1=C , 2=G, 3=T, or -1 for none)", - "index": 5, - "type": "integer", - }, - "selection_coeff": { - "binaryFormat": "f", - "description": "This mutation's selection coefficient.", - "index": 2, - "type": "number", - }, - "slim_time": { - "binaryFormat": "i", - "description": "The SLiM tick counter when this mutation occurred.", - "index": 4, + "tick": { + "description": "The 'SLiM tick' counter when this tree sequence was recorded.", "type": "integer", }, - "subpopulation": { - "binaryFormat": "i", - "description": "The ID of the subpopulation this mutation occurred in.", - "index": 3, - "type": "integer", + "traits": { + "description": "The traits defined for this tree sequence; each mutation and individual will have per-trait metadata.", + "items": { + "properties": { + "baselineAccumulation": { + "description": "Whether the baseline offset includes accumulated effects from fixed (substituted) mutations.", + "type": "boolean", + }, + "baselineOffset": { + "description": "The baseline offset of the trait.", + "type": "number", + }, + "directFitnessEffect": { + "description": "Whether the trait's effects are used directly as fitness effects.", + "type": "boolean", + }, + "index": { + "description": "The integer index for the trait; indices must be sequential starting from zero.", + "type": "integer", + }, + "individualOffsetMean": { + "description": "The mean of the trait's individual offset distribution (which might or might not be used).", + "type": "number", + }, + "individualOffsetSD": { + "description": "The standard deviation of the trait's individual offset distribution (which might or might not be used).", + "type": "number", + }, + "name": { + "description": "The string name for the trait.", + "type": "string", + }, + "type": { + "description": "The type of the trait; this must be 'additive', 'multiplicative', or 'logistic'.", + "enum": [ + "additive", + "multiplicative", + "logistic", + ], + "type": "string", + }, + }, + "required": ["index", "name", "type"], + "type": "object", + }, + "type": "array", }, }, "required": [ - "mutation_type", - "selection_coeff", - "subpopulation", - "slim_time", - "nucleotide", + "model_type", + "tick", + "file_version", + "spatial_dimensionality", + "spatial_periodicity", + "this_chromosome", + "separate_sexes", + "nucleotide_based", + "traits", ], "type": "object", - }, - "noLengthEncodingExhaustBuffer": True, - "type": "array", - } + } + }, + "required": ["SLiM"], + "type": "object", + }, + "struct": { + "codec": "struct", + "description": "SLiM schema for binary top-level metadata.", + "properties": { + "SLiM_mutation_list": { + "arrayLengthFormat": "Q", + "items": { + "additionalProperties": False, + "properties": { + "mutation_id": { + "binaryFormat": "q", + "description": "The SLiM mutation ID for this mutation.", + "index": 1, + "type": "integer", + }, + "mutation_type": { + "binaryFormat": "i", + "description": "The id of this mutation's mutationType.", + "index": 2, + "type": "integer", + }, + "nucleotide": { + "binaryFormat": "b", + "description": "The nucleotide for this mutation (0=A , 1=C , 2=G, 3=T, or -1 for none)", + "index": 5, + "type": "integer", + }, + "padding": { + "binaryFormat": "3x", + "description": "Padding bytes for alignment", + "index": 6, + "type": "null", + }, + "per_trait": { + "index": 7, + "items": { + "additionalProperties": False, + "properties": { + "dominance": { + "binaryFormat": "f", + "description": "The dominance coefficient for this trait.", + "index": 2, + "type": "number", + }, + "effect_size": { + "binaryFormat": "f", + "description": "The effect size for this trait.", + "index": 1, + "type": "number", + }, + "hemizygous_dominance": { + "binaryFormat": "f", + "description": "The hemizygous dominance coefficient for this trait.", + "index": 3, + "type": "number", + }, + }, + "required": [ + "dominance", + "effect_size", + "hemizygous_dominance", + ], + "type": "object", + }, + "length": 1, # NOTE this may need to be changed to match the number of traits! + "type": "array", + }, + "slim_time": { + "binaryFormat": "i", + "description": "The SLiM tick counter when this mutation occurred.", + "index": 4, + "type": "integer", + }, + "subpopulation": { + "binaryFormat": "i", + "description": "The ID of the subpopulation this mutation occurred in.", + "index": 3, + "type": "integer", + }, + }, + "required": [ + "mutation_id", + "mutation_type", + "slim_time", + "subpopulation", + "nucleotide", + "per_trait", + ], + "type": "object", + }, + "type": "array", + } + }, + "required": ["SLiM_mutation_list"], + "type": "object", }, - "required": ["mutation_list"], - "type": "object", }, + "edge": None, + "site": None, + "mutation": None, "node": { "$schema": "http://json-schema.org/schema#", "additionalProperties": False, @@ -269,12 +353,12 @@ def is_vacant_num_bytes(num_chromosomes): "slim_id": { "binaryFormat": "q", "description": "The 'pedigree ID' of the haplosomes associated with this node in SLiM.", - "index": 0, + "index": 1, "type": "integer", }, "is_vacant": { "description": "A vector of byte (uint8_t) values, with each bit representing whether the node represents a vacant position, either unused or a null haplosome (1), or a non-null haplosome (0), in the corresponding chromosome. This field encodes vacancy for all of the chromosomes in the model, not just the chromosome represented in this file (so that the node table is identical across all chromosomes for a multi-chromosome model). Each chromosome receives one bit here; there are two node table entries per individual, used for the two haplosomes of every chromosome, so only one bit is needed in each entry (making two bits total per chromosome, across the two node table entries). The least significant bit of the first byte is used first (for one haplosome of the first chromosome); the most significant bit of the last byte is used last. The number of bytes present in this field is indicated by this schema's 'binaryFormat' field, which is variable (!), and can also be deduced from the number of chromosomes in the model as given in the top-level 'chromosomes' metadata key, which should always be present if this metadata is present.", - "index": 1, + "index": 2, "type": "array", "length": 1, # MAY NEED TO BE CHANGED (in SLiM code is "%d") "items": {"type": "number", "binaryFormat": "B"}, @@ -285,8 +369,8 @@ def is_vacant_num_bytes(num_chromosomes): }, "individual": { "$schema": "http://json-schema.org/schema#", - "additionalProperties": False, "codec": "struct", + "type": "object", "description": "SLiM schema for individual metadata.", "examples": [ { @@ -295,64 +379,170 @@ def is_vacant_num_bytes(num_chromosomes): "pedigree_id": 123, "pedigree_p1": 12, "pedigree_p2": 23, + "per_trait": [{"offset": 1.0, "phenotype": 1.1}], "sex": 0, "subpopulation": 0, + "tag": 1, + "tagF": 5.5, + "tagL0_set": True, + "tagL0": True, + "tagL1_set": True, + "tagL1": False, + "tagL2_set": False, + "tagL2": False, + "tagL3_set": False, + "tagL3": False, + "tagL4_set": False, + "tagL4": False, } ], "flags": { "SLIM_INDIVIDUAL_METADATA_MIGRATED": { - "description": "Whether this individual was a migrant, either in the tick when the tree sequence " - "was written out (if the individual was alive then), or in the tick of the last time " - "they were Remembered (if not).", + "description": "Whether this individual was a migrant, either in the tick when the tree sequence was written out (if the individual was alive then), or in the tick of the last time they were Remembered (if not).", "value": 1, } }, "properties": { - "age": { - "binaryFormat": "i", - "description": "The age of this individual, either when the tree sequence was written out " - "(if the individual was alive then), or the last time they were Remembered (if not).", - "index": 4, - "type": "integer", - }, - "flags": { - "binaryFormat": "I", - "description": "Other information about the individual: see 'flags'.", - "index": 7, - "type": "integer", - }, "pedigree_id": { - "binaryFormat": "q", - "description": "The 'pedigree ID' of this individual in SLiM.", "index": 1, "type": "integer", + "binaryFormat": "q", + "description": "The 'pedigree ID' of this individual in SLiM.", }, "pedigree_p1": { - "binaryFormat": "q", - "description": "The 'pedigree ID' of this individual's first parent in SLiM.", "index": 2, "type": "integer", + "binaryFormat": "q", + "description": "The 'pedigree ID' of this individual's first parent in SLiM.", }, "pedigree_p2": { + "index": 3, + "type": "integer", "binaryFormat": "q", "description": "The 'pedigree ID' of this individual's second parent in SLiM.", - "index": 3, + }, + "age": { + "index": 4, "type": "integer", + "binaryFormat": "i", + "description": "The age of this individual, either when the tree sequence was written out (if the individual was alive then), or the last time they were Remembered (if not).", + }, + "subpopulation": { + "index": 5, + "type": "integer", + "binaryFormat": "i", + "description": "The ID of the subpopulation the individual was part of, either when the tree sequence was written out (if the individual was alive then), or the last time they were Remembered (if not).", }, "sex": { + "index": 6, + "type": "integer", "binaryFormat": "i", "description": "The sex of the individual (0 for female, 1 for male, -1 for hermaphrodite).", - "index": 6, + }, + "flags": { + "index": 7, "type": "integer", + "binaryFormat": "I", + "description": "Other information about the individual: see 'flags'.", }, - "subpopulation": { - "binaryFormat": "i", - "description": "The ID of the subpopulation the individual was part of, either when the tree sequence " - "was written out (if the individual was alive then), or the last time they were Remembered (if not).", - "index": 5, + "tag": { + "index": 8, "type": "integer", + "binaryFormat": "q", + "description": "The `tag` property of this individual; INT64_MIN if unset.", + }, + "tagF": { + "index": 9, + "type": "number", + "binaryFormat": "d", + "description": "The `tagF` property of this individual; -DBL_MAX if unset.", + }, + "tagL0_set": { + "index": 10, + "type": "boolean", + "binaryFormat": "?", + "description": "A flag indicating whether the `tagL0` property is set; if false, accessing `tagL0` is invalid.", + }, + "tagL0": { + "index": 11, + "type": "boolean", + "binaryFormat": "?", + "description": "The `tagL0` property of this individual; only valid if `tagL0_set` is true.", + }, + "tagL1_set": { + "index": 12, + "type": "boolean", + "binaryFormat": "?", + "description": "A flag indicating whether the `tagL1` property is set; if false, accessing `tagL1` is invalid.", + }, + "tagL1": { + "index": 13, + "type": "boolean", + "binaryFormat": "?", + "description": "The `tagL1` property of this individual; only valid if `tagL1_set` is true.", + }, + "tagL2_set": { + "index": 14, + "type": "boolean", + "binaryFormat": "?", + "description": "A flag indicating whether the `tagL2` property is set; if false, accessing `tagL2` is invalid.", + }, + "tagL2": { + "index": 15, + "type": "boolean", + "binaryFormat": "?", + "description": "The `tagL2` property of this individual; only valid if `tagL2_set` is true.", + }, + "tagL3_set": { + "index": 16, + "type": "boolean", + "binaryFormat": "?", + "description": "A flag indicating whether the `tagL3` property is set; if false, accessing `tagL3` is invalid.", + }, + "tagL3": { + "index": 17, + "type": "boolean", + "binaryFormat": "?", + "description": "The `tagL3` property of this individual; only valid if `tagL3_set` is true.", + }, + "tagL4_set": { + "index": 18, + "type": "boolean", + "binaryFormat": "?", + "description": "A flag indicating whether the `tagL4` property is set; if false, accessing `tagL4` is invalid.", + }, + "tagL4": { + "index": 19, + "type": "boolean", + "binaryFormat": "?", + "description": "The `tagL4` property of this individual; only valid if `tagL4_set` is true.", + }, + "per_trait": { + "index": 20, + "type": "array", + "length": 1, # MAY NEED TO BE CHANGED (in SLiM code is "%d") + "items": { + "additionalProperties": False, + "properties": { + "phenotype": { + "index": 1, + "type": "number", + "binaryFormat": "d", + "description": "The phenotype for this trait.", + }, + "offset": { + "index": 2, + "type": "number", + "binaryFormat": "d", + "description": "The individual offset for this trait.", + }, + }, + "required": ["offset", "phenotype"], + "type": "object", + }, }, }, + "additionalProperties": False, "required": [ "pedigree_id", "pedigree_p1", @@ -360,9 +550,21 @@ def is_vacant_num_bytes(num_chromosomes): "age", "subpopulation", "sex", + "tag", + "tagF", + "tagL0_set", + "tagL0", + "tagL1_set", + "tagL1", + "tagL2_set", + "tagL2", + "tagL3_set", + "tagL3", + "tagL4_set", + "tagL4", "flags", + "per_trait", ], - "type": "object", }, "population": { "$schema": "http://json-schema.org/schema#", @@ -464,6 +666,45 @@ def is_vacant_num_bytes(num_chromosomes): } +def slim_tree_sequence_metadata_schema(num_traits=1): + """ + The top-level metadata schema depends on the number of traits, and + {data}`.slim_metadata_schemas` + returns the schema for a single-trait simulation. This function + returns the correct schema for a simulation with arbitrary number of + traits. (The resulting schemas only differ in the + "length" of the "per_trait" property of + ``schema["properties"]["SLiM_mutation_list"]["items"]``). + + :param int num_traits: The number of traits in the model. + :return tskit.MetadataSchema: The metadata schema to be used + in the node table. + """ + schema = _raw_slim_metadata_schemas["tree_sequence"] + schema["struct"]["properties"]["SLiM_mutation_list"]["items"]["properties"][ + "per_trait" + ]["length"] = num_traits + return tskit.MetadataSchema(schema) + + +def slim_individual_metadata_schema(num_traits=1): + """ + The individual metadata schema depends on the number of traits, and + {data}`.slim_metadata_schemas` + returns the schema for a single-trait simulation. This function + returns the correct schema for a simulation with arbitrary number of + traits. (The resulting schemas only differ in the + "length" of the "per_trait" property.) + + :param int num_traits: The number of traits in the model. + :return tskit.MetadataSchema: The metadata schema to be used + in the node table. + """ + schema = _raw_slim_metadata_schemas["individual"] + schema["properties"]["per_trait"]["length"] = num_traits + return tskit.MetadataSchema(schema) + + def slim_node_metadata_schema(num_chromosomes=1): """ Unlike other schema, the node metadata schema depends on the number of @@ -510,12 +751,15 @@ def slim_node_metadata_schema(num_chromosomes=1): """ -def default_slim_metadata(name, num_chromosomes=1): +def default_slim_metadata(name, num_chromosomes=1, num_traits=1, **kwargs): """ Returns default metadata of type ``name``, where ``name`` is one of "tree_sequence", "edge", "site", "mutation", "mutation_list_entry", "node", "individual", or "population". + Additional kwargs are used to update the resulting metadata + (without validity checking). + :param str name: The type of metadata requested. :rtype dict: """ @@ -540,21 +784,26 @@ def default_slim_metadata(name, num_chromosomes=1): "type": "A", }, "chromosomes": [{"id": 1, "index": 0, "symbol": "A", "type": "A"}], - } + "traits": [{"index": 0, "name": "simT", "type": "multiplicative"}], + }, + "SLiM_mutation_list": [], } elif name == "edge": out = None elif name == "site": out = None elif name == "mutation": - out = {"mutation_list": []} + out = None elif name == "mutation_list_entry": out = { + "mutation_id": 0, "mutation_type": 0, - "selection_coeff": 0.0, "subpopulation": tskit.NULL, "slim_time": 0, "nucleotide": -1, + "per_trait": num_traits + * [{"effect_size": 0.0, "dominance": 0.5, "hemizygous_dominance": 1.0}], + "padding": None, } elif name == "node": out = { @@ -570,6 +819,19 @@ def default_slim_metadata(name, num_chromosomes=1): "flags": 0, "pedigree_p1": tskit.NULL, "pedigree_p2": tskit.NULL, + "tag": np.iinfo(np.int64).min, + "tagF": np.finfo(np.float64).min, + "tagL0_set": False, + "tagL0": False, + "tagL1_set": False, + "tagL1": False, + "tagL2_set": False, + "tagL2": False, + "tagL3_set": False, + "tagL3": False, + "tagL4_set": False, + "tagL4": False, + "per_trait": num_traits * [{"phenotype": np.nan, "offset": 1.0}], } elif name == "population": out = { @@ -594,6 +856,8 @@ def default_slim_metadata(name, num_chromosomes=1): "'edge', 'site', 'mutation', 'mutation_list_entry', 'node', " "'individual', or 'population'." ) + if out is not None: + out.update(kwargs) return out @@ -606,6 +870,7 @@ def set_tree_sequence_metadata( tables, model_type, tick, + *, cycle=None, spatial_dimensionality="", spatial_periodicity="", @@ -618,35 +883,58 @@ def set_tree_sequence_metadata( chromosomes=None, file_version=None, set_table_schemas=True, + traits=None, + SLiM_mutation_list=None, ): if file_version is None: file_version = slim_file_version - if isinstance(tables.metadata, bytes): - if len(tables.metadata) > 0: + if traits is None: + traits = [{"index": 0, "name": "simT", "type": "multiplicative"}] + num_traits = len(traits) + schema_dict = slim_tree_sequence_metadata_schema(num_traits).schema + old_schema_dict = tables.metadata_schema.schema + old_json_schema_dict = {} + old_struct_schema_dict = {} + tmd = tables.metadata + if isinstance(tmd, bytes): + if len(tmd) > 0: raise ValueError( "Tree sequence has top-level metadata but no schema: this is a problem " "since pyslim is trying to add to the metadata." ) - schema_dict = slim_metadata_schemas["tree_sequence"].schema metadata_dict = {} else: # we need to keep other keys in the metadata (and schema) if there are any - schema_dict = tables.metadata_schema.schema metadata_dict = tables.metadata + if old_schema_dict["codec"] == "json": + old_json_schema_dict = tables.metadata_schema.schema + else: + assert old_schema_dict["codec"] == "json+struct", ( + "You are using an unexpected codec; " + "please raise an issue on pyslim if " + "you need this functionality." + ) + old_json_schema_dict = tables.metadata_schema.schema["json"] + old_struct_schema_dict = tables.metadata_schema.schema["struct"] if cycle is None: cycle = tick - defaults = default_slim_metadata("tree_sequence") + if chromosomes is None: + num_chromosomes = 1 + else: + num_chromosomes = len(chromosomes) + defaults = default_slim_metadata( + "tree_sequence", num_chromosomes=num_chromosomes, num_traits=num_traits + ) if this_chromosome is None: this_chromosome = defaults["SLiM"]["this_chromosome"] if chromosomes is None: chromosomes = defaults["SLiM"]["chromosomes"] - assert schema_dict["codec"] == "json" - assert schema_dict["type"] == "object" - if "properties" not in schema_dict: - schema_dict["properties"] = {} - schema_dict["properties"]["SLiM"] = slim_metadata_schemas["tree_sequence"].schema[ - "properties" - ]["SLiM"] + if "properties" in old_json_schema_dict: + schema_dict["json"]["properties"].update(old_json_schema_dict["properties"]) + if "properties" in old_struct_schema_dict: + schema_dict["struct"]["properties"].update(old_struct_schema_dict["properties"]) + if SLiM_mutation_list is None: + SLiM_mutation_list = [] tables.metadata_schema = tskit.MetadataSchema(schema_dict) metadata_dict["SLiM"] = { "model_type": model_type, @@ -662,16 +950,19 @@ def set_tree_sequence_metadata( "description": description, "this_chromosome": this_chromosome, "chromosomes": chromosomes, + "traits": traits, } + metadata_dict["SLiM_mutation_list"] = SLiM_mutation_list tables.metadata = metadata_dict + return metadata_dict -def set_metadata_schemas(tables, num_chromosomes=1): +def set_metadata_schemas(tables, num_chromosomes=1, num_traits=1): tables.edges.metadata_schema = slim_metadata_schemas["edge"] tables.sites.metadata_schema = slim_metadata_schemas["site"] tables.mutations.metadata_schema = slim_metadata_schemas["mutation"] tables.nodes.metadata_schema = slim_node_metadata_schema(num_chromosomes) - tables.individuals.metadata_schema = slim_metadata_schemas["individual"] + tables.individuals.metadata_schema = slim_individual_metadata_schema(num_traits) tables.populations.metadata_schema = slim_metadata_schemas["population"] @@ -683,6 +974,161 @@ def _old_metadata_schema(name, file_version): # Returns a metadata schema *if the format has changed*, # and None otherwise. ms = None + if name == "tree_sequence" and file_version == "0.9": + pre_1_0_tree_sequence = { + "$schema": "http://json-schema.org/schema#", + "codec": "json", + "examples": [ + { + "SLiM": { + "file_version": "0.9", + "name": "fox", + "description": "foxes on Catalina island", + "cycle": 123, + "tick": 123, + "model_type": "WF", + "this_chromosome": { + "id": 1, + "index": 0, + "symbol": "1", + "name": "autosome_1", + "type": "A", + }, + "chromosomes": [ + {"id": 1, "symbol": "1", "name": "autosome_1", "type": "A"}, + {"id": 35, "symbol": "MT", "name": "mtDNA", "type": "HF"}, + ], + "nucleotide_based": False, + "separate_sexes": True, + "spatial_dimensionality": "xy", + "spatial_periodicity": "x", + } + } + ], + "properties": { + "SLiM": { + "description": "Top-level metadata for a SLiM tree sequence, file format version 0.9", + "properties": { + "file_version": { + "description": "The SLiM 'file format version' of this tree sequence.", + "type": "string", + }, + "name": { + "description": "The SLiM species name represented by this tree sequence.", + "type": "string", + }, + "description": { + "description": "A user-configurable description of the species represented by this tree sequence.", + "type": "string", + }, + "cycle": { + "description": "The 'SLiM cycle' counter when this tree sequence was recorded.", + "type": "integer", + }, + "tick": { + "description": "The 'SLiM tick' counter when this tree sequence was recorded.", + "type": "integer", + }, + "model_type": { + "description": "The model type used for the last part of this simulation (WF or nonWF).", + "enum": ["WF", "nonWF"], + "type": "string", + }, + "this_chromosome": { + "description": "The chromosome represented by the tree sequence in this file.", + "properties": { + "id": { + "description": "An integer identifier for the chromosome, unique within this set of tree sequences; often the chromosome number in the organism being represented, such as 1.", + "type": "integer", + }, + "index": { + "description": "The (zero-based) index of this chromosome in the chromosomes metadata array (if present), which should match the information given here.", + "type": "integer", + }, + "symbol": { + "description": 'A short string symbol for the chromosome, unique within this set of tree sequences, such as "1" or "MT".', + "type": "string", + }, + "name": { + "description": "A user-specified name for the chromosome, such as an accession identifier.", + "type": "string", + }, + "type": { + "description": "The type of chromosome, as specified by SLiM.", + "type": "string", + }, + }, + "required": ["id", "index", "symbol", "type"], + "type": "object", + }, + "chromosomes": { + "description": "The chromosomes represented by the collection of tree sequences, of which this tree sequence is one member.", + "items": { + "properties": { + "id": { + "description": "An integer identifier for the chromosome, unique within this set of tree sequences; often the chromosome number in the organism being represented, such as 1.", + "type": "integer", + }, + "symbol": { + "description": 'A short string symbol for the chromosome, unique within this set of tree sequences, such as "1" or "MT".', + "type": "string", + }, + "name": { + "description": "A user-specified name for the chromosome, such as an accession identifier.", + "type": "string", + }, + "type": { + "description": "The type of chromosome, as specified by SLiM.", + "type": "string", + }, + }, + "required": ["id", "symbol", "type"], + "type": "object", + }, + "type": "array", + }, + "nucleotide_based": { + "description": "Whether the simulation was nucleotide-based.", + "type": "boolean", + }, + "separate_sexes": { + "description": "Whether the simulation had separate sexes.", + "type": "boolean", + }, + "spatial_dimensionality": { + "description": "The spatial dimensionality of the simulation.", + "enum": ["", "x", "xy", "xyz"], + "type": "string", + }, + "spatial_periodicity": { + "description": "The spatial periodicity of the simulation.", + "enum": ["", "x", "y", "z", "xy", "xz", "yz", "xyz"], + "type": "string", + }, + "stage": { + "description": "The stage of the SLiM life cycle when this tree sequence was recorded.", + "type": "string", + }, + }, + "required": [ + "model_type", + "tick", + "file_version", + "spatial_dimensionality", + "spatial_periodicity", + "this_chromosome", + "separate_sexes", + "nucleotide_based", + ], + "type": "object", + } + }, + "required": ["SLiM"], + "type": "object", + } + + ms = pre_1_0_tree_sequence + if name == "tree_sequence" and file_version == "0.8": pre_0_9_tree_sequence = { "$schema": "http://json-schema.org/schema#", @@ -963,6 +1409,90 @@ def _old_metadata_schema(name, file_version): } ms = pre_0_7_population + if name == "individual" and file_version in ["0.7", "0.8", "0.9"]: + pre_1_0_individual = { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": False, + "codec": "struct", + "description": "SLiM schema for individual metadata.", + "examples": [ + { + "age": -1, + "flags": 0, + "pedigree_id": 123, + "pedigree_p1": 12, + "pedigree_p2": 23, + "sex": 0, + "subpopulation": 0, + } + ], + "flags": { + "SLIM_INDIVIDUAL_METADATA_MIGRATED": { + "description": "Whether this individual was a migrant, either in the tick when the tree sequence " + "was written out (if the individual was alive then), or in the tick of the last time " + "they were Remembered (if not).", + "value": 1, + } + }, + "properties": { + "age": { + "binaryFormat": "i", + "description": "The age of this individual, either when the tree sequence was written out " + "(if the individual was alive then), or the last time they were Remembered (if not).", + "index": 4, + "type": "integer", + }, + "flags": { + "binaryFormat": "I", + "description": "Other information about the individual: see 'flags'.", + "index": 7, + "type": "integer", + }, + "pedigree_id": { + "binaryFormat": "q", + "description": "The 'pedigree ID' of this individual in SLiM.", + "index": 1, + "type": "integer", + }, + "pedigree_p1": { + "binaryFormat": "q", + "description": "The 'pedigree ID' of this individual's first parent in SLiM.", + "index": 2, + "type": "integer", + }, + "pedigree_p2": { + "binaryFormat": "q", + "description": "The 'pedigree ID' of this individual's second parent in SLiM.", + "index": 3, + "type": "integer", + }, + "sex": { + "binaryFormat": "i", + "description": "The sex of the individual (0 for female, 1 for male, -1 for hermaphrodite).", + "index": 6, + "type": "integer", + }, + "subpopulation": { + "binaryFormat": "i", + "description": "The ID of the subpopulation the individual was part of, either when the tree sequence " + "was written out (if the individual was alive then), or the last time they were Remembered (if not).", + "index": 5, + "type": "integer", + }, + }, + "required": [ + "pedigree_id", + "pedigree_p1", + "pedigree_p2", + "age", + "subpopulation", + "sex", + "flags", + ], + "type": "object", + } + ms = pre_1_0_individual + if name == "individual" and file_version in [ "0.1", "0.2", @@ -1019,6 +1549,87 @@ def _old_metadata_schema(name, file_version): } ms = pre_0_7_individual + if name == "mutation" and file_version in [ + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "0.8", + "0.9", + ]: + mutation_pre_1_0 = { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": False, + "codec": "struct", + "description": "SLiM schema for mutation metadata.", + "examples": [ + { + "mutation_list": [ + { + "mutation_type": 1, + "nucleotide": 3, + "selection_coeff": -0.2, + "slim_time": 243, + "subpopulation": 0, + } + ] + } + ], + "properties": { + "mutation_list": { + "items": { + "additionalProperties": False, + "properties": { + "mutation_type": { + "binaryFormat": "i", + "description": "The index of this mutation's mutationType.", + "index": 1, + "type": "integer", + }, + "nucleotide": { + "binaryFormat": "b", + "description": "The nucleotide for this mutation (0=A , 1=C , 2=G, 3=T, or -1 for none)", + "index": 5, + "type": "integer", + }, + "selection_coeff": { + "binaryFormat": "f", + "description": "This mutation's selection coefficient.", + "index": 2, + "type": "number", + }, + "slim_time": { + "binaryFormat": "i", + "description": "The SLiM tick counter when this mutation occurred.", + "index": 4, + "type": "integer", + }, + "subpopulation": { + "binaryFormat": "i", + "description": "The ID of the subpopulation this mutation occurred in.", + "index": 3, + "type": "integer", + }, + }, + "required": [ + "mutation_type", + "selection_coeff", + "subpopulation", + "slim_time", + "nucleotide", + ], + "type": "object", + }, + "noLengthEncodingExhaustBuffer": True, + "type": "array", + } + }, + "required": ["mutation_list"], + "type": "object", + } + ms = mutation_pre_1_0 + if name == "mutation" and file_version in ["0.1", "0.2"]: mutation_pre_0_3 = { "$schema": "http://json-schema.org/schema#", @@ -1072,6 +1683,33 @@ def _old_metadata_schema(name, file_version): } ms = mutation_pre_0_3 + if name == "node" and file_version == "0.9": + node_0_9 = { + "$schema": "http://json-schema.org/schema#", + "additionalProperties": False, + "codec": "struct", + "description": "SLiM schema for node metadata.", + "examples": [{"slim_id": 123, "is_vacant": 0}], + "properties": { + "slim_id": { + "binaryFormat": "q", + "description": "The 'pedigree ID' of the haplosomes associated with this node in SLiM.", + "index": 0, + "type": "integer", + }, + "is_vacant": { + "description": "A vector of byte (uint8_t) values, with each bit representing whether the node represents a vacant position, either unused or a null haplosome (1), or a non-null haplosome (0), in the corresponding chromosome. This field encodes vacancy for all of the chromosomes in the model, not just the chromosome represented in this file (so that the node table is identical across all chromosomes for a multi-chromosome model). Each chromosome receives one bit here; there are two node table entries per individual, used for the two haplosomes of every chromosome, so only one bit is needed in each entry (making two bits total per chromosome, across the two node table entries). The least significant bit of the first byte is used first (for one haplosome of the first chromosome); the most significant bit of the last byte is used last. The number of bytes present in this field is indicated by this schema's 'binaryFormat' field, which is variable (!), and can also be deduced from the number of chromosomes in the model as given in the top-level 'chromosomes' metadata key, which should always be present if this metadata is present.", + "index": 1, + "type": "array", + "length": 1, # MAY NEED TO BE CHANGED (in SLiM code is "%d") + "items": {"type": "number", "binaryFormat": "B"}, + }, + }, + "required": ["slim_id", "is_vacant"], + "type": ["object", "null"], + } + ms = node_0_9 + if name == "node" and file_version in [ "0.1", "0.2", @@ -1119,24 +1757,66 @@ def _old_metadata_schema(name, file_version): return ms +def _make_mutation_list(mutations, file_version): + # Prior to 1.0, mutation metadata was a list of entries like: + # {'mutation_type': 1, 'selection_coeff': -0.1, + # 'subpopulation': 1, 'slim_time': 5, 'nucleotide': -1} + # with mutation id stored in the derived state. + # + # As of 1.0, this lives in the top-level ts.metadata['SLiM_mutation_list'], + # with entries like + # {'mutation_id': 87, 'mutation_type': 1, 'subpopulation': 1, 'slim_time': 5, + # 'nucleotide': -1, 'padding': None, + # 'per_trait': [{'effect_size': -0.1, 'dominance': 0.5, + # 'hemizygous_dominance': 1.0}]} + if mutations.metadata_schema == tskit.MetadataSchema(None): + mutations.metadata_schema = _old_metadata_schema("mutation", file_version) + mutation_list = [] + for mut in mutations: + for sid, md in zip(mut.derived_state.split(","), mut.metadata["mutation_list"]): + if "nucleotide" not in md: + md["nucleotide"] = -1 + md["mutation_id"] = int(sid) + md["per_trait"] = [ + { + "effect_size": md["selection_coeff"], + "dominance": 0.5, # WE DON'T KNOW THIS + "hemizygous_dominance": 1.0, # OR THIS + }, + ] + del md["selection_coeff"] + md["padding"] = None + mutation_list.append(md) + return mutation_list + + def is_current_version(ts, _warn=False): """ - Tests whether the tree sequence or table collection provided is the current - SLiM file format or not. If not, use `pyslim.update( )` to bring it up to - date. + Tests whether the metadata provided is the current SLiM file format or not. + If not, use `pyslim.update( )` to bring it up to date. - :param TreeSequence ts: The tree sequence or table collection. + This method may be provided either a TreeSequence or TableCollection directly, + or the metadata from one of these. The latter is useful because + accessing top-level metadata can be a costly operation. + + :param dict ts: Either the top-level metadata of a tree sequence, + or a TreeSequence or TableCollection that carries this metadata. :return bool: Whether the tree sequence is the current version. """ + if ( + isinstance(ts, tskit.TreeSequence) + or isinstance(ts, tskit.TableCollection) + or isinstance(ts, tskit.ImmutableTableCollection) + ): + ts = ts.metadata out = ( - isinstance(ts.metadata, dict) - and ("SLiM" in ts.metadata) - and (ts.metadata["SLiM"]["file_version"] == slim_file_version) + isinstance(ts, dict) + and ("SLiM" in ts) + and (ts["SLiM"]["file_version"] == slim_file_version) ) if _warn and not out: warnings.warn( - "This tree sequence is not the current SLiM format, " - "so some operations may not work. " + "This tree sequence is not the current SLiM format. " "Use `pyslim.update( )` to update the tree sequence." ) return out @@ -1161,10 +1841,13 @@ def update_tables(tables): """ # First we ensure we can find the file format version number # in top-level metadata. Then we proceed to fix up the tables as necessary. - if not (isinstance(tables.metadata, dict) and "SLiM" in tables.metadata): + md = tables.metadata + if not (isinstance(md, dict) and "SLiM" in md): # Old versions kept information in provenance, not top-level metadata. # Note this uses defaults on keys not present in provenance, # which prior to 0.5 was everything but generation and model_type. + # Recovering from provenance has also been useful for operations + # that discard metadata (eg as msprime did prior to 0.7.5). values = default_slim_metadata("tree_sequence")["SLiM"] prov = None file_version = "unknown" @@ -1192,25 +1875,38 @@ def update_tables(tables): values[k] = record["slim"][k] except: raise ValueError("Failed to obtain metadata from provenance.") - set_tree_sequence_metadata(tables, **values) + md = set_tree_sequence_metadata(tables, **values) - file_version = tables.metadata["SLiM"]["file_version"] + file_version = md["SLiM"]["file_version"] if file_version != slim_file_version: warnings.warn( - "This is a version {} SLiM tree sequence.".format(file_version) - + " If you write this out to a file, " - + "it will be converted to version {}.".format(slim_file_version) + f"This is a version {file_version} SLiM tree sequence. " + "If you write this out to a file, " + f"it will be converted to version {slim_file_version}." ) - # the only tables to have metadata schema changed thus far - # are nodes, populations, individuals, mutations, and top-level: old_schema = _old_metadata_schema("tree_sequence", file_version) if old_schema is not None: - md = tables.metadata - new_schema = slim_metadata_schemas["tree_sequence"] - new_properties = new_schema.asdict()["properties"]["SLiM"]["required"] + assert ( + "struct" not in old_schema.schema + or "SLiM_mutation_list" not in old_schema.schema["struct"]["properties"] + ) + # we should get the number of traits from the metadata, + # but for old file versions, this won't be present + assert ( + "json" not in old_schema.schema + or "traits" not in old_schema.schema["json"]["properties"] + ) + md["SLiM_mutation_list"] = _make_mutation_list( + tables.mutations, file_version + ) + num_traits = 1 + new_schema = slim_tree_sequence_metadata_schema(num_traits=num_traits) + new_properties = new_schema.asdict()["json"]["properties"]["SLiM"][ + "required" + ] tables.metadata_schema = new_schema - defaults = default_slim_metadata("tree_sequence") + defaults = default_slim_metadata("tree_sequence", num_traits=num_traits) for k in new_properties: if k not in md["SLiM"]: if k == "tick": @@ -1226,63 +1922,71 @@ def update_tables(tables): tables.nodes.clear() if nodes.metadata_schema == tskit.MetadataSchema(None): nodes.metadata_schema = old_schema - assert "chromosomes" not in tables.metadata - # if chromosomes was in metadata - # we should use its length to get num_chroms, - # but old file versions did not have this. - num_chroms = 1 + if "chromosomes" not in md["SLiM"]: + num_chroms = 1 + else: + num_chroms = len(md["SLiM"]["chromosomes"]) new_schema = slim_node_metadata_schema(num_chroms) tables.nodes.metadata_schema = new_schema - not_vacant = [0] # single chromosome - yes_vacant = [1] - gt = None - for n in nodes: - md = n.metadata - if len(md) > 0: - md["is_vacant"] = yes_vacant if md["is_null"] else not_vacant - if not md["is_null"]: - if gt is None: - gt = md["genome_type"] - else: - assert md["genome_type"] == gt, ( - "Inconsistent tables: " - f"mismatching genome types {gt} and " - f"{md['genome_type']} in node metadata." - ) - del md["is_null"] - del md["genome_type"] - tables.nodes.append(n.replace(metadata=md)) - # flags for node genome type pre-0.9: - # confusingly and sub-optimally, these were redundant: - # all non-null nodes in the same sim would have the same genome type - assert gt is not None - GENOME_TYPE_AUTOSOME = 0 - GENOME_TYPE_X = 1 - GENOME_TYPE_Y = 2 - top_md = tables.metadata - i = top_md["SLiM"]["this_chromosome"]["index"] - # 'chromosomes' is not required so won't be inserted, - # so we won't update it also - assert "chromosomes" not in top_md["SLiM"]["this_chromosome"], ( - "" - "This is an unexpected result: if you hit this, " - "please file a bug at " - "https://github.com/tskit-dev/pyslim." - ) - if gt == GENOME_TYPE_X: - top_md["SLiM"]["this_chromosome"]["type"] = "X" - top_md["SLiM"]["this_chromosome"]["symbol"] = "X" - # if "chromosomes" in tables.metadata['SLiM']: - # top_md['SLiM']['chromosomes'][i]['type'] = "X" - # top_md['SLiM']['chromosomes'][i]['symbol'] = "X" - elif gt == GENOME_TYPE_Y: - top_md["SLiM"]["this_chromosome"]["type"] = "-Y" - top_md["SLiM"]["this_chromosome"]["symbol"] = "Y" - # top_md['SLiM']['chromosomes'][i]['type'] = "Y" - # top_md['SLiM']['chromosomes'][i]['symbol'] = "Y" + new_node_schema = new_schema + if file_version in ("0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7", "0.8"): + # 0.8->0.9 switched from is_null to is_vacant, + # and moved chromosome type from node metadata to top-level + not_vacant = [0] # single chromosome + yes_vacant = [1] + gt = None + for n in nodes: + md = n.metadata + if len(md) > 0: + md["is_vacant"] = yes_vacant if md["is_null"] else not_vacant + if not md["is_null"]: + if gt is None: + gt = md["genome_type"] + else: + assert md["genome_type"] == gt, ( + "Inconsistent tables: " + f"mismatching genome types {gt} and " + f"{md['genome_type']} in node metadata." + ) + del md["is_null"] + del md["genome_type"] + tables.nodes.append(n.replace(metadata=md)) + # flags for node genome type pre-0.9: + # confusingly and sub-optimally, these were redundant: + # all non-null nodes in the same sim would have the same genome type + GENOME_TYPE_AUTOSOME = 0 + GENOME_TYPE_X = 1 + GENOME_TYPE_Y = 2 + top_md = tables.metadata + i = top_md["SLiM"]["this_chromosome"]["index"] + # 'chromosomes' is not required so won't be inserted, + # so we won't update it also + assert "chromosomes" not in top_md["SLiM"]["this_chromosome"], ( + "" + "This is an unexpected result: if you hit this, " + "please file a bug at " + "https://github.com/tskit-dev/pyslim." + ) + if gt == GENOME_TYPE_X: + top_md["SLiM"]["this_chromosome"]["type"] = "X" + top_md["SLiM"]["this_chromosome"]["symbol"] = "X" + # if "chromosomes" in tables.metadata['SLiM']: + # top_md['SLiM']['chromosomes'][i]['type'] = "X" + # top_md['SLiM']['chromosomes'][i]['symbol'] = "X" + elif gt == GENOME_TYPE_Y: + top_md["SLiM"]["this_chromosome"]["type"] = "-Y" + top_md["SLiM"]["this_chromosome"]["symbol"] = "Y" + # top_md['SLiM']['chromosomes'][i]['type'] = "Y" + # top_md['SLiM']['chromosomes'][i]['symbol'] = "Y" + else: + assert gt == GENOME_TYPE_AUTOSOME + tables.metadata = top_md else: - assert gt == GENOME_TYPE_AUTOSOME - tables.metadata = top_md + assert file_version == "0.9" + # just needs recoding (and doesn't really need that, we just + # changed the index of some entries in the schema) + for n in nodes: + tables.nodes.append(n) old_schema = _old_metadata_schema("population", file_version) if old_schema is not None: @@ -1302,15 +2006,33 @@ def update_tables(tables): tables.individuals.clear() if inds.metadata_schema == tskit.MetadataSchema(None): inds.metadata_schema = old_schema - new_schema = slim_metadata_schemas["individual"] + num_traits = len(tables.metadata["SLiM"]["traits"]) + new_schema = slim_individual_metadata_schema(num_traits=num_traits) tables.individuals.metadata_schema = new_schema - defaults = default_slim_metadata("individual") - d = {} - for k in ["pedigree_p1", "pedigree_p2"]: - d[k] = defaults[k] + # new(er) additions are pedigree_pX in 0.7 + # and per_trait in 1.0 + defaults = default_slim_metadata("individual", num_traits=num_traits) for ind in inds: md = ind.metadata - md.update(d) + for k in [ + "pedigree_p1", + "pedigree_p2", + "tag", + "tagF", + "tagL0", + "tagL0_set", + "tagL1", + "tagL1_set", + "tagL2", + "tagL2_set", + "tagL3", + "tagL3_set", + "tagL4", + "tagL4_set", + ]: + md.setdefault(k, defaults[k]) + if "per_trait" not in md: + md["per_trait"] = copy.deepcopy(defaults["per_trait"]) tables.individuals.append(ind.replace(metadata=md)) old_schema = _old_metadata_schema("mutation", file_version) @@ -1321,10 +2043,8 @@ def update_tables(tables): muts.metadata_schema = old_schema tables.mutations.metadata_schema = slim_metadata_schemas["mutation"] for mut in muts: - md = mut.metadata - for ml in md["mutation_list"]: - ml["nucleotide"] = -1 - tables.mutations.append(mut.replace(metadata=md)) + # drop metadata: it should have been copied into top-level above + tables.mutations.append(mut.replace(metadata=None)) if file_version == "0.1": # shift times @@ -1364,7 +2084,6 @@ def update_tables(tables): tskit.validate_provenance(new_record) tables.provenances.add_row(json.dumps(new_record)) - set_metadata_schemas(tables) md = tables.metadata md["SLiM"]["file_version"] = slim_file_version tables.metadata = md diff --git a/pyslim/slim_tree_sequence.py b/pyslim/slim_tree_sequence.py index 95de0333..f6bc9c0a 100644 --- a/pyslim/slim_tree_sequence.py +++ b/pyslim/slim_tree_sequence.py @@ -10,6 +10,45 @@ def load(*args, **kwargs): raise RuntimeError("This method has been removed: use tskit.load( ) instead.") +def mutation_metadata(ts, check=True, _ts_metadata=None): + """ + Returns a dictionary whose keys are the numeric SLiM IDs of mutations, + and whose values are metadata entries for those mutations. + *Note:* this is indexed by integers, not strings, so if you obtain SLiM IDs + from something like ``mut.derived_state.split(",")``, you must convert + the result to integers before looking up metadata! + + This is a simple extraction function that places the list of metadata entries + stored in ``ts.metadata["SLiM_mutation_list"]`` in a dictionary + indexed by SLiM ID. It is recommended to extract this information once + and use the result in script, because calling this function many times + (or, even just referring to ``ts.metadata`` many times) + can slow down scripts considerably. + + :param tskit.TreeSequence ts: The tree sequence. + + :returns dict: A dictionary of metadata entries, indexed by SLiM ID + and in sorted order by SLiM ID. + """ + if _ts_metadata is None: + _ts_metadata = ts.metadata + # Note that dictionaries preserve insertion order + ml = _ts_metadata["SLiM_mutation_list"] + ml.sort(key=lambda x: x["mutation_id"]) + out = {mut["mutation_id"]: mut for mut in ml} + if check: + ids = {int(j) for x in ts.mutations_derived_state for j in x.split(",")} + for k in ids: + if k not in out: + raise ValueError( + "Top-level mutation metadata is missing " + f"information for mutation ID {k}: " + "do you need to run " + "pyslim.add_mutation_metadata(ts)?" + ) + return out + + def mutation_at(ts, node, position, time=None): """ Finds the mutation present in the genome of ``node`` at ``position``, @@ -59,7 +98,7 @@ def mutation_at(ts, node, position, time=None): return out -def nucleotide_at(ts, node, position, time=None): +def nucleotide_at(ts, node, position, time=None, mut_metadata=None): """ Finds the nucleotide present in the genome of ``node`` at ``position``. Warning: if ``node`` is not actually in the tree sequence (e.g., not @@ -69,20 +108,34 @@ def nucleotide_at(ts, node, position, time=None): at ``position`` inherited by ``node`` that occurred at or before ``time`` ago. + This method uses a dictionary of mutation metadata, computed by + :meth:`mut_metadata`. This step can be expensive if there are + many mutations, so this can be pre-computed and passed in as + ``mutations``. If not provided, it will be computed. + :param int node: The index of a node in the tree sequence. :param float position: A position along the genome. :param int time: The time ago that we want the nucleotide, or None, in which case the ``time`` of ``node`` is used. + :param dict mut_metadata: If provided, a dictionary mapping + mutation ID to metadata, as returned by ``pyslim.mutation_metadata(ts)``. :returns: Index of the nucleotide in ``NUCLEOTIDES`` (0=A, 1=C, 2=G, 3=T). """ if not ts.has_reference_sequence(): raise ValueError("This tree sequence has no reference sequence.") + if mut_metadata is None: + mut_metadata = mutation_metadata(ts) mut_id = mutation_at(ts, node, position, time) if mut_id == tskit.NULL: out = NUCLEOTIDES.index(ts.reference_sequence.data[int(position)]) else: mut = ts.mutation(mut_id) - k = np.argmax([u["slim_time"] for u in mut.metadata["mutation_list"]]) - out = mut.metadata["mutation_list"][k]["nucleotide"] + _, k = max( + [ + (mut_metadata[int(j)]["slim_time"], int(j)) + for j in mut.derived_state.split(",") + ] + ) + out = mut_metadata[k]["nucleotide"] return out diff --git a/tests/__init__.py b/tests/__init__.py index 0996d1a3..376b6ce8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,6 +20,25 @@ class PyslimTestCase: Base class for test cases in pyslim. """ + def assert_indiv_metadata_equal(self, a, b): + # individual metadata can have nan's in it, thus this function + assert isinstance(a, dict) + assert type(a) == type(b) + assert a.keys() == b.keys() + for k in a: + if k != "per_trait": + assert a[k] == b[k] + apt = a["per_trait"] + bpt = b["per_trait"] + assert len(apt) == len(bpt) + for x, y in zip(apt, bpt): + assert x.keys() == y.keys() + for k in x: + if k == "phenotype": + assert (np.isnan(x[k]) and np.isnan(y[k])) or (x[k] == y[k]) + else: + assert x[k] == y[k] + def verify_haplotype_equality(self, ts, slim_ts): assert ts.num_sites == slim_ts.num_sites for j, v1, v2 in zip(range(ts.num_sites), ts.variants(), slim_ts.variants()): @@ -53,9 +72,8 @@ def assertMetadataEqual(self, t1, t2): assert t1.metadata_schema == t2.metadata_schema assert t1.metadata == t2.metadata # and now check the underlying bytes - # TODO: use the public interface if https://github.com/tskit-dev/tskit/issues/832 happens - md1 = t1._ll_tables.metadata - md2 = t2._ll_tables.metadata + md1 = t1.metadata_bytes + md2 = t2.metadata_bytes assert md1 == md2 def verify_trees_equal(self, ts1, ts2): @@ -76,10 +94,6 @@ def verify_trees_equal(self, ts1, ts2): if n.metadata is not None: map2[n.metadata["slim_id"]] = j assert set(map1.keys()) == set(map2.keys()) - print(ts1) - print(map1) - print(ts2) - print(map2) sids = list(map1.keys()) for sid in sids: n1 = ts1.node(map1[sid]) @@ -88,22 +102,13 @@ def verify_trees_equal(self, ts1, ts2): assert n1.metadata == n2.metadata i1 = ts1.individual(n1.individual) i2 = ts2.individual(n2.individual) - if i1.metadata != i2.metadata: - print("i1: ", i1.metadata) - print("i2: ", i2.metadata) - assert i1.metadata == i2.metadata + self.assert_indiv_metadata_equal(i1.metadata, i2.metadata) for _ in range(10): pos = random.uniform(0, ts1.sequence_length) t1 = ts1.at(pos) t2 = ts2.at(pos) for _ in range(10): a, b = random.choices(sids, k=2) - print(a, b, map1[a], map1[b], map2[a], map2[b]) - print(t1) - print("a", t1.time(map1[a])) - print("b", t1.time(map1[b])) - print("1", t1.tmrca(map1[a], map1[b])) - print("2", t2.tmrca(map2[a], map2[b])) assert t1.tmrca(map1[a], map1[b]) == t2.tmrca(map2[a], map2[b]) def assertTableCollectionsEqual( diff --git a/tests/conftest.py b/tests/conftest.py index 0a3fa875..6895b5ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import pytest import tskit from filelock import FileLock +from frozendict import frozendict from .recipe_specs import recipe_specs @@ -73,7 +74,7 @@ def load_ts(self, path): c, e = os.path.splitext(cfile) if e == ".trees": out[c] = tskit.load(os.path.join(path, cfile)) - return out + return frozendict(out) def __init__(self, out_dir): # Note: the 'key' below cannot match a 'key' in recipe_specs diff --git a/tests/recipe_specs.py b/tests/recipe_specs.py index 283daff1..d31c2f69 100644 --- a/tests/recipe_specs.py +++ b/tests/recipe_specs.py @@ -12,11 +12,16 @@ # retained: has retained individuals # multipop: has more than one population # multichrom: has more than one chromosome +# traits: has more than just the usual trait +# no_simplify: does not run simplify when writing out +# old_mutations: uses addMutation to add back in some previously lost mutations +# record_mutations: whether mutations and substitutions and reference sequence +# are in top-level metadata # long: kinda big # (chromosome type) # All files are of the form `tests/test_recipes/{key}` recipe_specs = { - "recipe_nonWF.slim": {"nonWF": True, "pedigree": True}, + "recipe_nonWF.slim": {"nonWF": True, "pedigree": True, "record_mutations": True}, "recipe_nonWF_X.slim": {"nonWF": True, "pedigree": True, "X": True}, "recipe_nonWF_Y.slim": {"nonWF": True, "pedigree": True, "Y": True}, "recipe_nonWF_H.slim": {"nonWF": True, "pedigree": True, "H": True}, @@ -78,14 +83,23 @@ }, "recipe_long_nonWF.slim": {"nonWF": True, "long": True}, "recipe_old_nonWF.slim": {"nonWF": True, "remembered_first": True}, - "recipe_WF.slim": {"WF": True, "pedigree": True}, + "recipe_WF.slim": {"WF": True, "pedigree": True, "record_mutations": True}, + "recipe_no_simplify.slim": {"WF": True, "no_simplify": True}, "recipe_long_WF.slim": {"WF": True, "long": True}, "recipe_WF_migration.slim": {"WF": True, "pedigree": True, "multipop": True}, - "recipe_nucleotides_WF.slim": {"WF": True, "pedigree": True, "nucleotides": True}, + "recipe_nucleotides_WF.slim": { + "WF": True, + "pedigree": True, + "nucleotides": True, + "record_mutations": True, + "refseq": True, + }, "recipe_nucleotides_nonWF.slim": { "nonWF": True, "pedigree": True, "nucleotides": True, + "record_mutations": True, + "refseq": True, }, "recipe_nucleotides_plus_others.slim": { "WF": True, @@ -177,7 +191,10 @@ "adds_mutations": True, "nucleotides": True, "non-nucleotides": True, + "record_mutations": True, + "refseq": True, }, + "recipe_adds_old_muts.slim": {"WF": True, "old_mutations": True}, "recipe_many_chromosomes.slim": { "nonWF": True, "pedigree": True, @@ -189,6 +206,15 @@ "multichrom": True, "H-": True, }, + "recipe_with_traits.slim": { + "WF": True, + "traits": True, + "multichrom": True, + "begun_late": True, + "X": True, + "Y": True, + "H": True, + }, } for x in ("first", "early", "late"): diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 6336bc44..ead0a046 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -15,17 +15,33 @@ import pyslim import tests -from .recipe_specs import restarted_recipe_eq +from .recipe_specs import recipe_eq, restarted_recipe_eq def mutcontext(ts): - if ts.num_mutations > 0: + md = ts.metadata + if ( + isinstance(md, dict) + and "SLiM_mutation_list" in md + and len(md["SLiM_mutation_list"]) > 0 + ): handler = pytest.warns(Warning, match="already has.*metadata") else: handler = contextlib.nullcontext() return handler +def canonicalise_tables(tables): + md = tables.metadata + num_muts = len(md["SLiM_mutation_list"]) + md["SLiM_mutation_list"] = list( + pyslim.mutation_metadata(tables, check=False).values() + ) + assert num_muts == len(md["SLiM_mutation_list"]) + tables.metadata = md + tables.canonicalise() + + def verify_slim_restart_equality(in_ts_dict, out_ts_dict, check_prov=True): """ Check for equality, in everything but the last provenance. @@ -37,9 +53,9 @@ def verify_slim_restart_equality(in_ts_dict, out_ts_dict, check_prov=True): if check_prov: assert in_ts.num_provenances + 1 == out_ts.num_provenances in_tables = in_ts.dump_tables() - in_tables.canonicalise() + canonicalise_tables(in_tables) out_tables = out_ts.dump_tables() - out_tables.canonicalise() + canonicalise_tables(out_tables) in_tables.assert_equals(out_tables, ignore_provenance=True) @@ -103,13 +119,14 @@ def verify_defaults(self, ts): Verify the default values have been entered into metadata. """ do_pops = [False for _ in ts.populations()] - for m in ts.mutations(): - md = m.metadata - assert isinstance(md["mutation_list"], list) - for mdl in md["mutation_list"]: - assert mdl["mutation_type"] == 0 - assert mdl["selection_coeff"] == 0.0 - assert mdl["subpopulation"] == tskit.NULL + for mdl in ts.metadata["SLiM_mutation_list"]: + assert mdl["mutation_type"] == 0 + assert mdl["per_trait"] == [ + {"effect_size": 0.0, "dominance": 0.5, "hemizygous_dominance": 1.0} + ] + assert mdl["subpopulation"] == tskit.NULL + # assert mdl["slim_time"] == 0 # this is set to something meaningful + assert mdl["nucleotide"] == -1 for n in ts.nodes(): md = n.metadata if not n.is_sample(): @@ -220,17 +237,17 @@ def verify_remapping(self, ts, rts, subpop_map): assert m == rm assert ts.num_mutations == rts.num_mutations + mut_info = pyslim.mutation_metadata(ts) + rmut_info = pyslim.mutation_metadata(rts) + assert len(mut_info) == len(rmut_info) # mutations may have changed order - tsm = {m.derived_state: m for m in ts.mutations()} - rtsm = {m.derived_state: m for m in rts.mutations()} + tsm = {m.derived_state for m in ts.mutations()} + rtsm = {m.derived_state for m in rts.mutations()} for x in tsm: assert x in rtsm - m = tsm[x] - rm = rtsm[x] - md = m.metadata - rmd = rm.metadata - assert len(md["mutation_list"]) == len(rmd["mutation_list"]) - for x, y in zip(md["mutation_list"], rmd["mutation_list"]): + for sid in x.split(","): + x = mut_info[int(sid)].copy() + y = rmut_info[int(sid)] x["subpopulation"] = fwd_map[x["subpopulation"]] assert x == y @@ -239,7 +256,7 @@ def verify_remapping(self, ts, rts, subpop_map): md = i.metadata rmd = ri.metadata md["subpopulation"] = fwd_map[md["subpopulation"]] - assert md == rmd + self.assert_indiv_metadata_equal(md, rmd) def test_annotate_errors(self, helper_functions): for ts in helper_functions.get_msprime_examples(): @@ -279,6 +296,11 @@ def test_warns_overwriting_mutations(self, helper_functions): ts, rate=1, random_seed=12, model=msprime.SLiMMutationModel(type=1) ) assert ts.num_mutations > 0 + t = ts.dump_tables() + t.metadata_schema = pyslim.slim_metadata_schemas["tree_sequence"] + t.metadata = pyslim.default_slim_metadata("tree_sequence") + ts = t.tree_sequence() + ts = pyslim.add_mutation_metadata(ts) with pytest.warns(Warning, match="already has.*metadata"): slim_ts = pyslim.annotate(ts, model_type="WF", tick=1) @@ -335,45 +357,35 @@ def test_just_simulate(self, helper_functions, tmp_path): def test_basic_annotation(self, helper_functions, tmp_path): for ts in helper_functions.get_msprime_examples(): - for do_mutations in [False, True]: - tick = 4 - cycle = 1 - stage = "late" - if do_mutations: - handler = mutcontext(ts) - else: - handler = contextlib.nullcontext() - with handler: - slim_ts = pyslim.annotate( - ts, - model_type="WF", - tick=tick, - cycle=cycle, - stage=stage, - annotate_mutations=do_mutations, - ) - assert slim_ts.metadata["SLiM"]["model_type"] == "WF" - assert slim_ts.metadata["SLiM"]["tick"] == tick - assert slim_ts.metadata["SLiM"]["cycle"] == cycle - assert slim_ts.metadata["SLiM"]["stage"] == stage - assert ( - slim_ts.metadata["SLiM"]["file_version"] == pyslim.slim_file_version - ) - self.verify_annotated_tables( - ts, slim_ts, check_alleles=(not do_mutations) + tick = 4 + cycle = 1 + stage = "late" + with mutcontext(ts): + slim_ts = pyslim.annotate( + ts, + model_type="WF", + tick=tick, + cycle=cycle, + stage=stage, + annotate_mutations=True, ) - self.verify_annotated_trees(ts, slim_ts) - if not do_mutations: - self.verify_haplotype_equality(ts, slim_ts) - self.verify_defaults(slim_ts) - self.verify_provenance(slim_ts) - # try loading this into SLiM - loaded_ts = helper_functions.run_msprime_restart( - {"default": slim_ts}, tmp_path, multichrom=False, WF=True - )["default"] - self.verify_annotated_tables(loaded_ts, slim_ts) - self.verify_annotated_trees(loaded_ts, slim_ts) - self.verify_haplotype_equality(loaded_ts, slim_ts) + slim_ts_md = slim_ts.metadata["SLiM"] + assert slim_ts_md["model_type"] == "WF" + assert slim_ts_md["tick"] == tick + assert slim_ts_md["cycle"] == cycle + assert slim_ts_md["stage"] == stage + assert slim_ts_md["file_version"] == pyslim.slim_file_version + self.verify_annotated_tables(ts, slim_ts, check_alleles=False) + self.verify_annotated_trees(ts, slim_ts) + self.verify_defaults(slim_ts) + self.verify_provenance(slim_ts) + # try loading this into SLiM + loaded_ts = helper_functions.run_msprime_restart( + {"default": slim_ts}, tmp_path, multichrom=False, WF=True + )["default"] + self.verify_annotated_tables(loaded_ts, slim_ts) + self.verify_annotated_trees(loaded_ts, slim_ts) + self.verify_haplotype_equality(loaded_ts, slim_ts) def test_annotate_refseq(self): ts = msprime.sim_ancestry(2, sequence_length=10, random_seed=77) @@ -447,22 +459,22 @@ def test_annotate_nodes(self, helper_functions): # not testing SLiM because needs annotation of indivs to make sense def test_annotate_mutations(self, helper_functions): + # test workflow of annotating and editing + rng = np.random.default_rng(seed=123) for ts in helper_functions.get_msprime_examples(): with mutcontext(ts): slim_ts = pyslim.annotate(ts, model_type="nonWF", tick=1) tables = slim_ts.dump_tables() - metadata = [m.metadata for m in tables.mutations] - selcoefs = [random.uniform(0, 1) for _ in metadata] + md = tables.metadata + metadata = md["SLiM_mutation_list"] + # these are stored as "f" meaning "float" (not "double") + selcoefs = rng.uniform(size=len(metadata)).astype("float32") for j in range(len(metadata)): - metadata[j]["mutation_list"][0]["selection_coeff"] = selcoefs[j] - ms = tables.mutations.metadata_schema - tables.mutations.packset_metadata( - [ms.validate_and_encode_row(r) for r in metadata] - ) + metadata[j]["per_trait"][0]["effect_size"] = selcoefs[j] + tables.metadata = md new_ts = tables.tree_sequence() - for j, x in enumerate(new_ts.mutations()): - md = x.metadata - assert np.isclose(md["mutation_list"][0]["selection_coeff"], selcoefs[j]) + for j, md in enumerate(new_ts.metadata["SLiM_mutation_list"]): + assert md["per_trait"][0]["effect_size"] == selcoefs[j] def test_dont_annotate_mutations(self): # Test the option to not overwrite mutation annotations @@ -828,11 +840,14 @@ def test_remapping(self, helper_functions, tmp_path): random_seed=455, ) ts = pyslim.annotate(ts, model_type="WF", tick=1) - ts = msprime.sim_mutations( - ts, - rate=1e-2, - random_seed=9, - model=msprime.SLiMMutationModel(type=1), + ts = pyslim.add_mutation_metadata( + msprime.sim_mutations( + ts, + rate=1e-2, + random_seed=9, + model=msprime.SLiMMutationModel(type=1), + ), + mutation_type=1, ) assert ts.num_mutations > 0 for subpop_map in ( @@ -947,8 +962,8 @@ def test_reload_annotate(self, restart_name, recipe, helper_functions, tmp_path) in_ts = {} for chrom, ts in recipe["ts"].items(): tables = ts.dump_tables() - metadata = [m.metadata for m in tables.mutations] - has_nucleotides = tables.metadata["SLiM"]["nucleotide_based"] + metadata = tables.metadata + has_nucleotides = metadata["SLiM"]["nucleotide_based"] if has_nucleotides: nucs = [random.choice([0, 1, 2, 3]) for _ in metadata] refseq = "".join( @@ -957,17 +972,13 @@ def test_reload_annotate(self, restart_name, recipe, helper_functions, tmp_path) k=int(ts.sequence_length), ), ) - for n, md in zip(nucs, metadata): - for m in md["mutation_list"]: - m["nucleotide"] = n + for n, md in zip(nucs, metadata["SLiM_mutation_list"]): + md["nucleotide"] = n tables.reference_sequence.data = refseq - for md in metadata: - for m in md["mutation_list"]: - m["selection_coeff"] = random.random() - ms = tables.mutations.metadata_schema - tables.mutations.packset_metadata( - [ms.validate_and_encode_row(r) for r in metadata] - ) + for md in metadata["SLiM_mutation_list"]: + for x in md["per_trait"]: + x["effect_size"] = random.random() + tables.metadata = metadata in_ts[chrom] = tables.tree_sequence() # put it through SLiM (which just reads in and writes out) out_ts = helper_functions.run_slim_restart( @@ -1046,3 +1057,136 @@ def test_restarts_and_runs_simplified( it = in_ts[k] ot = out_ts[k] assert ot.metadata["SLiM"]["tick"] >= it.metadata["SLiM"]["tick"] + + +class TestAddMutationMetadata(tests.PyslimTestCase): + def test_add_mutation_metadata_errors(self): + ts = msprime.sim_ancestry(10, random_seed=5) + ts = msprime.sim_mutations( + ts, rate=5, random_seed=3, model=msprime.SLiMMutationModel(type=0) + ) + # bad metadata schema + t = ts.dump_tables() + t.metadata_schema = tskit.MetadataSchema(None) + bad_ts = t.tree_sequence() + with pytest.raises(ValueError, match="metadata schema is not"): + _ = pyslim.add_mutation_metadata(bad_ts) + t.metadata_schema = tskit.MetadataSchema.permissive_json() + t.metadata = {} + bad_ts = t.tree_sequence() + with pytest.raises(ValueError, match="metadata schema is not"): + _ = pyslim.add_mutation_metadata(bad_ts) + t.metadata["SLiM"] = pyslim.default_slim_metadata("tree_sequence")["SLiM"] + bad_ts = t.tree_sequence() + with pytest.raises(ValueError, match="metadata schema is not"): + _ = pyslim.add_mutation_metadata(bad_ts) + + def test_add_mutation_metadata_mutation_type(self): + ts = msprime.sim_ancestry(10, random_seed=5) + ts = msprime.sim_mutations( + ts, rate=5, random_seed=3, model=msprime.SLiMMutationModel(type=0) + ) + t = ts.dump_tables() + t.metadata_schema = pyslim.slim_metadata_schemas["tree_sequence"] + t.metadata = pyslim.default_slim_metadata("tree_sequence") + ts = t.tree_sequence() + for k in (0, 1, 5): + new_ts = pyslim.add_mutation_metadata(ts, mutation_type=k) + for x in new_ts.metadata["SLiM_mutation_list"]: + assert x["mutation_type"] == k + + @pytest.mark.parametrize( + "recipe", + recipe_eq( + exclude=( + "long", + "no_simplify", + "multichrom", + "everyone", + "init_mutated", + "old_mutations", + ) + ), + indirect=True, + ) + def test_add_mutation_metadata(self, recipe): + for _, ts in recipe["ts"].items(): + tables = ts.dump_tables() + md = tables.metadata + md["SLiM_mutation_list"] = [] + tables.metadata = md + new_ts = pyslim.add_mutation_metadata( + tables.tree_sequence(), + ) + old_metadata = pyslim.mutation_metadata(ts) + new_metadata = pyslim.mutation_metadata(new_ts) + assert len(old_metadata) == len(new_metadata) + for k in old_metadata: + assert 0 == new_metadata[k]["mutation_type"] # default + assert old_metadata[k]["slim_time"] == new_metadata[k]["slim_time"] + assert old_metadata[k]["mutation_id"] == new_metadata[k]["mutation_id"] + + @pytest.mark.parametrize( + "recipe", + recipe_eq( + exclude=( + "long", + "no_simplify", + "multichrom", + "everyone", + "init_mutated", + "old_mutations", + ) + ), + indirect=True, + ) + def test_add_mutation_metadata_keeps(self, recipe): + for _, ts in recipe["ts"].items(): + if ts.num_mutations > 15: + tables = ts.dump_tables() + md = tables.metadata + metadata = md["SLiM_mutation_list"] + del metadata[:2] + del metadata[10:] + kept_ids = {m["mutation_id"] for m in metadata} + md["SLiM_mutation_list"] = metadata + tables.metadata = md + new_ts = pyslim.add_mutation_metadata( + tables.tree_sequence(), + ) + old_metadata = pyslim.mutation_metadata(ts) + new_metadata = pyslim.mutation_metadata(new_ts) + assert len(old_metadata) == len(new_metadata) + for k in old_metadata: + if k in kept_ids: + assert old_metadata[k] == new_metadata[k] + else: + assert 0 == new_metadata[k]["mutation_type"] # default + assert ( + old_metadata[k]["slim_time"] == new_metadata[k]["slim_time"] + ) + assert ( + old_metadata[k]["mutation_id"] + == new_metadata[k]["mutation_id"] + ) + + def test_add_mutation_metadata_removes(self): + ts = msprime.sim_ancestry(10, random_seed=5) + ts = msprime.sim_mutations( + ts, rate=5, random_seed=3, model=msprime.SLiMMutationModel(type=0) + ) + ts = pyslim.add_mutation_metadata(pyslim.annotate(ts, model_type="WF", tick=1)) + sts = ts.simplify([0, 1]) + assert sts.num_mutations < ts.num_mutations + assert ts.metadata["SLiM_mutation_list"] == sts.metadata["SLiM_mutation_list"] + nsts = pyslim.add_mutation_metadata(sts, remove_unused=True) + mut_info = pyslim.mutation_metadata(ts) + nmut_info = pyslim.mutation_metadata(nsts) + mut_ids = np.unique( + [int(k) for mut in nsts.mutations() for k in mut.derived_state.split(",")] + ) + assert len(mut_ids) == len(nsts.metadata["SLiM_mutation_list"]) + for k in mut_ids: + assert k in nmut_info + assert k in mut_info + assert mut_info[k] == nmut_info[k] diff --git a/tests/test_metadata.py b/tests/test_metadata.py index b0a44336..e472e8ff 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -12,6 +12,20 @@ from .recipe_specs import recipe_eq +def assert_nan_equal(a, b): + if isinstance(a, float): + assert (a == b) or (np.isnan(a) and np.isnan(b)) + elif isinstance(a, dict) and isinstance(b, dict): + assert a.keys() == b.keys() + for k in a: + assert_nan_equal(a[k], b[k]) + elif isinstance(a, list) and isinstance(b, list): + for x, y in zip(a, b, strict=True): + assert_nan_equal(x, y) + else: + assert a == b + + class TestMetadataSchemas(tests.PyslimTestCase): def validate_table_metadata(self, table): ms = table.metadata_schema @@ -46,35 +60,47 @@ def test_default_metadata(self): schema = pyslim.slim_metadata_schemas[k] entry = pyslim.default_slim_metadata(k) sd = schema.asdict() - if sd is not None: - for p in sd["properties"]: - assert p in entry - encoded = schema.validate_and_encode_row(entry) - decoded = schema.decode_row(encoded) - if entry is None: - assert decoded is None + if k != "tree_sequence": + if sd is not None: + for p in sd["properties"]: + assert p in entry + encoded = schema.validate_and_encode_row(entry) + decoded = schema.decode_row(encoded) + if entry is None: + assert decoded is None + else: + # some defaults have nans, which are not equal + assert_nan_equal(entry, decoded) else: + assert k == "tree_sequence" + for p in sd["json"]["properties"]: + assert p in entry + encoded = schema.validate_and_encode_row(entry) + decoded = schema.decode_row(encoded) + assert entry == decoded + entry["SLiM_mutation_list"].append( + pyslim.default_slim_metadata("mutation_list_entry") + ) + encoded = schema.validate_and_encode_row(entry) + decoded = schema.decode_row(encoded) + assert entry == decoded + entry["SLiM_mutation_list"].append( + pyslim.default_slim_metadata("mutation_list_entry") + ) + encoded = schema.validate_and_encode_row(entry) + decoded = schema.decode_row(encoded) assert entry == decoded - schema = pyslim.slim_metadata_schemas["mutation"] - entry = pyslim.default_slim_metadata("mutation") - entry["mutation_list"].append( - pyslim.default_slim_metadata("mutation_list_entry") - ) - encoded = schema.validate_and_encode_row(entry) - decoded = schema.decode_row(encoded) - assert entry == decoded - entry["mutation_list"].append( - pyslim.default_slim_metadata("mutation_list_entry") - ) - encoded = schema.validate_and_encode_row(entry) - decoded = schema.decode_row(encoded) - assert entry == decoded def test_slim_metadata_schema_equality(self, recipe): num_chromosomes = len(recipe["ts"]) for ts in recipe["ts"].values(): t = ts.dump_tables() - assert t.metadata_schema == pyslim.slim_metadata_schemas["tree_sequence"] + num_traits = len(t.metadata["SLiM"]["traits"]) + ts_schema = pyslim.slim_metadata_schemas["tree_sequence"].asdict() + ts_schema["struct"]["properties"]["SLiM_mutation_list"]["items"][ + "properties" + ]["per_trait"]["length"] = num_traits + assert t.metadata_schema.asdict() == ts_schema assert t.edges.metadata_schema == pyslim.slim_metadata_schemas["edge"] assert t.sites.metadata_schema == pyslim.slim_metadata_schemas["site"] assert ( @@ -85,10 +111,9 @@ def test_slim_metadata_schema_equality(self, recipe): (num_chromosomes + 7) / 8 ) assert t.nodes.metadata_schema.asdict() == node_schema - assert ( - t.individuals.metadata_schema - == pyslim.slim_metadata_schemas["individual"] - ) + ind_schema = pyslim.slim_metadata_schemas["individual"].asdict() + ind_schema["properties"]["per_trait"]["length"] = num_traits + assert t.individuals.metadata_schema.asdict() == ind_schema assert ( t.populations.metadata_schema == pyslim.slim_metadata_schemas["population"] @@ -117,18 +142,25 @@ class TestTreeSequenceMetadata(tests.PyslimTestCase): def validate_slim_metadata(self, t): # t could be tables or a tree sequence schema = t.metadata_schema.schema - assert "SLiM" in schema["properties"] - assert "SLiM" in t.metadata + assert schema["codec"] == "json+struct" + assert "SLiM" in schema["json"]["properties"] + tmd = t.metadata + assert "SLiM" in tmd for k in pyslim.default_slim_metadata("tree_sequence")["SLiM"]: - assert k in schema["properties"]["SLiM"]["properties"] - assert k in t.metadata["SLiM"] + assert k in schema["json"]["properties"]["SLiM"]["properties"] + assert k in tmd["SLiM"] + sml = schema["struct"]["properties"] + assert "SLiM_mutation_list" in sml + for k in pyslim.default_slim_metadata("mutation_list_entry"): + assert k in sml["SLiM_mutation_list"]["items"]["properties"] def validate_model_type(self, tsdict, model_type): for _, ts in tsdict.items(): - assert ts.metadata["SLiM"]["file_version"] == pyslim.slim_file_version - assert ts.metadata["SLiM"]["model_type"] == model_type - assert ts.metadata["SLiM"]["tick"] > 0 - assert ts.metadata["SLiM"]["tick"] >= np.max(ts.tables.nodes.time) + md = ts.metadata + assert md["SLiM"]["file_version"] == pyslim.slim_file_version + assert md["SLiM"]["model_type"] == model_type + assert md["SLiM"]["tick"] > 0 + assert md["SLiM"]["tick"] >= np.max(ts.tables.nodes.time) @pytest.mark.parametrize("recipe", arbitrary_recipe, indirect=True) def test_set_tree_sequence_metadata_errors(self, recipe): @@ -156,19 +188,59 @@ def test_set_tree_sequence_metadata_keeps(self, recipe): tables.metadata = dummy_metadata pyslim.set_tree_sequence_metadata(tables, "nonWF", 0) schema = tables.metadata_schema.schema + tmd = tables.metadata for k in dummy_metadata: if len(x) > 0: - assert k in schema["properties"] - assert k in tables.metadata - assert tables.metadata[k] == dummy_metadata[k] + assert k in schema["json"]["properties"] + assert k in tmd + assert tmd[k] == dummy_metadata[k] self.validate_slim_metadata(tables) - assert tables.metadata["SLiM"]["model_type"] == "nonWF" - assert tables.metadata["SLiM"]["tick"] == 0 + assert tmd["SLiM"]["model_type"] == "nonWF" + assert tmd["SLiM"]["tick"] == 0 + + @pytest.mark.parametrize("recipe", arbitrary_recipe, indirect=True) + def test_set_tree_sequence_metadata_keeps_struct(self, recipe): + # make sure doesn't overwrite other stuff + ts = list(recipe["ts"].values())[0] + json_props = {"num": {"type": "number"}} + struct_props = {"binnum": {"type": "integer", "binaryFormat": "i", "default": 0}} + schema_dict = { + "codec": "json+struct", + "type": "object", + "json": {"codec": "json", "type": "object", "properties": json_props}, + "struct": {"struct": "json", "type": "object", "properties": struct_props}, + } + dummy_schema = tskit.MetadataSchema(schema_dict) + dummy_metadata = {"num": 12, "binnum": 42} + tables = ts.dump_tables() + tables.metadata_schema = dummy_schema + tables.metadata = dummy_metadata + pyslim.set_tree_sequence_metadata(tables, "nonWF", 0) + schema = tables.metadata_schema.schema + tmd = tables.metadata + for k in dummy_metadata: + if k in json_props: + assert schema["json"]["properties"][k] == json_props[k] + else: + assert schema["struct"]["properties"][k] == struct_props[k] + assert k in tmd + assert tmd[k] == dummy_metadata[k] + self.validate_slim_metadata(tables) + assert tmd["SLiM"]["model_type"] == "nonWF" + assert tmd["SLiM"]["tick"] == 0 @pytest.mark.parametrize("recipe", arbitrary_recipe, indirect=True) def test_set_tree_sequence_metadata(self, recipe): ts = list(recipe["ts"].values())[0] tables = ts.dump_tables() + chroms = [ + {"id": 1, "name": "autosome_1", "symbol": "1", "type": "A", "index": 0}, + {"id": 35, "name": "mtDNA", "symbol": "MT", "type": "HF", "index": 1}, + ] + traits = [ + {"index": 0, "name": "theTrait", "type": "additive"}, + {"index": 1, "name": "perfectness", "type": "multiplicative"}, + ] pyslim.set_tree_sequence_metadata( tables, "WF", @@ -179,16 +251,23 @@ def test_set_tree_sequence_metadata(self, recipe): spatial_periodicity="y", separate_sexes=False, nucleotide_based=True, + this_chromosome=chroms[1], + chromosomes=chroms, + traits=traits, ) self.validate_slim_metadata(tables) - assert tables.metadata["SLiM"]["model_type"] == "WF" - assert tables.metadata["SLiM"]["tick"] == 99 - assert tables.metadata["SLiM"]["cycle"] == 40 - assert tables.metadata["SLiM"]["stage"] == "early" - assert tables.metadata["SLiM"]["spatial_dimensionality"] == "xy" - assert tables.metadata["SLiM"]["spatial_periodicity"] == "y" - assert tables.metadata["SLiM"]["separate_sexes"] == False - assert tables.metadata["SLiM"]["nucleotide_based"] == True + tmd = tables.metadata + assert tmd["SLiM"]["model_type"] == "WF" + assert tmd["SLiM"]["tick"] == 99 + assert tmd["SLiM"]["cycle"] == 40 + assert tmd["SLiM"]["stage"] == "early" + assert tmd["SLiM"]["spatial_dimensionality"] == "xy" + assert tmd["SLiM"]["spatial_periodicity"] == "y" + assert tmd["SLiM"]["separate_sexes"] == False + assert tmd["SLiM"]["nucleotide_based"] == True + assert tmd["SLiM"]["chromosomes"] == chroms + assert tmd["SLiM"]["this_chromosome"] == chroms[1] + assert tmd["SLiM"]["traits"] == traits @pytest.mark.parametrize("recipe", recipe_eq("WF"), indirect=True) def test_WF_model_type(self, recipe): @@ -199,12 +278,12 @@ def test_nonWF_model_type(self, recipe): self.validate_model_type(recipe["ts"], "nonWF") @pytest.mark.parametrize( - "recipe", recipe_eq(exclude=["user_metadata", "multichrom"]), indirect=True + "recipe", + recipe_eq(exclude=["user_metadata", "multichrom", "record_mutations"]), + indirect=True, ) def test_recover_metadata(self, recipe): # msprime <=0.7.5 discards metadata, but we can recover it from provenance - # HOWEVER: multichromosome information is not saved - # but this is not something we need to maintain any more. for _, ts in recipe["ts"].items(): tables = ts.dump_tables() tables.metadata_schema = tskit.MetadataSchema(None) @@ -212,13 +291,12 @@ def test_recover_metadata(self, recipe): pyslim.update_tables(tables) md = tables.metadata assert "SLiM" in md - for k in ts.metadata["SLiM"]: - if k in ("chromosomes", "this_chromosome"): - continue # TODO: see https://github.com/MesserLab/SLiM/issues/520 + tsmd = ts.metadata["SLiM"] + for k in tsmd: assert k in md["SLiM"] # slim does not write out empty descriptions - if k != "description" or ts.metadata["SLiM"][k] != "": - assert ts.metadata["SLiM"][k] == md["SLiM"][k] + if k != "description" or tsmd[k] != "": + assert tsmd[k] == md["SLiM"][k] @pytest.mark.parametrize( "recipe", recipe_eq("recipe_with_metadata.slim"), indirect=True @@ -267,10 +345,9 @@ def test_nucleotides(self, recipe): -1, 0, 1, 2, or 3. """ for _, ts in recipe["ts"].items(): - for mut in ts.mutations(): - for u in mut.metadata["mutation_list"]: - assert u["nucleotide"] >= -1 - assert u["nucleotide"] <= 3 + for u in ts.metadata["SLiM_mutation_list"]: + assert u["nucleotide"] >= -1 + assert u["nucleotide"] <= 3 class TestMultichrom(tests.PyslimTestCase): diff --git a/tests/test_provenance.py b/tests/test_provenance.py index d95b275d..71568b22 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -122,6 +122,18 @@ old_provenance_examples = [_slim_v3_0_example, _slim_v3_1_example, _slim_v3_3_1_example] +def yield_ts(path): + out = {} + if os.path.isfile(path): + yield tskit.load(path) + elif os.path.isdir(path): + chroms = os.listdir(path) + for cfile in os.listdir(path): + _, e = os.path.splitext(cfile) + if e == ".trees": + yield tskit.load(os.path.join(path, cfile)) + + class TestProvenance(tests.PyslimTestCase): script_dir = os.path.dirname(os.path.realpath(__file__)) @@ -183,6 +195,18 @@ def get_0_8_slim_examples(self): ]: yield tskit.load(filename) + def get_0_9_slim_examples(self): + for filename in [ + os.path.join(self.script_dir, "test_recipes", "recipe_WF.v5.2.trees"), + os.path.join(self.script_dir, "test_recipes", "recipe_WF_X.v5.2.trees"), + os.path.join(self.script_dir, "test_recipes", "recipe_WF_Y.v5.2.trees"), + os.path.join(self.script_dir, "test_recipes", "recipe_nonWF.v5.2.trees"), + os.path.join( + self.script_dir, "test_recipes", "recipe_WF_many_chromosomes.v5.2.trees" + ), + ]: + yield from yield_ts(filename) + def get_mixed_slim_examples(self): for filename in [ os.path.join( @@ -209,6 +233,115 @@ def verify_upgrade(self, ts): for x in t: _ = ms.validate_and_encode_row(x.metadata) + def verify_consistency(self, ts, pts, file_version): + # Check for stuff we know should be copied over verbatim + # 0.1-0.4 we had no metadata schemas; we could pull those old ones + # from slim_metadata.py but we're not + self.verify_top_level_consistency(ts, pts, file_version) + self.verify_nodes_consistency(ts, pts, file_version) + self.verify_edges_consistency(ts, pts, file_version) + self.verify_sites_consistency(ts, pts, file_version) + self.verify_mutations_consistency(ts, pts, file_version) + self.verify_individuals_consistency(ts, pts, file_version) + self.verify_populations_consistency(ts, pts, file_version) + + def verify_top_level_consistency(self, ts, pts, file_version): + # 0.1-0.7: + # model_type, generation, spatial_dimesionality, spatial_periodicity, + # separate_sexes, nucleotide_based + # 0.8: + # changed generation to tick + # 0.9: + # added this_chromosome + # 1.0: + # added traits + if file_version not in ("0.1", "0.2", "0.3", "0.4"): + md = ts.metadata["SLiM"] + pmd = pts.metadata["SLiM"] + for k in ( + "model_type", + "spatial_dimensionality", + "spatial_periodicity", + "separate_sexes", + "nucleotide_based", + ): + assert md[k] == pmd[k] + k = pk = "tick" + if file_version in ("0.5", "0.6", "0.7"): + k = "generation" + assert md[k] == pmd[pk] + k = "this_chromosome" + if file_version == "0.9": + assert md[k] == pmd[k] + + def verify_edges_consistency(self, ts, pts, file_version): + # no metadata + ts.tables.edges.assert_equals(pts.tables.edges, ignore_metadata=True) + + def verify_sites_consistency(self, ts, pts, file_version): + # no metadata + ts.tables.sites.assert_equals(pts.tables.sites, ignore_metadata=True) + + def verify_mutations_consistency(self, ts, pts, file_version): + ts.tables.mutations.assert_equals(pts.tables.mutations, ignore_metadata=True) + # As of 1.0, metadata moved to top level + if file_version not in ("0.1", "0.2", "0.3", "0.4"): + ptsmd = pts.metadata + num_traits = len(ptsmd["SLiM"]["traits"]) + mut_info = {x["mutation_id"]: x for x in ptsmd["SLiM_mutation_list"]} + for mut in ts.mutations(): + for sid, md in zip( + mut.derived_state.split(","), mut.metadata["mutation_list"] + ): + assert int(sid) in mut_info + mi = mut_info[int(sid)] + for k in ("mutation_type", "subpopulation", "slim_time"): + assert mi[k] == md[k] + assert len(mi["per_trait"]) == 1 + if "nucleotide" in md: + assert mi["nucleotide"] == md["nucleotide"] + assert len(mi["per_trait"]) == num_traits + # we're only converting from single-trait slim so far + assert num_traits == 1 + assert mi["per_trait"][0]["effect_size"] == md["selection_coeff"] + + def verify_individuals_consistency(self, ts, pts, file_version): + ts.tables.individuals.assert_equals(pts.tables.individuals, ignore_metadata=True) + # This has: + # pedigree_id, age, subpopulation, sex, flags + # Starting in 0.7 also: + # pedigree_p1, pedigree_p2 + # Starting in 1.0: per_trait + for a, b in zip(ts.individuals(), pts.individuals()): + if file_version not in ("0.1", "0.2", "0.3", "0.4"): + for k in ("pedigree_id", "age", "subpopulation", "sex", "flags"): + assert a.metadata[k] == b.metadata[k] + if file_version not in ("0.5", "0.6"): + for k in ("pedigree_p1", "pedigree_p2"): + assert a.metadata[k] == b.metadata[k] + + def verify_nodes_consistency(self, ts, pts, file_version): + # 0.1-0.8: had slim_id, is_null, genome_type + # 0.9: removed genome_type + # and changed is_null to is_vacant + # 1.0: same as 0.9 but changed some indexes + if file_version != "0.1": + # 0.1 had a shift in time we're not checking here + ts.tables.nodes.assert_equals(pts.tables.nodes, ignore_metadata=True) + for a, b in zip(ts.nodes(), pts.nodes()): + if file_version in ("0.5", "0.6", "0.7", "0.8"): + assert a.metadata["slim_id"] == b.metadata["slim_id"] + elif file_version == "0.9": + assert a.metadata == b.metadata + + def verify_populations_consistency(self, ts, pts, file_version): + ts.tables.populations.assert_equals(pts.tables.populations, ignore_metadata=True) + # This has a whole bunch of things, none of which are required. + for a, b in zip(ts.individuals(), pts.individuals()): + if file_version not in ("0.1", "0.2", "0.3", "0.4"): + for k in a.metadata: + assert a.metadata[k] == b.metadata[k] + def test_convert_0_1_files(self): for ts in self.get_0_1_slim_examples(): assert not pyslim.is_current_version(ts) @@ -216,24 +349,16 @@ def test_convert_0_1_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.1") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert record["model_type"] == pts.metadata["SLiM"]["model_type"] - assert record["generation"] == pts.metadata["SLiM"]["tick"] - assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) - samples = list(ts.samples()) - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["generation"] == ptsmd["SLiM"]["tick"] def test_convert_0_2_files(self): for ts in self.get_0_2_slim_examples(): @@ -242,26 +367,16 @@ def test_convert_0_2_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.2") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["generation"] == pts.metadata["SLiM"]["tick"] - assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) - samples = list(ts.samples()) - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["generation"] == ptsmd["SLiM"]["tick"] def test_convert_0_3_files(self): for ts in self.get_0_3_slim_examples(): @@ -270,26 +385,16 @@ def test_convert_0_3_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.3") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["generation"] == pts.metadata["SLiM"]["tick"] - assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) - samples = list(ts.samples()) - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["generation"] == ptsmd["SLiM"]["tick"] def test_convert_0_4_files(self): # Note that with version 0.5 and above, we *don't* get information from @@ -300,26 +405,16 @@ def test_convert_0_4_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.4") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["generation"] == pts.metadata["SLiM"]["tick"] - assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) - samples = list(ts.samples()) - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["generation"] == ptsmd["SLiM"]["tick"] def test_convert_0_5_files(self): for ts in self.get_0_5_slim_examples(): @@ -328,26 +423,16 @@ def test_convert_0_5_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.5") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["generation"] == pts.metadata["SLiM"]["tick"] - assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) - samples = list(ts.samples()) - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["generation"] == ptsmd["SLiM"]["tick"] def test_convert_0_6_files(self): for ts in self.get_0_6_slim_examples(): @@ -356,26 +441,16 @@ def test_convert_0_6_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.6") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["generation"] == pts.metadata["SLiM"]["tick"] - assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) - samples = list(ts.samples()) - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["generation"] == ptsmd["SLiM"]["tick"] def test_convert_0_7_files(self): for ts in self.get_0_7_slim_examples(): @@ -384,26 +459,16 @@ def test_convert_0_7_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.7") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["generation"] == pts.metadata["SLiM"]["tick"] - assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) - samples = list(ts.samples()) - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["generation"] == ptsmd["SLiM"]["tick"] def test_convert_0_8_files(self): for ts in self.get_0_8_slim_examples(): @@ -412,18 +477,17 @@ def test_convert_0_8_files(self): pts = pyslim.update(ts) assert pyslim.is_current_version(pts) self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.8") assert ts.num_provenances == 1 assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["tick"] == pts.metadata["SLiM"]["tick"] + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["tick"] == ptsmd["SLiM"]["tick"] assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) samples = list(ts.samples()) genome_type = None for n in samples: @@ -432,7 +496,7 @@ def test_convert_0_8_files(self): genome_type = md["genome_type"] break assert genome_type is not None - chromosome_type = pts.metadata["SLiM"]["this_chromosome"]["type"] + chromosome_type = ptsmd["SLiM"]["this_chromosome"]["type"] GENOME_TYPE_AUTOSOME = 0 GENOME_TYPE_X = 1 GENOME_TYPE_Y = 2 @@ -442,13 +506,24 @@ def test_convert_0_8_files(self): assert chromosome_type == "X" elif genome_type == GENOME_TYPE_Y: assert chromosome_type == "-Y" - t = ts.first() - pt = pts.first() - for _ in range(20): - u = random.sample(samples, 1)[0] - assert t.parent(u) == pt.parent(u) - if t.parent(u) != tskit.NULL: - assert t.branch_length(u) == pt.branch_length(u) + + def test_convert_0_9_files(self): + for ts in self.get_0_9_slim_examples(): + assert not pyslim.is_current_version(ts) + with pytest.warns(Warning): + pts = pyslim.update(ts) + assert pyslim.is_current_version(pts) + self.verify_upgrade(pts) + self.verify_consistency(ts, pts, file_version="0.9") + assert ts.num_provenances == 1 + assert pts.num_provenances == 2 + assert ts.provenance(0).record == pts.provenance(0).record + record = json.loads(ts.provenance(0).record) + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["tick"] == ptsmd["SLiM"]["tick"] def test_convert_mixed_files(self): for ts in self.get_mixed_slim_examples(): @@ -461,14 +536,13 @@ def test_convert_mixed_files(self): assert pts.num_provenances == 2 assert ts.provenance(0).record == pts.provenance(0).record record = json.loads(ts.provenance(0).record) - assert isinstance(pts.metadata, dict) - assert "SLiM" in pts.metadata - assert ( - record["parameters"]["model_type"] == pts.metadata["SLiM"]["model_type"] - ) - assert record["slim"]["generation"] == pts.metadata["SLiM"]["tick"] + ptsmd = pts.metadata + assert isinstance(ptsmd, dict) + assert "SLiM" in ptsmd + assert record["parameters"]["model_type"] == ptsmd["SLiM"]["model_type"] + assert record["slim"]["generation"] == ptsmd["SLiM"]["tick"] assert list(ts.samples()) == list(pts.samples()) - assert np.array_equal(ts.tables.nodes.flags, pts.tables.nodes.flags) + assert np.array_equal(ts.tables.nodes.flags, pts.nodes_flags) samples = list(ts.samples()) t = ts.first() pt = pts.first() diff --git a/tests/test_recipes/make_v3_tests.sh b/tests/test_recipes/make_old_file_versions.sh similarity index 80% rename from tests/test_recipes/make_v3_tests.sh rename to tests/test_recipes/make_old_file_versions.sh index eb88af9b..ab6b48f9 100644 --- a/tests/test_recipes/make_v3_tests.sh +++ b/tests/test_recipes/make_old_file_versions.sh @@ -95,4 +95,20 @@ $SLIMDIR/slim recipe_WF_X.slim && mv out.trees recipe_WF_X.${TAG}.trees $SLIMDIR/slim recipe_WF_Y.slim && mv out.trees recipe_WF_Y.${TAG}.trees git add -f recipe_nonWF.${TAG}.trees recipe_WF.${TAG}.trees recipe_WF_X.${TAG}.trees recipe_WF_Y.${TAG}.trees +# To make the v5.2 files: + +TAG=v5.2 +git checkout $TAG +mkdir -p build_$TAG && cd build_$TAG +cmake .. && make +SLIMDIR=$(pwd) +cd ../.. +$SLIMDIR/slim recipe_nonWF.slim && mv out.trees recipe_nonWF.${TAG}.trees +$SLIMDIR/slim recipe_WF.slim && mv out.trees recipe_WF.${TAG}.trees +$SLIMDIR/slim recipe_WF_X.slim && mv out.trees recipe_WF_X.${TAG}.trees +$SLIMDIR/slim recipe_WF_Y.slim && mv out.trees recipe_WF_Y.${TAG}.trees +$SLIMDIR/slim recipe_all_the_chromosome_types.slim && mv out.trees recipe_all_the_chromosome_types.${TAG}.trees +git add -f recipe_nonWF.${TAG}.trees recipe_WF.${TAG}.trees recipe_WF_X.${TAG}.trees recipe_WF_Y.${TAG}.trees recipe_all_the_chromosome_types.${TAG}.trees + + diff --git a/tests/test_recipes/recipe_WF.slim b/tests/test_recipes/recipe_WF.slim index 25032360..11702500 100644 --- a/tests/test_recipes/recipe_WF.slim +++ b/tests/test_recipes/recipe_WF.slim @@ -10,14 +10,49 @@ initialize() initializeGenomicElementType("g1", m1, 1.0); initializeGenomicElement(g1, 0, 99); initializeRecombinationRate(1e-2); + defineGlobal("MD", Dictionary()); } 1 early() { sim.addSubpop("p1", 10); } +// MUTATION/GENOTYPE INFO +1 first() { // mutation information + MD.setValue("mutations", Dictionary()); +} +mutation() { + muts = MD.getValue("mutations"); + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "originTick", mut.originTick + ); + muts.setValue(asString(mut.id), m); + return T; +} +10 late() { + subs = Dictionary(); + for (mut in sim.substitutions) { + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "fixationTick", mut.fixationTick + ); + subs.setValue(asString(mut.id), m); + } + MD.setValue("substitutions", subs); +} + +// OUTPUT/FINISH 10 late() { - sim.treeSeqOutput(TREES_FILE); + sim.treeSeqOutput(TREES_FILE, metadata=MD); catn("Done."); sim.simulationFinished(); } diff --git a/tests/test_recipes/recipe_WF.v5.2.trees b/tests/test_recipes/recipe_WF.v5.2.trees new file mode 100644 index 0000000000000000000000000000000000000000..936f1cd1e6108b42d3485d5b7d086e9e6954f606 GIT binary patch literal 36020 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 L%?uXvJ=6aeKf~fK literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_WF_X.v5.2.trees b/tests/test_recipes/recipe_WF_X.v5.2.trees new file mode 100644 index 0000000000000000000000000000000000000000..d09e122b71d6b273ca7e36590ec7b532794efdce GIT binary patch literal 134436 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 literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_HF.trees b/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_HF.trees new file mode 100644 index 0000000000000000000000000000000000000000..473023a98775eed4c72ee8ad77dcd7263d527394 GIT binary patch literal 47996 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 ZgQc;xg|XS0wNq1t;jyVfCV|5<{|O?iJiGt^ literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_HM.trees b/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_HM.trees new file mode 100644 index 0000000000000000000000000000000000000000..1fb4b3f5537efd3d98e71c0b97aeb74783f5b434 GIT binary patch literal 47068 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 Vmp$DFd)Xa+wp5xKoSPn+{(o&7lDq%_ literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_ML.trees b/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_ML.trees new file mode 100644 index 0000000000000000000000000000000000000000..09bd52f42870d6db1acb1c50fc05ad67ec0c95f5 GIT binary patch literal 38284 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 bb(L|ld)Iuq)ZaB<%+KYAivt60f9d}LRtL{R literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_W.trees b/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_W.trees new file mode 100644 index 0000000000000000000000000000000000000000..740a592db2bbe9e71ea9ba50789d42a6bd692402 GIT binary patch literal 40676 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 literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_Z.trees b/tests/test_recipes/recipe_all_the_chromosome_types.v5.2.trees/chromosome_Z.trees new file mode 100644 index 0000000000000000000000000000000000000000..252e4426ec9c5017bd25642df8a58c4722a63c75 GIT binary patch literal 61932 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 literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_chromosomes_adds_muts.slim b/tests/test_recipes/recipe_chromosomes_adds_muts.slim index e53e7332..2ffd3299 100644 --- a/tests/test_recipes/recipe_chromosomes_adds_muts.slim +++ b/tests/test_recipes/recipe_chromosomes_adds_muts.slim @@ -20,10 +20,11 @@ initialize() { for (id in ids, symbol in symbols, type in types) { initializeChromosome(id, length, type, symbol); - initializeAncestralNucleotides(paste0(rep("A", length))); + initializeAncestralNucleotides(randomNucleotides(length)); initializeRecombinationRate(1e-5); initializeGenomicElement(g1, 0, length-1); } + defineGlobal("MD", Dictionary()); } 1 early() { sim.addSubpop("p1", 10); @@ -34,13 +35,68 @@ initialize() { inds = p1.individuals; haps = inds.haplosomesForChromosomes(chrom, includeNulls=F); if (length(haps) > 0) { - sample(haps, 1 + asInteger(length(haps)/2)).addNewDrawnMutation(m2, rdunif(10, 0, chrom.length-1)); + mutl = sample(haps, 1 + asInteger(length(haps)/2)).addNewDrawnMutation(m2, rdunif(10, 0, chrom.length-1)); + muts = MD.getValue("mutations"); + for (mut in mutl) { + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "originTick", mut.originTick + ); + muts.setValue(asString(mut.id), m); + } } } } + +// MUTATION/GENOTYPE INFO +1 first() { // reference sequences + refseqs = Dictionary(); + for (chrom in sim.chromosomes) { + refseqs.setValue("chr" + chrom.id, chrom.ancestralNucleotides()); + } + MD.setValue("reference_sequence", refseqs); +} + +1 first() { // mutation information + MD.setValue("mutations", Dictionary()); +} +mutation() { + muts = MD.getValue("mutations"); + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "originTick", mut.originTick + ); + muts.setValue(asString(mut.id), m); + return T; +} +10 late() { + subs = Dictionary(); + for (mut in sim.substitutions) { + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "fixationTick", mut.fixationTick + ); + subs.setValue(asString(mut.id), m); + } + MD.setValue("substitutions", subs); +} + +// OUTPUT/FINISH 10 late() { - sim.treeSeqOutput(TREES_FILE); + sim.treeSeqOutput(TREES_FILE, metadata=MD); catn("Done."); sim.simulationFinished(); } diff --git a/tests/test_recipes/recipe_no_simplify.slim b/tests/test_recipes/recipe_no_simplify.slim new file mode 100644 index 00000000..ceb49b0a --- /dev/null +++ b/tests/test_recipes/recipe_no_simplify.slim @@ -0,0 +1,22 @@ +initialize() +{ + setSeed(23); + if (!exists("TREES_FILE")) defineGlobal("TREES_FILE", "out.trees"); + initializeSLiMOptions(keepPedigrees=T); + initializeTreeSeq(simplificationRatio=INF, timeUnit="generations"); + initializeMutationRate(1e-2); + initializeMutationType("m1", 0.5, "f", -0.1); + initializeGenomicElementType("g1", m1, 1.0); + initializeGenomicElement(g1, 0, 99); + initializeRecombinationRate(1e-2); +} + +1 early() { + sim.addSubpop("p1", 10); +} + +10 late() { + sim.treeSeqOutput(TREES_FILE, simplify=F); + catn("Done."); + sim.simulationFinished(); +} diff --git a/tests/test_recipes/recipe_nonWF.slim b/tests/test_recipes/recipe_nonWF.slim index 72727ad8..7174a853 100644 --- a/tests/test_recipes/recipe_nonWF.slim +++ b/tests/test_recipes/recipe_nonWF.slim @@ -12,6 +12,7 @@ initialize() initializeGenomicElement(g1, 0, 99); initializeRecombinationRate(1e-2); defineConstant("K", 10); + defineGlobal("MD", Dictionary()); } reproduction() { @@ -26,8 +27,43 @@ early() { p1.fitnessScaling = K / p1.individualCount; } + +// MUTATION/GENOTYPE INFO +1 first() { // mutation information + MD.setValue("mutations", Dictionary()); +} +mutation() { + muts = MD.getValue("mutations"); + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "originTick", mut.originTick + ); + muts.setValue(asString(mut.id), m); + return T; +} +10 late() { + subs = Dictionary(); + for (mut in sim.substitutions) { + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "fixationTick", mut.fixationTick + ); + subs.setValue(asString(mut.id), m); + } + MD.setValue("substitutions", subs); +} + +// OUTPUT/FINISH 10 late() { - sim.treeSeqOutput(TREES_FILE); + sim.treeSeqOutput(TREES_FILE, metadata=MD); catn("Done."); sim.simulationFinished(); } diff --git a/tests/test_recipes/recipe_nonWF.v5.2.trees b/tests/test_recipes/recipe_nonWF.v5.2.trees new file mode 100644 index 0000000000000000000000000000000000000000..137d2b0325285ecc3a7330110074153fae35a1f7 GIT binary patch literal 25044 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 literal 0 HcmV?d00001 diff --git a/tests/test_recipes/recipe_nucleotides_WF.slim b/tests/test_recipes/recipe_nucleotides_WF.slim index 29baf777..32b30e9b 100644 --- a/tests/test_recipes/recipe_nucleotides_WF.slim +++ b/tests/test_recipes/recipe_nucleotides_WF.slim @@ -12,14 +12,57 @@ initialize() { initializeGenomicElementType("g1", m1, 1.0, mmJukesCantor(4e-2)); initializeGenomicElement(g1, 0, L-1); initializeRecombinationRate(1e-2); + defineGlobal("MD", Dictionary()); } 1 early() { sim.addSubpop("p1", 10); } +// MUTATION/GENOTYPE INFO +1 first() { // reference sequences + refseqs = Dictionary(); + for (chrom in sim.chromosomes) { + refseqs.setValue("chr" + chrom.id, chrom.ancestralNucleotides()); + } + MD.setValue("reference_sequence", refseqs); +} + +1 first() { // mutation information + MD.setValue("mutations", Dictionary()); +} +mutation() { + muts = MD.getValue("mutations"); + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "originTick", mut.originTick + ); + muts.setValue(asString(mut.id), m); + return T; +} +10 late() { + subs = Dictionary(); + for (mut in sim.substitutions) { + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "fixationTick", mut.fixationTick + ); + subs.setValue(asString(mut.id), m); + } + MD.setValue("substitutions", subs); +} + +// OUTPUT/FINISH 10 late() { - sim.treeSeqOutput(TREES_FILE); + sim.treeSeqOutput(TREES_FILE, metadata=MD); catn("Done."); sim.simulationFinished(); } diff --git a/tests/test_recipes/recipe_nucleotides_nonWF.slim b/tests/test_recipes/recipe_nucleotides_nonWF.slim index 0ae0dc03..5896984f 100644 --- a/tests/test_recipes/recipe_nucleotides_nonWF.slim +++ b/tests/test_recipes/recipe_nucleotides_nonWF.slim @@ -12,6 +12,7 @@ initialize() { initializeGenomicElement(g1, 0, L-1); initializeRecombinationRate(1e-2); defineConstant("K", 10); + defineGlobal("MD", Dictionary()); } reproduction() { @@ -26,8 +27,51 @@ early() { p1.fitnessScaling = K / p1.individualCount; } + +// MUTATION/GENOTYPE INFO +1 first() { // reference sequences + refseqs = Dictionary(); + for (chrom in sim.chromosomes) { + refseqs.setValue("chr" + chrom.id, chrom.ancestralNucleotides()); + } + MD.setValue("reference_sequence", refseqs); +} + +1 first() { // mutation information + MD.setValue("mutations", Dictionary()); +} +mutation() { + muts = MD.getValue("mutations"); + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "originTick", mut.originTick + ); + muts.setValue(asString(mut.id), m); + return T; +} +10 late() { + subs = Dictionary(); + for (mut in sim.substitutions) { + nuc = mut.mutationType.nucleotideBased ? mut.nucleotide else "N"; + m = Dictionary( + "chromosome_id", mut.chromosome.id, + "mutationType", mut.mutationType.id, + "nucleotide", nuc, + "position", mut.position, + "fixationTick", mut.fixationTick + ); + subs.setValue(asString(mut.id), m); + } + MD.setValue("substitutions", subs); +} + +// OUTPUT/FINISH 10 late() { - sim.treeSeqOutput(TREES_FILE); + sim.treeSeqOutput(TREES_FILE, metadata=MD); catn("Done."); sim.simulationFinished(); } diff --git a/tests/test_recipes/recipe_with_traits.slim b/tests/test_recipes/recipe_with_traits.slim new file mode 100644 index 00000000..72602d26 --- /dev/null +++ b/tests/test_recipes/recipe_with_traits.slim @@ -0,0 +1,101 @@ +initialize() { + setSeed(23); + if (!exists("TREES_FILE")) defineGlobal("TREES_FILE", "out.trees"); + initializeSLiMOptions(keepPedigrees=T); + initializeTreeSeq(timeUnit="generations"); + defineConstant("I1", 5.0); + defineConstant("I2", -5.0); + defineConstant("OPT1", 10.0); + defineConstant("OPT2", 10.0); + defineConstant("SD1", 2.0); + defineConstant("SD2", 2.0); + + initializeSex(); + + popgen1T = initializeTrait("popgen1T", "m", 1.0, 0.0, 0.01, directFitnessEffect=T); + popgen2T = initializeTrait("popgen2T", "m", 1.0, 0.0, 0.01, directFitnessEffect=T); + n1T = initializeTrait("n1T", "m", directFitnessEffect=T); + n2T = initializeTrait("n2T", "m", directFitnessEffect=F); + + quant1T = initializeTrait("quant1T", "a", I1, 0.0, 0.01, directFitnessEffect=F); + quant2T = initializeTrait("quant2T", "a", I2, 0.0, 0.01, directFitnessEffect=F); + n3T = initializeTrait("n3T", "a", directFitnessEffect=F, baselineAccumulation=F); + + + logistic1T = initializeTrait("logistic1T", "l", 0.0, 0.01, 0.01, directFitnessEffect=T); + initializeMutationType("m1", 0.4, "f", 0.0); + initializeMutationType("m2", 0.4, "e", 0.05); + m2.setEffectSizeDistributionForTrait(c(n1T, n2T), "f", 0.0); + m2.setEffectSizeDistributionForTrait(c(quant1T, quant2T), "n", 0.0, 0.1); + m2.setEffectSizeDistributionForTrait(c(logistic1T), "n", -0.05, 0.1); + + initializeMutationType("m3", 0.4, "g", -0.05, 1.0); + m3.setEffectSizeDistributionForTrait(c(n1T, n2T), "f", 0.0); + m3.setEffectSizeDistributionForTrait(c(quant1T, quant2T), "n", 0.0, 0.1); + m3.setEffectSizeDistributionForTrait(c(logistic1T), "n", -0.05, 0.1); + + c(m2,m3).setEffectSizeDistributionForTrait(n3T, "n", -5.0, 0.5); + + c(m2,m3).setDefaultDominanceForTrait(c(popgen2T, quant2T), NAN); + + c(m2,m3).logMutationData(T, trait=NULL, effectSize=T, dominance=T); + + initializeGenomicElementType("g1", m1, 1.0); + initializeGenomicElementType("g2", 1:3, c(3, 1, 2)); + + ids = 1:5; + symbols = c(1, 2, "X", "Y", "MT"); + lengths = rdunif(5, 1e7, 2e7); + types = c("A", "A", "X", "Y", "H"); + names = c("A1", "A2", "X", "Y", "MT"); + + for (id in ids, symbol in symbols, length in lengths, type in types, name in names) + { + initializeChromosome(id, length, type, symbol, name); + initializeMutationRate(1e-7); + initializeRecombinationRate(1e-8); + + if (id == 1) + initializeGenomicElement(g1); // autosome 1 is pure-neutral, using only m1 + else + initializeGenomicElement(g2); // autosome 2 is a mix, using m1 / m2 / m3 + } +} + +mutation(m2) { + // set random dominance effects for the popgen1T and quant1T and logistic1TDominance traits + // other effects are generated as specified by the mutation type DES + mut.popgen1TDominance = runif(1); + mut.quant1TDominance = runif(1); + mut.logistic1TDominance = runif(1); + return T; +} +mutation(m3) { + // set random dominance effects for the popgen1T and quant1T and logistic1TDominance traits + // other effects are generated as specified by the mutation type DES + mut.popgen1TDominance = runif(1); + mut.quant1TDominance = runif(1); + mut.logistic1TDominance = runif(1); + return T; +} + +1 late() { + sim.addSubpop("p1", 20); +} + +1: late() { + inds = sim.subpopulations.individuals; + sim.demandPhenotype(NULL, c(sim.quant1T, sim.quant2T)); + phenotypes_q1 = inds.quant1T; + phenotypes_q2 = inds.quant2T; + fitnessEffect_q1 = dnorm(phenotypes_q1, OPT1, SD1) / dnorm(0.0, 0.0, SD1); + fitnessEffect_q2 = dnorm(phenotypes_q2, OPT2, SD2) / dnorm(0.0, 0.0, SD2); + inds.fitnessScaling = fitnessEffect_q1 * fitnessEffect_q2; +} + + +10 late() { + sim.treeSeqOutput(TREES_FILE); + catn("Done."); + sim.simulationFinished(); +} diff --git a/tests/test_tree_sequence.py b/tests/test_tree_sequence.py index 57f9b0ea..18a96111 100644 --- a/tests/test_tree_sequence.py +++ b/tests/test_tree_sequence.py @@ -2,6 +2,7 @@ Test cases for tree sequences. """ +import copy import json import random import sys @@ -17,6 +18,18 @@ from .recipe_specs import recipe_eq, restarted_recipe_eq +def run_with_ts_metadata(f, ts_metadata, *args, **kwargs): + # check for equality in a method with and without passing in + # the ts_metadata argument + a = f(*args, **kwargs) + new_kwargs = copy.deepcopy(kwargs) + new_kwargs["ts_metadata"] = ts_metadata + b = f(*args, **new_kwargs) + assert len(a) == len(b) + np.testing.assert_equal(a, b) + return a + + def mutations_above(ts, node, pos): for s in ts.sites(): if s.position == pos: @@ -42,26 +55,65 @@ def naive_mutation_at(ts, node, pos, time=None): return mut_id +def verify_mutation_metadata(ts): + # Verify that all derived states are properly accounted for + # in mutation metadata. + mdl = ts.metadata["SLiM_mutation_list"] + mut_info = {str(mut["mutation_id"]): mut for mut in mdl} + assert len(mut_info) == len(mdl) + for mut in ts.mutations(): + for j in mut.derived_state.split(","): + assert j in mut_info + + +class TestMutationMetadata(tests.PyslimTestCase): + @pytest.mark.parametrize("recipe", recipe_eq("multichrom"), indirect=True) + def test_mutation_IDs_unique(self, recipe): + ids = set() + for _, ts in recipe["ts"].items(): + mut_info = pyslim.mutation_metadata(ts) + new_ids = set(mut_info.keys()) + assert len(ids.intersection(new_ids)) == 0 + ids = ids.union(new_ids) + + def test_mutation_metadata(self, recipe): + # test that mutation metadata is properly present + for _, ts in recipe["ts"].items(): + verify_mutation_metadata(ts) + + @pytest.mark.parametrize("recipe", [next(recipe_eq())], indirect=True) + def test_check(self, recipe): + for _, ts in recipe["ts"].items(): + assert ts.num_mutations > 5 + t = ts.dump_tables() + md = t.metadata + del md["SLiM_mutation_list"][5:] + t.metadata = md + ts = t.tree_sequence() + mut_info = pyslim.mutation_metadata(ts, check=False) + assert len(mut_info) == 5 + with pytest.raises(ValueError, match="missing information for mutation"): + _ = pyslim.mutation_metadata(ts) + + class TestSlimTime(tests.PyslimTestCase): # Tests for slim_time() - @pytest.mark.parametrize("recipe", recipe_eq(exclude="long"), indirect=True) + @pytest.mark.parametrize( + "recipe", recipe_eq(exclude=["long", "old_mutations"]), indirect=True + ) def test_slim_time(self, recipe): for _, ts in recipe["ts"].items(): - if "init_mutated" not in recipe: - for mut in ts.mutations(): - mut_time = max( - [x["slim_time"] for x in mut.metadata["mutation_list"]] - ) - assert mut_time == pyslim.slim_time(ts, mut.time) + muts = pyslim.mutation_metadata(ts) # the mutations in "init_mutated" examples have mutations that are *added* # in *early*, and so their times match in that stage. - else: - for mut in ts.mutations(): - mut_time = max( - [x["slim_time"] for x in mut.metadata["mutation_list"]] - ) - assert mut_time == pyslim.slim_time(ts, mut.time, stage="early") + stage = "early" if "init_mutated" in recipe else None + slim_times = pyslim.slim_time(ts, ts.mutations_time, stage=stage) + for t, mut in zip(slim_times, ts.mutations()): + mut_time = max( + [muts[int(j)]["slim_time"] for j in mut.derived_state.split(",")] + ) + assert mut_time == t class TestNextMutationID(tests.PyslimTestCase): @@ -99,12 +151,15 @@ def test_reload_slim(self, recipe, helper_functions, tmp_path): ) next_id = pyslim.next_slim_mutation_id(rts) T = max(1, rts.segregating_sites(mode="branch", span_normalise=False)) - mts = msprime.sim_mutations( - rts, - rate=max(6e-4, 10 / T), - keep=True, - model=msprime.SLiMMutationModel(type=1, next_id=next_id), - random_seed=135, + mts = pyslim.add_mutation_metadata( + msprime.sim_mutations( + rts, + rate=max(6e-4, 10 / T), + keep=True, + model=msprime.SLiMMutationModel(type=1, next_id=next_id), + random_seed=135, + ), + mutation_type=1, ) assert mts.num_mutations > rts.num_mutations recapped[chrom] = mts @@ -127,11 +182,16 @@ def test_reload_slim(self, recipe, helper_functions, tmp_path): assert chrom in recipe["ts"] assert pyslim.next_slim_mutation_id(mts) == pyslim.next_slim_mutation_id(ts) assert ts.num_mutations == recapped[chrom].num_mutations - a = ts.metadata - a["SLiM"].pop("user_metadata", None) - b = recipe["ts"][chrom].metadata - b["SLiM"].pop("user_metadata", None) - assert a == b + ots = recipe["ts"][chrom] + assert ts.metadata["SLiM"] == ots.metadata["SLiM"] + mut_info = pyslim.mutation_metadata(ts) + omut_info = pyslim.mutation_metadata(ots) + assert len(mut_info) == len(ts.metadata["SLiM_mutation_list"]) + assert len(omut_info) == len(ots.metadata["SLiM_mutation_list"]) + # we've added new mutations but originals should all be there + for k in omut_info: + assert k in mut_info + assert omut_info[k] == mut_info[k] def test_invalid_derived_state(self): ts = msprime.sim_ancestry( @@ -156,10 +216,12 @@ class TestRecapitate(tests.PyslimTestCase): """ def check_recap_consistency(self, ts, recap, with_ancestral_Ne=True): - assert ts.metadata["SLiM"]["tick"] == recap.metadata["SLiM"]["tick"] - assert ts.metadata["SLiM"]["cycle"] == recap.metadata["SLiM"]["cycle"] - assert ts.metadata["SLiM"]["stage"] == recap.metadata["SLiM"]["stage"] - assert ts.metadata["SLiM"]["name"] == recap.metadata["SLiM"]["name"] + tsmd = ts.metadata + remd = recap.metadata + assert tsmd["SLiM"]["tick"] == remd["SLiM"]["tick"] + assert tsmd["SLiM"]["cycle"] == remd["SLiM"]["cycle"] + assert tsmd["SLiM"]["stage"] == remd["SLiM"]["stage"] + assert tsmd["SLiM"]["name"] == remd["SLiM"]["name"] assert all(tree.num_roots == 1 for tree in recap.trees()) assert ts.has_reference_sequence() == recap.has_reference_sequence() if ts.has_reference_sequence(): @@ -264,6 +326,7 @@ def test_unique_names(self): assert names[0] == "ancestral" assert names[-2] == "ancestral_ancestral" + @pytest.mark.parametrize("recipe", recipe_eq(exclude="no_simplify"), indirect=True) def test_recapitation(self, recipe): for _, ts in recipe["ts"].items(): recomb_rate = 1.0 / ts.sequence_length @@ -282,7 +345,9 @@ def test_recapitation(self, recipe): assert t.num_roots == 1 assert recap.node(t.root).time >= old_root_time - @pytest.mark.parametrize("recipe", recipe_eq(exclude="long"), indirect=True) + @pytest.mark.parametrize( + "recipe", recipe_eq(exclude=["long", "no_simplify"]), indirect=True + ) def test_with_recomb_map(self, recipe): for _, ts in recipe["ts"].items(): recomb_rate = 1.0 / ts.sequence_length @@ -333,9 +398,10 @@ def test_first_gen_nodes(self, recipe): # (note this will fail if some populations were started at different # times than others or if the tick has been changed) for _, ts in recipe["ts"].items(): - root_time = ts.metadata["SLiM"]["tick"] - is_wf = ts.metadata["SLiM"]["model_type"] == "WF" - remembered_stage = ts.metadata["SLiM"]["stage"] + tsmd = ts.metadata + root_time = tsmd["SLiM"]["tick"] + is_wf = tsmd["SLiM"]["model_type"] == "WF" + remembered_stage = tsmd["SLiM"]["stage"] if (not is_wf) or (remembered_stage != "late"): root_time -= 1 if (not is_wf) and ("begun_first" in recipe): @@ -344,12 +410,10 @@ def test_first_gen_nodes(self, recipe): root_time -= 1 if is_wf and ("begun_late" in recipe): root_time -= 1 + vacant = pyslim.nodes_vacant(ts) for t in ts.trees(): for u in t.roots: - assert ( - pyslim.node_is_vacant(ts, ts.node(u)) - or ts.node(u).time == root_time - ) + assert vacant[u] or ts.node(u).time == root_time class TestIndividualAges(tests.PyslimTestCase): @@ -384,14 +448,23 @@ def test_mismatched_remembered_stage(self, recipe): def test_population(self, recipe): for _, ts in recipe["ts"].items(): individual_populations = ts.individuals_population - all_inds = pyslim.individuals_alive_at(ts, 0) + ts_metadata = ts.metadata + all_inds = run_with_ts_metadata( + pyslim.individuals_alive_at, ts_metadata, ts, 0 + ) assert len(all_inds) > 0 for p in range(ts.num_populations): - sub_inds = pyslim.individuals_alive_at(ts, 0, population=p) + sub_inds = pyslim.individuals_alive_at( + ts, 0, population=p, ts_metadata=ts_metadata + ) assert set(sub_inds) == set(all_inds[individual_populations == p]) - sub_inds = pyslim.individuals_alive_at(ts, 0, population=[p]) + sub_inds = pyslim.individuals_alive_at( + ts, 0, population=[p], ts_metadata=ts_metadata + ) assert set(sub_inds) == set(all_inds[individual_populations == p]) - sub_inds = pyslim.individuals_alive_at(ts, 0, population=np.arange(p)) + sub_inds = pyslim.individuals_alive_at( + ts, 0, population=np.arange(p), ts_metadata=ts_metadata + ) assert set(sub_inds) == set(all_inds[individual_populations != p]) @pytest.mark.parametrize( @@ -399,9 +472,14 @@ def test_population(self, recipe): ) def test_samples_only(self, recipe): for _, ts in recipe["ts"].items(): - all_inds = pyslim.individuals_alive_at(ts, 0) + ts_metadata = ts.metadata + all_inds = run_with_ts_metadata( + pyslim.individuals_alive_at, ts_metadata, ts, 0 + ) assert set(all_inds) == set( - pyslim.individuals_alive_at(ts, 0, samples_only=False) + pyslim.individuals_alive_at( + ts, 0, samples_only=False, ts_metadata=ts_metadata + ) ) sub_inds = np.random.choice( all_inds, size=min(len(all_inds), 4), replace=False @@ -440,18 +518,19 @@ def test_after_simplify(self, recipe): @pytest.mark.parametrize("recipe", recipe_eq("pedigree"), indirect=True) def test_ages(self, recipe): for _, ts in recipe["ts"].items(): + ts_metadata = ts.metadata info = recipe["info"] remembered_stage = "late" if "remembered_first" in recipe: remembered_stage = "first" elif "remembered_early" in recipe: remembered_stage = "early" - assert remembered_stage == ts.metadata["SLiM"]["stage"] - max_time_ago = ts.metadata["SLiM"]["tick"] + assert remembered_stage == ts_metadata["SLiM"]["stage"] + max_time_ago = ts_metadata["SLiM"]["tick"] if remembered_stage in ("first", "early"): max_time_ago -= 1 for time in range(0, max_time_ago): - slim_tick = ts.metadata["SLiM"]["tick"] - time + slim_tick = ts_metadata["SLiM"]["tick"] - time check_stages = ("first", "early", "late") if time == 0: if remembered_stage == "first": @@ -470,10 +549,18 @@ def test_ages(self, recipe): check_stages = ("late",) for stage in check_stages: alive = pyslim.individuals_alive_at( - ts, time, stage=stage, remembered_stage=remembered_stage + ts, + time, + stage=stage, + remembered_stage=remembered_stage, + ts_metadata=ts_metadata, ) ages = pyslim.individual_ages_at( - ts, time, stage=stage, remembered_stage=remembered_stage + ts, + time, + stage=stage, + remembered_stage=remembered_stage, + ts_metadata=ts_metadata, ) for ind in ts.individuals(): ind_time = ts.node(ind.nodes[0]).time @@ -489,7 +576,7 @@ def test_ages(self, recipe): assert slim_alive == pyslim_alive if slim_alive: slim_age = info[slim_id]["age"][(slim_tick, stage)] - if ts.metadata["SLiM"]["model_type"] == "WF": + if ts_metadata["SLiM"]["model_type"] == "WF": # SLiM records -1 but we return 0 in late and 1 in early slim_age = 0 + (stage in ("first", "early")) assert ages[ind.id] == slim_age @@ -595,9 +682,10 @@ def test_post_simplify(self, recipe): for _, ts in recipe["ts"].items(): rng = np.random.default_rng(seed=3) individual_times = ts.individuals_time + md_tick = ts.metadata["SLiM"]["tick"] keep_indivs = rng.choice( # assumes tick hasn't been changed - np.where(individual_times < ts.metadata["SLiM"]["tick"] - 1)[0], + np.where(individual_times < md_tick - 1)[0], size=30, replace=False, ) @@ -694,14 +782,66 @@ def test_pedigree_parents(self, recipe): gfolks = [] for a in set(info[sid]["parents"]) - set(ts_p): gfolks.extend(info[a]["parents"]) - print("===== ", hasp, ind) - print("sid: ", sid, "ts_p: ", ts_p) - print("slim_p: ", slim_p) - print(gfolks) + # print("===== ", hasp, ind) + # print("sid: ", sid, "ts_p: ", ts_p) + # print("slim_p: ", slim_p) + # print(gfolks) for a in set(ts_p) - set(slim_p): assert a in gfolks +class TestMutationConsistency(tests.PyslimTestCase): + """ + Test for consistency between what SLiM has written down in top-level metadata + and what's in the tree sequence + """ + + @pytest.mark.parametrize("recipe", recipe_eq("refseq"), indirect=True) + def test_reference_sequence_consistency(self, recipe): + for n, ts in recipe["ts"].items(): + tsmd = ts.metadata + chrom_id = tsmd["SLiM"]["this_chromosome"]["id"] + assert ts.has_reference_sequence() + ref = list( + tsmd["SLiM"]["user_metadata"]["reference_sequence"][0][f"chr{chrom_id}"][ + 0 + ] + ) + ts_ref = ts.reference_sequence.data + subs = [ + x[0] + for x in tsmd["SLiM"]["user_metadata"]["substitutions"][0].values() + if x[0]["chromosome_id"][0] == chrom_id + ] + subs.sort(key=lambda x: (x["position"][0], x["fixationTick"][0])) + for s in subs: + nuc = s["nucleotide"][0] + if nuc != "N": + ref[s["position"][0]] = nuc + assert ts_ref == "".join(ref) + + @pytest.mark.parametrize("recipe", recipe_eq("record_mutations"), indirect=True) + def test_mutation_consistency(self, recipe): + for n, ts in recipe["ts"].items(): + tsmd = ts.metadata + chrom_id = tsmd["SLiM"]["this_chromosome"]["id"] + # this is just making these things not lists, mostly + debug_info = { + int(k): {x: y[0] for x, y in v[0].items()} + for k, v in tsmd["SLiM"]["user_metadata"]["mutations"][0].items() + } + mut_info = pyslim.mutation_metadata(ts) + for mut in ts.mutations(): + for k in mut.derived_state.split(","): + k = int(k) + assert k in debug_info or mut_info[k]["mutation_id"] == 2 + assert k in mut_info + assert debug_info[k]["chromosome_id"] == chrom_id + assert debug_info[k]["position"] == ts.site(mut.site).position + assert debug_info[k]["mutationType"] == mut_info[k]["mutation_type"] + assert debug_info[k]["originTick"] == mut_info[k]["slim_time"] + + class TestReferenceSequence(tests.PyslimTestCase): """ Test for operations involving the reference sequence @@ -749,6 +889,7 @@ def test_nucleotide_at_errors(self, recipe): def test_mutation_at(self, recipe): rng = random.Random(42) for _, ts in recipe["ts"].items(): + L = int(min(50000, ts.sequence_length)) for _ in range(min(10, ts.num_sites)): site = rng.choice(ts.sites()) pos = site.position @@ -766,10 +907,8 @@ def test_mutation_at(self, recipe): a = pyslim.mutation_at(ts, node, pos, time=time) b = naive_mutation_at(ts, node, pos, time=time) assert a == b - for _ in range(min(10, int(ts.sequence_length - ts.num_sites))): - pos = rng.choice( - list(set(range(int(ts.sequence_length))) - set(ts.sites_position)) - ) + for _ in range(min(10, int(L - ts.num_sites))): + pos = rng.choice(list(set(range(L)) - set(ts.sites_position))) tree = ts.at(pos) for _ in range(10): node = rng.randint(0, ts.num_nodes - 1) @@ -778,33 +917,63 @@ def test_mutation_at(self, recipe): for time in [None, ts.node(node).time, ut]: assert naive_mutation_at(ts, node, pos, time=time) == -1 + @pytest.mark.parametrize("recipe", recipe_eq("nucleotides"), indirect=True) def test_nucleotide_at(self, recipe): random.seed(42) for _, ts in recipe["ts"].items(): if ts.num_mutations > 0: + mut_metadata = pyslim.mutation_metadata(ts) mut_md = ts.mutation(0).metadata - has_nucleotides = mut_md["mutation_list"][0]["nucleotide"] >= 0 - if has_nucleotides: - assert ts.has_reference_sequence() - assert len(ts.reference_sequence.data) == ts.sequence_length - for _ in range(100): - node = random.randint(0, ts.num_nodes - 1) - pos = random.randint(0, int(ts.sequence_length) - 1) - tree = ts.at(pos) - parent = tree.parent(node) - a = pyslim.nucleotide_at(ts, node, pos) - if parent == tskit.NULL: - nuc = ts.reference_sequence.data[int(pos)] - assert a == pyslim.NUCLEOTIDES.index(nuc) - else: - b = pyslim.nucleotide_at(ts, parent, pos) - c = pyslim.nucleotide_at(ts, node, pos, ts.node(parent).time) - assert b == c - for k in np.where(node == ts.tables.mutations.node)[0]: - mut = ts.mutation(k) - if ts.site(mut.site).position == pos: - b = mut.metadata["mutation_list"][0]["nucleotide"] - assert a == b + tsmd = ts.metadata + # check we've got nucleotide mutations + nucs = np.array([x["nucleotide"] for x in tsmd["SLiM_mutation_list"]]) + assert np.sum(nucs >= 0) > 1 + mut_info = { + str(mut["mutation_id"]): mut for mut in tsmd["SLiM_mutation_list"] + } + assert ts.has_reference_sequence() + assert len(ts.reference_sequence.data) == ts.sequence_length + for _ in range(100): + node = random.randint(0, ts.num_nodes - 1) + pos = random.randint(0, int(ts.sequence_length) - 1) + tree = ts.at(pos) + parent = tree.parent(node) + a = pyslim.nucleotide_at(ts, node, pos) + if parent == tskit.NULL: + nuc = ts.reference_sequence.data[int(pos)] + assert a == pyslim.NUCLEOTIDES.index(nuc) + else: + b = pyslim.nucleotide_at( + ts, parent, pos, mut_metadata=mut_metadata + ) + c = pyslim.nucleotide_at( + ts, + node, + pos, + ts.node(parent).time, + mut_metadata=mut_metadata, + ) + assert b == c + for k in np.where(node == ts.tables.mutations.node)[0]: + mut = ts.mutation(k) + if ts.site(mut.site).position == pos: + b = mut_info[mut.derived_state.split(",")[0]][ + "nucleotide" + ] + assert a == b + + @pytest.mark.parametrize("recipe", [next(recipe_eq("nucleotides"))], indirect=True) + def test_nucleotide_at_without_mut_metadata(self, recipe): + random.seed(23) + for _, ts in recipe["ts"].items(): + assert ts.num_mutations > 0 + mut_metadata = pyslim.mutation_metadata(ts) + for _ in range(100): + node = random.randint(0, ts.num_nodes - 1) + pos = random.randint(0, int(ts.sequence_length) - 1) + a = pyslim.nucleotide_at(ts, node, pos) + b = pyslim.nucleotide_at(ts, node, pos, mut_metadata=mut_metadata) + assert a == b @pytest.mark.parametrize("recipe", recipe_eq("mutation_spectrum"), indirect=True) def test_nucleotide_spectrum(self, recipe): @@ -814,6 +983,7 @@ def test_nucleotide_spectrum(self, recipe): # access to the parental genome, so if two adjacent mutations # occur in the same meiosis then each will not know about the other. for _, ts in recipe["ts"].items(): + mut_info = pyslim.mutation_metadata(ts) mutation_spectrum = recipe["mutation_info"] M = { a + b + c + "," + d: 0 @@ -827,7 +997,7 @@ def test_nucleotide_spectrum(self, recipe): pos = ts.site(mut.site).position if pos > 0 and pos < ts.sequence_length - 1: nmuts += 1 - mut_list = mut.metadata["mutation_list"] + mut_list = [mut_info[int(k)] for k in mut.derived_state.split(",")] k = np.argmax([u["slim_time"] for u in mut_list]) derived_nuc = mut_list[k]["nucleotide"] left_nuc = pyslim.nucleotide_at( @@ -847,13 +1017,8 @@ def test_nucleotide_spectrum(self, recipe): ) key = context + "," + pyslim.NUCLEOTIDES[derived_nuc] M[key] += 1 - if key == "ACA,T" or key == "CCA,T": - print(key, pos, mut.node, mut.time) assert sum([M[k] for k in M]) == nmuts assert sum([mutation_spectrum[k][0] for k in mutation_spectrum]) == nmuts - for k in M: - if M[k] != mutation_spectrum[k][0]: - print(k, M[k], mutation_spectrum[k]) for k in M: assert len(mutation_spectrum[k]) == 1 assert M[k] == mutation_spectrum[k][0] @@ -869,12 +1034,12 @@ def last_slim_mutations(self, ts): # (slim id, slim mutation metadata) of the slim mutation that is the # *most recent* one of any possibly stacked mutations. Note that it # is possible that this is ambiguous. + mut_info = pyslim.mutation_metadata(ts) for mut in ts.mutations(): slim_muts = { k: v - for k, v in zip( - mut.derived_state.split(","), mut.metadata["mutation_list"] - ) + for k, v in mut_info.items() + if str(k) in mut.derived_state.split(",") } if mut.parent == tskit.NULL: parent_slim_ids = [] @@ -938,10 +1103,7 @@ def scramble_mutations(self, ts): for m in ts.mutations(): a = np.array(m.derived_state.split(",")) ii = rng.permutation(len(a)) - ml = [m.metadata["mutation_list"][i] for i in ii] - t.mutations.append( - m.replace(derived_state=",".join(a[ii]), metadata={"mutation_list": ml}) - ) + t.mutations.append(m.replace(derived_state=",".join(a[ii]))) t.compute_mutation_parents() return t.tree_sequence() @@ -956,6 +1118,7 @@ def test_convert_alleles_errors(self): ts, model=msprime.SLiMMutationModel(type=1), rate=0.1, random_seed=23 ) assert mts.num_mutations > 0 + mts = pyslim.add_mutation_metadata(mts) mtt = mts.dump_tables() mtt.reference_sequence.data = "A" * int(mts.sequence_length) mts = mtt.tree_sequence() @@ -967,6 +1130,7 @@ def test_convert_alleles_errors(self): ) def test_convert_alleles(self, recipe): for _, ts in recipe["ts"].items(): + verify_mutation_metadata(ts) cts = pyslim.convert_alleles(ts) self.verify_converted_nucleotides(ts, cts) @@ -1021,18 +1185,19 @@ def test_generate_nucleotides_errors(self): def verify_generate_nucleotides(self, ts, check_transitions=False): # if check_transitions is True, verify that derived states differ # from parental states - which we try to do but is not guaranteed, - # for instance, if keep=True or in other weird situations. + # for instance, if keep=True, there was more than one mutation in + # single generation, or in other weird situations. assert ts.metadata["SLiM"]["nucleotide_based"] assert len(ts.reference_sequence.data) == ts.sequence_length + mut_info = pyslim.mutation_metadata(ts) muts = {} ts_muts = { j: v["nucleotide"] for j, (_, v) in enumerate(self.last_slim_mutations(ts)) } for mut in ts.mutations(): aa = ts.reference_sequence.data[int(ts.site(mut.site).position)] - for i, md in zip( - mut.derived_state.split(","), mut.metadata["mutation_list"] - ): + for i in mut.derived_state.split(","): + md = mut_info[int(i)] nuc = md["nucleotide"] assert nuc in [0, 1, 2, 3] if i in muts: @@ -1042,9 +1207,14 @@ def verify_generate_nucleotides(self, ts, check_transitions=False): if mut.parent == tskit.NULL: assert pyslim.NUCLEOTIDES[nuc] != aa else: - if ts.mutation(mut.parent).derived_state != mut.derived_state: - assert ts_muts[mut.parent] != ts_muts[mut.id] - + mp = ts.mutation(mut.parent) + if mp.derived_state != mut.derived_state: + assert (ts_muts[mut.parent] != ts_muts[mut.id]) or ( + len(mut.derived_state.split(",")) + > 1 + len(mp.derived_state.split(",")) + ) + + @pytest.mark.parametrize("recipe", recipe_eq(exclude="old_mutations"), indirect=True) def test_generate_nucleotides(self, recipe): for _, ts in recipe["ts"].items(): nts = pyslim.generate_nucleotides(ts, keep=False, seed=5) @@ -1061,8 +1231,10 @@ def test_generate_nucleotides_refseq(self): random_seed=10, ) ts = pyslim.annotate(ts, model_type="nonWF", tick=1) - mts = msprime.sim_mutations( - ts, model=msprime.SLiMMutationModel(type=1), rate=0.5, random_seed=23 + mts = pyslim.add_mutation_metadata( + msprime.sim_mutations( + ts, model=msprime.SLiMMutationModel(type=1), rate=0.5, random_seed=23 + ) ) refseq = "A" * int(mts.sequence_length) nts = pyslim.generate_nucleotides(mts, reference_sequence=refseq, seed=6) @@ -1072,35 +1244,42 @@ def test_generate_nucleotides_refseq(self): def test_generate_nucleotides_keep(self): ts = msprime.sim_ancestry(4, sequence_length=10, population_size=10) ts = pyslim.annotate(ts, model_type="nonWF", tick=1) - mts1 = msprime.sim_mutations( - ts, model=msprime.SLiMMutationModel(type=1), rate=0.1, random_seed=23 + mts1 = pyslim.add_mutation_metadata( + msprime.sim_mutations( + ts, model=msprime.SLiMMutationModel(type=1), rate=0.1, random_seed=23 + ) ) - mts1.dump("out.trees") nts1 = pyslim.generate_nucleotides(mts1, seed=10, keep=False) assert nts1.num_mutations > 0 self.verify_generate_nucleotides(nts1, check_transitions=False) - mts2 = msprime.sim_mutations( - nts1, - model=msprime.SLiMMutationModel( - type=2, - next_id=nts1.num_mutations, - ), - rate=0.1, - random_seed=24, + mut_info1 = { + str(mut["mutation_id"]): mut for mut in nts1.metadata["SLiM_mutation_list"] + } + mts2 = pyslim.add_mutation_metadata( + msprime.sim_mutations( + nts1, + model=msprime.SLiMMutationModel( + type=2, + next_id=nts1.num_mutations, + ), + rate=0.1, + random_seed=24, + ) ) # keep defaults to True nts2 = pyslim.generate_nucleotides(mts2, seed=12) assert nts2.num_mutations > nts1.num_mutations + mut_info2 = { + str(mut["mutation_id"]): mut for mut in nts2.metadata["SLiM_mutation_list"] + } muts1 = {} for mut in nts1.mutations(): - for i, md in zip( - mut.derived_state.split(","), mut.metadata["mutation_list"] - ): + for i in mut.derived_state.split(","): + md = mut_info1[i] muts1[i] = md["nucleotide"] for mut in nts2.mutations(): - for i, md in zip( - mut.derived_state.split(","), mut.metadata["mutation_list"] - ): + for i in mut.derived_state.split(","): + md = mut_info2[i] if md["mutation_type"] == 1: assert i in muts1 assert muts1[i] == md["nucleotide"] @@ -1160,8 +1339,9 @@ def get_vacant_samples(self, ts): def verify_remove_vacant(self, ts, rts): vacant_samples = self.get_vacant_samples(ts) - for node in rts.nodes(): - assert not (pyslim.node_is_vacant(rts, node) and (node.is_sample() == 1)) + vacant = pyslim.nodes_vacant(ts) + for v, node in zip(vacant, rts.nodes()): + assert not (v and (node.is_sample() == 1)) assert (node.id in vacant_samples) == ( node.flags & pyslim.NODE_IS_VACANT_SAMPLE > 0 ) @@ -1262,14 +1442,23 @@ def test_has_vacant_msprime(self): tables.nodes.metadata_schema = pyslim.slim_metadata_schemas["node"] assert not pyslim.has_vacant_samples(tables.tree_sequence()) - def test_node_is_vacant(self, recipe): + def test_nodes_vacant(self, recipe): + np.random.seed(123) num_chromosomes = len(recipe["ts"]) for _, ts in recipe["ts"].items(): + test_nodes = np.random.choice( + np.arange(ts.num_nodes), size=min(1, ts.num_nodes), replace=False + ) k = ts.metadata["SLiM"]["this_chromosome"]["index"] - for node in ts.nodes(): + vacant = pyslim.nodes_vacant(ts) + for pv, node in zip(vacant, ts.nodes()): v = self.vacancy_values(node) isv = v is not None and v[k] - assert isv == pyslim.node_is_vacant(ts, node) + assert pv == isv + if node.id in test_nodes: + # node_is_vacant is kinda slow so don't test all of them + with pytest.warns(FutureWarning, match="deprecated"): + assert isv == pyslim.node_is_vacant(ts, node) for j in range(num_chromosomes, len(v)): assert not v[j] @@ -1394,6 +1583,16 @@ def test_no_change(self, restart_name, recipe, helper_functions, tmp_path): ) for chrom, ts in recipe["ts"].items(): self.verify_reset(ts, out_ts[chrom]) + # again!! + out_out_ts = helper_functions.run_slim_restart( + out_ts, + restart_name, + tmp_path, + "multichrom" in recipe, + WF="WF" in recipe, + ) + for chrom, ts in recipe["ts"].items(): + self.verify_reset(ts, out_out_ts[chrom]) @pytest.mark.parametrize( "restart_name, recipe", restarted_recipe_eq("no_op"), indirect=["recipe"] @@ -1425,16 +1624,20 @@ def test_set_individuals( ): in_ts = {} ts = list(recipe["ts"].values())[0] + tsmd = ts.metadata assert ( - "user_metadata" in ts.metadata["SLiM"] - and "reset_tick" in ts.metadata["SLiM"]["user_metadata"] + "user_metadata" in tsmd["SLiM"] + and "reset_tick" in tsmd["SLiM"]["user_metadata"] ), "Simulation not set up for this test." - reset_tick = ts.metadata["SLiM"]["user_metadata"]["reset_tick"][0] + reset_tick = tsmd["SLiM"]["user_metadata"]["reset_tick"][0] if time is None: - for time in range(ts.metadata["SLiM"]["tick"] + 1): - if pyslim.slim_time(ts, time) == reset_tick: - break - individuals = pyslim.individuals_alive_at(ts, time)[:num_indivs] + pytimes = run_with_ts_metadata( + pyslim.slim_time, tsmd, ts, np.arange(tsmd["SLiM"]["tick"] + 1) + ) + time = np.searchsorted(pytimes, reset_tick) + individuals = pyslim.individuals_alive_at(ts, time, ts_metadata=tsmd)[ + :num_indivs + ] for chrom, ts in recipe["ts"].items(): in_ts[chrom] = pyslim.set_slim_state(ts, time=time, individuals=individuals) out_ts = helper_functions.run_slim_restart( diff --git a/uv.lock b/uv.lock index 68ae0211..fbf98b33 100644 --- a/uv.lock +++ b/uv.lock @@ -645,6 +645,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -1844,6 +1853,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "filelock" }, + { name = "frozendict" }, { name = "jupyter-book" }, { name = "matplotlib" }, { name = "msprime" }, @@ -1879,6 +1889,7 @@ packaging = [ ] test = [ { name = "filelock" }, + { name = "frozendict" }, { name = "msprime" }, { name = "pandas" }, { name = "pytest" }, @@ -1891,12 +1902,13 @@ test = [ requires-dist = [ { name = "msprime", specifier = ">=1.0.1" }, { name = "numpy" }, - { name = "tskit" }, + { name = "tskit", specifier = ">=1.0.3" }, ] [package.metadata.requires-dev] dev = [ { name = "filelock" }, + { name = "frozendict" }, { name = "jupyter-book", specifier = "<2" }, { name = "matplotlib" }, { name = "msprime" }, @@ -1932,6 +1944,7 @@ packaging = [ ] test = [ { name = "filelock" }, + { name = "frozendict" }, { name = "msprime" }, { name = "pandas" }, { name = "pytest" }, @@ -2798,23 +2811,38 @@ wheels = [ [[package]] name = "tskit" -version = "0.6.4" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/95/2c2d8bdaae4a3948181de68d1fac0569d9c937a42f7ccfcf097f9a428721/tskit-0.6.4.tar.gz", hash = "sha256:bdac1bb7e3ae3d1f562ec191b5d840156e082dd2adc6af7c41b170c4fb1be792", size = 874772, upload-time = "2025-05-21T18:18:18.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/28/547acef423709fad5b70bbc68332c1fa0a4c1887ec4e76cb93a433a95bbb/tskit-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:510fd219f2c6d5d669e178a3ea1e4cd60d7fc0b40a50fae4859a6a8f35d2394a", size = 748024, upload-time = "2025-05-21T18:17:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/5816ee9ac9708f2e1def387e36b2d0763bdea2267db3ff4a55c17f079e85/tskit-0.6.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b631d38c352b618c3ede33515cf97ffcbc24e3a4252b6979a80b1770434a534", size = 1322885, upload-time = "2025-05-21T18:17:56.958Z" }, - { url = "https://files.pythonhosted.org/packages/bd/db/b98964916b3f9c603f8ad92045db0c050ca4699a976051acdc1368085c3c/tskit-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:e121226092816a1e36b2835aa0348e652c09bdf0604a936f63c4fd0d64ca6422", size = 470677, upload-time = "2025-05-21T18:17:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cf/d2a1c6a6ad29b16310b60bf08dd5b1d1e4e0d23819c49284662373c9fc54/tskit-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8fd5a0c94f302f5fe69a9f7a662b8ddf8219e4dc7325a1990b279afd084bb649", size = 749765, upload-time = "2025-05-21T18:18:00.234Z" }, - { url = "https://files.pythonhosted.org/packages/80/46/1630514e8a9f97a8f75e805a7872fb3083119d7e421309e05e99d5f5ca66/tskit-0.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f086e648a624004343882ca57f9365d5005cc2e472113b61874c9403c14be272", size = 1324515, upload-time = "2025-05-21T18:18:02.461Z" }, - { url = "https://files.pythonhosted.org/packages/07/44/2116904f37ffe1db0e675b4c70b70cdaf7ab38b474cd546f6ff9d46de4b9/tskit-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:507eee5b20c5e47202d90a70143c601aef3b3ec70321b5251c2e6d896b1e0722", size = 470047, upload-time = "2025-05-21T18:18:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d7/5ee3b118281f2a7032f5d38fca0ee7e11bf7cf1823d2dbfa5a991ccf3497/tskit-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:66f07b76f18ad576a7585b6e4d9e46d58994e3df1dd2d1808d32d294354695cf", size = 749773, upload-time = "2025-05-21T18:18:06.48Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6d/6368c2ffbccb4d7ee8d82096f1ad0b3cb0d639b958c408589cceff09600e/tskit-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfb40717382cf47b8844c1f73d73c1e7ca7f63e6a506e7f859f0980667677de2", size = 1324529, upload-time = "2025-05-21T18:18:08.674Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b9/5b092c5b409cad0005b560622b34f9fd38fe0945939974671306fedef30a/tskit-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:c6b1f1b22e5d55a906ee33bca33447500df0cf5b12ec20926773ca1e3c3c8931", size = 470097, upload-time = "2025-05-21T18:18:11.022Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c23333b3ffd86cbe79520bbee85f050f815c16a625b014dbacbe486fb6c2/tskit-1.0.3.tar.gz", hash = "sha256:8a305b3bcf4145688be17962f6ea2ac20aa65db440ed0c02b49f9b4a3c00836b", size = 933435, upload-time = "2026-05-14T18:20:16.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/6c/7fdf85e62204319d77da52d2647e1e65c6dc94660a27d7f202fd134f42c9/tskit-1.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5d5f5e0d0ad53ac554563c76b91c5f2bb721d021b7c761606a9a72fe1e49d74", size = 526356, upload-time = "2026-05-14T18:19:40.482Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ce/4ff9ba98eca28992250fa8eab25db5bc4a3c7b3af4ad46652f7d1b5b22c3/tskit-1.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af978aa415f04fbabd613ad2933526110b4767e8e64f319ec03cefca9e2242bd", size = 496008, upload-time = "2026-05-14T18:19:42.332Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/d4d8567a2bca51df46f0c7ad706678b106ce2ba094d8584d8829a24451c0/tskit-1.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f509b7b271dd80a68ee3f6bfdff614e3b8ac989dcc44dd8d36c25c8181443b79", size = 1395725, upload-time = "2026-05-14T18:19:43.83Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/d5ce78eeeda65c4673966d00567f4166b9500843ce953c0dad927a2200c3/tskit-1.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0722a4c1fc9c287c96a2141ae3587df6defed2a049cca7ab90479958c613835f", size = 1377087, upload-time = "2026-05-14T18:19:45.238Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7c/9e53fb9f71bf89eb8327cb2a859d4c9764dc447a42d3407f7ec7c7a25fb8/tskit-1.0.3-cp311-cp311-win32.whl", hash = "sha256:3901c9fc02497e2c7e0ee5ca36ce5b65749f9c630e5ace1daa990963d8f5a63d", size = 449670, upload-time = "2026-05-14T18:19:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/72/88/a80beb0adfb8ceade30b1913010c56e6bd4f837ef2f75aeb76222377d148/tskit-1.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:0d374768d422e941f8ad6ffdba85d6ca1585467532f6f4945b171f18d6613285", size = 496418, upload-time = "2026-05-14T18:19:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/56/1f/d6726034289911b326e74b8b5339c9d1ec6fe46bf203253ae8d90b146a89/tskit-1.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d035946888c9da5eefb93ca99c2ae3ad2cb7cb64bc6037588fe285f775ee67a", size = 528485, upload-time = "2026-05-14T18:19:49.299Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ed/7fa3f43bc0d7569b75bc6a5c03bc14bd2e9de12a3f6a2f8b3251d505226a/tskit-1.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31aee6a8b6d9a0bdae06f0b186fb7aad54d53f34050c0ee72a6caa560506950f", size = 496860, upload-time = "2026-05-14T18:19:50.716Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/fb828d0479be0f178e9f33b4483003a421b069e6331dc5f35066dc3029e4/tskit-1.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb07ead500c7e88c69677469b4f489c8997ad944103102b04d724c855b4b980b", size = 1398919, upload-time = "2026-05-14T18:19:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/1c7adaae07a00ce009e692a6d171c7a0882d1ed9753017b78a7db7ae7d72/tskit-1.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:241192b12d310e49b21bfae524c76768a15d0ed3f7b950d6ee09dce1528b8973", size = 1378470, upload-time = "2026-05-14T18:19:53.881Z" }, + { url = "https://files.pythonhosted.org/packages/71/a1/10d12e9b7908775cf97ad9e6c3d94b4b970940c74a161cc9aaf540fa83c0/tskit-1.0.3-cp312-cp312-win32.whl", hash = "sha256:3e11c9fe328b27c0e4a3ff64a0fed972d2cd48a743c03c8bc339352664322ee8", size = 450010, upload-time = "2026-05-14T18:19:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b7/d1842bec89cd4993958165d6da774e85ad79b9426baca571c39457a1b6b5/tskit-1.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:66c83c7f971bc160fd29ca6169e1ee31023a5283ca88936df3eed1e568eee209", size = 496000, upload-time = "2026-05-14T18:19:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/aa8cc65060669b911946649cd1802f29640998c5b6ca78c4c845fb3c473d/tskit-1.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d52e2ad0eb79e3b21da8a236d0f5eb888f9b18569e48f4b84a3875523e80ee22", size = 528459, upload-time = "2026-05-14T18:19:58.156Z" }, + { url = "https://files.pythonhosted.org/packages/eb/42/34d981123a335d80e3030e9b0a81a41f654c9cf1c5db0ac630d6b20d8bfd/tskit-1.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5bd1744a020a518e5835b85971fea14ca827d76f778650eb37e3f44c6db0a711", size = 496838, upload-time = "2026-05-14T18:19:59.909Z" }, + { url = "https://files.pythonhosted.org/packages/65/9c/09b072c01b8e58a3137011851d4761daf957f2f86447bc2a2c25e0d5aa4a/tskit-1.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:405c20fad5256edd6abd1f8b514c5bc653d808102f509c4512c9b174507dbe84", size = 1399080, upload-time = "2026-05-14T18:20:01.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bb/789b91f7a2deca4d50f649b685e2b3dca4b3358e36bf7a5b2b84709bb8d5/tskit-1.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bc1c97f00ce8e79460aa9ff3b36987daf70f2dc267e36a32db77018dbefc822b", size = 1378631, upload-time = "2026-05-14T18:20:03.273Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/3b2b19246b2ba3159456e4068887e505d7a2ec857ecd7ae7606d6c129fda/tskit-1.0.3-cp313-cp313-win32.whl", hash = "sha256:53444ef3b2fd7ea9ca863e562c33402e46a55e645887dd4d983686b607e3f788", size = 449996, upload-time = "2026-05-14T18:20:04.788Z" }, + { url = "https://files.pythonhosted.org/packages/23/fc/53ef7ad8b3f2183584af81b0ff046a660c3164530a1f768770dbd4d60cb7/tskit-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:721ae7f02730ed91233a6da2a9d4b59632d1512877cd0d6fe948ea5d9ddc17e9", size = 496006, upload-time = "2026-05-14T18:20:06.577Z" }, + { url = "https://files.pythonhosted.org/packages/66/b9/91194216907ebf278d7a58288f3e64881e11a0eedcca7125a49e0bbdafdd/tskit-1.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:62a45d5e57f5a9181eb113c0dd478783ef3c4829856c0831ea2eba2cdf00a8de", size = 528594, upload-time = "2026-05-14T18:20:08.032Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0a888f4f140922c7a3945dbca5e444fa8052a620c784359673fe9eb1eda7/tskit-1.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8df716837129652fc82d095d80fd4e23a728213a4b18de9233f3aebb073b60b4", size = 496866, upload-time = "2026-05-14T18:20:09.34Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ce/56aa69b768734ede0b8b36c5ea2ce842d50c786b74cfefdfc036a1fd29da/tskit-1.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:151eefbbae9a339f3ea64980937fa89d6ac9fb371dba880a9b30791a461753ad", size = 1397616, upload-time = "2026-05-14T18:20:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5a/b05eab4a4a32bce6a51f991033ed460a836aafc1f406eebc624831558d9b/tskit-1.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2170cacd8ade47fd8f1510aca78549e09e9ba875c25496f0ccfb755e09c0b06b", size = 1377477, upload-time = "2026-05-14T18:20:12.649Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c5/116419617468dc06428e507001d0a7b212116e9e8d336bd8c05a6531b2a0/tskit-1.0.3-cp314-cp314-win32.whl", hash = "sha256:24f8fffc1e1dd4154654e91bb7bac22078b3cadd9837e5c490225aa5c313b819", size = 455586, upload-time = "2026-05-14T18:20:14.165Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7f/61818ed922629178abb07e45439d0af3bb5867b629fe392e9864df58ce5c/tskit-1.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:cbe791757cbd4d060a9c06c270bbcacc932f60ce1d77e98dcea35c931d0b0aa9", size = 505409, upload-time = "2026-05-14T18:20:15.527Z" }, ] [[package]] From 3e00b172959470f38087be1c2d88fd71c23670f0 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 16:02:37 -0700 Subject: [PATCH 2/9] dev slim in docs build? --- .github/workflows/docs.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c79fac76..e02f6d69 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,6 +11,7 @@ jobs: Docs: permissions: contents: read - uses: tskit-dev/.github/.github/workflows/docs.yml@v19 + uses: petrelharp/tskit.github/.github/workflows/docs.yml@a3405f892599a52b1166993f886ee45de44a4eca with: install-slim: true + install-slim-branch: "multitrait" From 773783c58b5902a31ce1763d25705be8a1c9439c Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 16:07:08 -0700 Subject: [PATCH 3/9] DO NOT MERGE for testing --- .github/workflows/lint.yml | 13 - .github/workflows/tests.yml | 147 --- .github/workflows/wheels.yml | 56 - docs/_toc.yml | 13 - docs/development.md | 35 - docs/metadata.md | 284 ----- docs/previous_versions.md | 335 ------ docs/tutorial.md | 1407 ------------------------- docs/vignette_coalescent_diversity.md | 549 ---------- docs/vignette_continuing.md | 238 ----- docs/vignette_parallel_phylo.md | 339 ------ docs/vignette_space.md | 472 --------- 12 files changed, 3888 deletions(-) delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/tests.yml delete mode 100644 .github/workflows/wheels.yml delete mode 100644 docs/development.md delete mode 100644 docs/metadata.md delete mode 100644 docs/previous_versions.md delete mode 100644 docs/tutorial.md delete mode 100644 docs/vignette_coalescent_diversity.md delete mode 100644 docs/vignette_continuing.md delete mode 100644 docs/vignette_parallel_phylo.md delete mode 100644 docs/vignette_space.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 8b29520b..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Lint - -on: - pull_request: - push: - branches: [main] - merge_group: - -jobs: - Lint: - permissions: - contents: read - uses: tskit-dev/.github/.github/workflows/lint.yml@v19 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 538a8735..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,147 +0,0 @@ -name: Tests - -on: - pull_request: - push: - branches: [main] - merge_group: - -permissions: - contents: read - -jobs: - packaging: - name: Python packaging - uses: tskit-dev/.github/.github/workflows/python-packaging.yml@v19 - - test: - name: Python - runs-on: ${{ matrix.os }} - strategy: - matrix: - python: ["3.11", "3.13"] - os: [macos-latest, ubuntu-24.04, windows-latest] - sys: [mingw64, ucrt64] - env: [x86_64, ucrt-x86_64] - exclude: - - os: macos-latest - sys: ucrt64 - - os: macos-latest - sys: mingw64 - env: ucrt-x86_64 - - os: ubuntu-24.04 - sys: ucrt64 - - os: ubuntu-24.04 - sys: mingw64 - env: ucrt-x86_64 - - os: windows-latest - sys: ucrt64 - env: x86_64 - - os: windows-latest - sys: mingw64 - env: ucrt-x86_64 - defaults: - run: - shell: bash -l {0} - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1 - with: - access_token: ${{ github.token }} - - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Install Conda - uses: mamba-org/setup-micromamba@d7c9bd84e824b79d2af72a2d4196c7f4300d3476 # v3.0.0 - with: - environment-name: anaconda-client-env - cache-environment: true - create-args: | - python=${{ matrix.python }} - - - name: Setup MSYS2 ${{matrix.sys}} - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 - if: matrix.os == 'windows-latest' - with: - msystem: ${{matrix.sys}} - release: false - install: >- - git - base-devel - msys2-devel - mingw-w64-${{matrix.env}}-zstd - mingw-w64-${{matrix.env}}-zlib - mingw-w64-${{matrix.env}}-toolchain - mingw-w64-${{matrix.env}}-cmake - mingw-w64-${{matrix.env}}-autotools - - - name: Cache SLiM build - if: matrix.os == 'windows-latest' - id: cache-slim - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: D:\a\pyslim\pyslim\SLiM - key: ${{runner.os}}-${{matrix.sys}}-${{matrix.env}}-key - - - name: Build SLiM (Windows) - if: matrix.os == 'windows-latest' && steps.cache-slim.outputs.cache-hit != 'true' - shell: msys2 {0} - run: | - git clone https://github.com/messerlab/SLiM.git - mkdir -p SLiM/Release - cd SLiM/windows_compat/gnulib - git checkout multitrait # <-- note multitrait branch!! - touch --date="`date`" aclocal.m4 Makefile.am configure configure.ac config.h.in Makefile.in - cd ../.. - cd Release - cmake -G"MSYS Makefiles" -DCMAKE_BUILD_TYPE=Release .. - make -j 2 - - - name: Install uv and dependencies - run: | - pip install uv - uv sync --locked --group test --no-default-groups - - # UNCOMMENT THIS when the below is commented again - # - name: Install SLiM (macOS / Linux) - # if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-24.04' - # run: micromamba install slim -y - - - name: Install development SLiM - # This should be COMMENTED OUT for release versions, - # since this builds SLiM from github head. - # Also note that this checks out the multitrait branch!! - if: (matrix.os == 'macos-latest' || matrix.os == 'ubuntu-24.04') && steps.cache.outputs.cache-hit != 'true' - # If we want to re-build slim from a new commit to the slim repo - # we may need to bump the cache key above. - shell: bash -l {0} - run: | - git clone https://github.com/messerlab/SLiM.git - mkdir -p SLiM/Release - cd SLiM/Release - git checkout multitrait - cmake -DCMAKE_BUILD_TYPE=Release .. - make -j 2 - - - - name: Run tests - run: | - export PATH=$PWD/SLiM/Release:$PATH - uv run --no-default-groups --group test pytest \ - -n 0 -v \ - --cov=pyslim --cov-branch \ - --cov-report=xml \ - tests - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true - flags: python-tests - files: coverage.xml - disable_search: true - verbose: true diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml deleted file mode 100644 index 6bba338b..00000000 --- a/.github/workflows/wheels.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Publish Python release - -on: - push: - branches: [test-publish] - release: - types: [published] - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - with: - version: "0.10.0" - enable-cache: false - - - name: Build - run: uv build - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: dist - path: dist/ - - publish: - runs-on: ubuntu-24.04 - environment: release - needs: [build] - permissions: - id-token: write - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: dist - path: dist - - - name: Publish to TestPyPI - if: github.event_name == 'push' && github.ref_name == 'test-publish' - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - with: - repository-url: https://test.pypi.org/legacy/ - verbose: true - - - name: Publish to PyPI - if: github.event_name == 'release' - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/docs/_toc.yml b/docs/_toc.yml index b2a83888..c47647d8 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -7,17 +7,4 @@ parts: - file: installation - caption: Using pyslim chapters: - - file: tutorial - - file: vignette_space - - file: vignette_continuing - - file: vignette_coalescent_diversity - - file: vignette_parallel_phylo - file: time_units - - file: metadata - - file: previous_versions -- caption: pyslim reference - chapters: - - file: python_api -- caption: Miscellaneous - chapters: - - file: development diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index 53b951a0..00000000 --- a/docs/development.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -(sec_development)= - -# Development - -All contributions, bug reports, documentation improvements and ideas are welcome. If you think -there is anything missing, please open an [issue](https://github.com/tskit-dev/pyslim/issues) -or [pull request](https://github.com/tskit-dev/pyslim/pulls) on GitHub. - -See the [tskit developer documentation](https://tskit.dev/tskit/docs/stable/development.html) -for the general development workflow (git, prek, testing, documentation). - -Install development dependencies with: - -```bash -uv sync -``` - -Run the tests with: - -```bash -uv run pytest -``` diff --git a/docs/metadata.md b/docs/metadata.md deleted file mode 100644 index 04c3b70b..00000000 --- a/docs/metadata.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -```{code-cell} -:tags: [remove-cell] -import pyslim, tskit, msprime -from IPython.display import SVG -import numpy as np -import random -random.seed(23) - -ts = tskit.load("example_sim.trees") -tables = ts.dump_tables() -``` - -```{eval-rst} -.. currentmodule:: pyslim -``` - - -(sec_metadata)= - -# Metadata - -(sec_metdata_overview)= - -## Overview - -SLiM puts SLiM-specific information into the *metadata* for the tree sequence, -as well as for each population, individual, node and mutation. -Here is a quick reference to what information is available: -see the SLiM manual for the more technical writeup. -A good way to get a generic metadata example is with {func}`.default_slim_metadata`. - -**Top-level:** -If `ts` is your tree sequence, then `ts.metadata` is a dict, -and `ts.metadata["SLiM"]` contains information about the simulation: - -- `file_version`: the version of the SLiM tree sequence file format -- `tick`: the value of `community.tick` within SLiM when the file was written out -- `cycle`: the value of `sim.cycle` within SLiM when the file was written out -- `model_type`: either `"WF"` or `"nonWF"` -- `nucleotide_based`: whether this is a nucleotide-based simulation -- `separate_sexes`: whether the simulation has separate sexes or not -- `spatial_dimensionality`: for instance, `""` or `"x"` or `"xy"` (etcetera) -- `spatial_periodicity`: whether space wraps around in some directions (same format as dimensionality) -- `stage`: the *stage* of the life cycle at which the file was written out (either `"first"`, `"early"`, or `"late"`) -- `name`: the *name* of this species in SLiM -- `this_chromosome`: contains, for the chromosome in SLiM recorded in this tree sequence - * `id`: SLiM's ID - * `index`: the index of this chromosome in the list of chromosomes - * `symbol`: the user-assigned symbol - * `type`: specifies inheritance type, e.g., `"A"` for autosome -- `chromosomes`: (optional) a list of all chromosomes in the simulation -- `traits`: a list of information for each of the traits: - * `index`: the index of the trait in SLiM - * `name`: the name in SLiM for the trait - * `type`: additive, multiplicative, or logistic - * `baselineOffset`, `baselineAccumulation`: a value added to all traits, and whether the effect of substitutions - accumulate in that value - * `directFitnessEffect`: whether the trait has a direct effect on fitness - * `individualOffsetMean`, `individualOffsetSD`: parameters governing the individual-level offsets - (i.e., "environment" effects) - -**Populations:** -Information about each SLiM-produced population is written to metatadata. -The format uses JSON and is extensible, so other keys may be present -and some keys may be missing (for instance, there are no spatial bounds -in a nonspatial simulation). The metadata may be `None` for populations -that SLiM did not use. The keys that SLiM uses are: - -- `slim_id`: the ID of this population in SLiM -- `name`: the name of the population (by default, `p0`, `p1`, etcetera) -- `description`: a string describing the population -- `selfing_fraction`, `female_cloning_fraction`, `male_cloning_fraction`, and `sex_ratio`: only present when applicable (e.g., in WF simulations) -- `bounds_x0`, `bounds_x1`, `bounds_y0`, `bounds_y1`, `bounds_z0`, and `bounds_z1`: the spatial bounds, when applicable -- `migration_records`: A *list* of entries decribing migration between populations in a WF model. - -**Individuals:** -Each individual produced by SLiM contains the following metadata: - -- `pedigree_id`: the "pedigree ID", unique within the SLiM simulation -- `pedigree_p1`, `pedigree_p2`: the pedigree IDs of the individuals' two - parents (they may be equal in the case of selfing, or `-1` to indicate no - parent, in the case of the initial generation or for cloning) -- `age`: the `.age` property within SLiM at the time the file was written out -- `subpopulation`: the subpopulation within SLiM the individual was in at the time the file was written out -- `sex`: the sex of the individual (either {data}`.INDIVIDUAL_TYPE_FEMALE`, {data}`.INDIVIDUAL_TYPE_MALE`, or {data}`.INDIVIDUAL_TYPE_HERMAPHRODITE`) -- `flags`: additional information; currently only recording whether the individual was a "migrant" or not (see the SLiM manual) -- `tag`, `tagF`: the corresponding properties in SLiM: default values returned by pyslim - are the special values that SLiM uses to mean that the values are unset -- `tagL0`, `tagL0_set`, etcetera: again, the corresponding properties in SLiM; - the purpose of `tagLX_set` is to record whether the tag has been set in the simulation -- `per_trait`: a list of information about the trait values for this indivdual; these are in the same order - as the traits listed in top-level metadata; - * `phenotype`: the trait value - * `offset`: the individual's offset (i.e., the "environmental effect") - -**Nodes:** -Each "node" produced by SLiM (i.e., "genome" within SLiM) has: - -- `slim_id`: the unique ID associated with the genome by SLiM -- `is_vacant`: records the genome is a "vacant" genome (in which case it isn't - really there, so shouldn't have any mutations or relationships in the tree - sequence!) - see [](sec_overview_vacant_nodes) for more explanation - -**Mutations:** -Prior to SLiM 6.0, mutation metadata was associated with the tskit mutation objects. -Now, this is stored in top-level metadata, under ``ts.metadata["SLiM_mutation_list"]``. -Each entry - -- `mutation_id`: the numeric ID of mutation in SLiM -- `mutation_type`: the numeric ID of the `MutationType` within SLiM -- `subpopulation`: the numeric ID of the subpopulation the mutation occurred in -- `slim_time`: the value of `community.tick` when the mutation occurred -- `nucleotide`: either `-1` if there is no associated nucleotide, or the numeric code for the nucleotide (see {data}`.NUCLEOTIDES`) -- `per_trait`: a list of information in the same order as the traits in top-level metadata, recording for each: - * `effect_size`: the effect on the trait of this mutation - * `dominance`: its dominance coefficient - * `hemizygous_dominance`: its hemizygous dominance coefficient (see the SLiM manual) -- `padding`: this is simply empty bytes, here for byte-alignment reasons, and is always `None` - - -(sec_metadata_using_top_level)= - -## Using top-level metadata - -If you are going to be using information from top-level metadata, -it is good practice to extract the metadata as a separate python object once -and refer to that object, since otherwise you can incur runtime penalties -for decoding and copying the metadata every time you call `ts.metadata`. -This can be substantial, given the amount of mutation information -in top-level metadata. -For instance, to subtract off baseline offsets from individual's trait values, -we might do: -```{code-cell} -md = ts.metadata -traits = md["SLiM"]["traits"] -values = [ - [x['phenotype'] - y["baselineOffset"] for x, y in zip(ind.metadata['per_trait'], traits)] - for ind in ts.individuals() -] -``` -If we instead inserted ``ts.metadata["SLiM"]["traits"]`` directly into the loop, -this would become infeasibly slow. - -In some more detail: -each time python evaluates ``ts.metadata`` (e.g., using ``ts.metadata["SLiM"]``) -a new copy of the metadata dict is decoded and returned. Furthermore, a number -of pyslim functions need to look up information from metadata under the hood. -For instance, previously it was acceptable to run -``[pyslim.slim_time(ts, mut.time) for mut in ts.mutations()]``. -However, this could now easily take hours even for moderately-sized simulations. -There are several recommendations for how to mitigate this: - -- If you use information from top-level metadata, make a copy of it - and refer to that copy instead: so, ``ts_metadata = ts.metadata`` - after ``ts = tskit.load(...)`` and then use `ts_metadata`. However, - be careful that you use the correct metadata object! - -- Use a single pyslim function call rather than many. For instance, run: - ``slim_times = pyslim.slim_time(ts, ts.mutations_time)`` and extract - slim times from this vector. Similarly, use {func}`.nodes_vacant` - instead of {func}`.node_is_vacant`. - -- Some pyslim methods will accept a pre-extracted metadata dictionary - as an optional argument. If this is not provided, those methods will - extract the metadata again. The methods that now take a `ts_metadata` argument are: - {func}`.individual_ages`, - {func}`.individual_ages_at`, - {func}`.individuals_alive_at`, and - {func}`.slim_time`. - - -(sec_metadata_tools)= - -## Metadata tools - -The dictionaries describing the schema for these metadata entries -are available in `pyslim.slim_metadata_schemas`. -Furthermore, these method may be useful in working with metadata: - -```{eval-rst} -.. autofunction:: default_slim_metadata - -.. autofunction:: slim_tree_sequence_metadata_schema - -.. autofunction:: slim_individual_metadata_schema - -.. autofunction:: slim_node_metadata_schema - -.. autofunction:: set_tree_sequence_metadata - -.. autofunction:: set_metadata_schemas -``` - - -## Modifying SLiM metadata -For more on working with metadata, -see {ref}`tskit's metadata documentation `. - - -### Top-level metadata - -The entries of the top-level metadata dict are *read-only*. -So, although you might think that -`tables.metadata["SLiM"]["model_type"] = "nonWF"` -would switch the model type, -this in fact (silently) does nothing. To modify the top-level metadata, -we must (a) work with tables (as tree sequences are immutable), and (b) -extract the metadata dict, modify the dict, and copy it back in. -Instead, you should do -```{code-cell} -md = tables.metadata -md["SLiM"]["model_type"] = "nonWF" -tables.metadata = md -``` -Modifying the top-level metadata -could be used to set spatial bounds on an annotated msprime simulation, for instance. -(This is recorded in the population metadata.) - - -### Modifying SLiM metadata in tables - - -To modify the metadata that ``pyslim`` has introduced into -the tree sequence produced by a coalescent simulation, -or the metadata in a SLiM-produced tree sequence, -we need to edit the TableCollection that forms the editable data behind the tree sequence. -For instance, to set the ages of the individuals in the tree sequence to random numbers between 1 and 4, -we will extract a copy of the underlying tables, clear it, -and then iterate over the individuals in the tree sequence, -as we go re-inserting them into the tables -after replacing their metadata with a modified version: - -```{code-cell} -tables = ts.dump_tables() -tables.individuals.clear() -for ind in ts.individuals(): - md = ind.metadata - md["age"] = random.choice([1,2,3,4]) - _ = tables.individuals.append( - ind.replace(metadata=md) - ) - -mod_ts = tables.tree_sequence() - -# check that it worked: -print("First ten ages:", [mod_ts.individual(i).metadata["age"] for i in range(10)]) -for ind in mod_ts.individuals(): - assert ind.metadata['age'] in [1, 2, 3, 4] - -# save out the tree sequence -mod_ts.dump("modified_ts.trees") -``` - -## Technical details - -### Metadata entries - -SLiM records additional information in the metadata columns of Individual, Node, and Mutation tables, -in a binary format using the python ``struct`` module. -See {ref}`tskit's metadata documentation ` -for details on how this works. -Nothing besides this binary information can be stored in the metadata of these tables if the tree sequence is to be used by SLiM, -and so when ``pyslim`` annotates an existing tree sequence, anything in those columns is overwritten. -Population metadata is stored as JSON, however, which is more flexible. -For more detailed documentation on the contents and format of the metadata, see the SLiM manual. - -Of particular note is that *nodes* and *populations* may have empty metadata. -SLiM will not use the metadata of nodes that are not associated with alive individuals, -so this can safely be omitted (and makes recapitation easier). -And, populations not used by SLiM will have empty metadata. -All remaining metadata are required (besides edges and sites, whose metadata is not used at all). diff --git a/docs/previous_versions.md b/docs/previous_versions.md deleted file mode 100644 index ce067fa2..00000000 --- a/docs/previous_versions.md +++ /dev/null @@ -1,335 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -```{code-cell} -:tags: [remove-cell] -import pyslim, tskit, msprime - -ts = tskit.load("example_sim.trees") -tables = ts.dump_tables() -``` - - -(sec_previous_versions)= - - -# Migrating from previous versions of pyslim - -## 1.2 - -Release 1.2 goes along with SLiM v6, which introduces support for traits. -It also changes the format for storing mutation metadata: now this is stored -in top-level metadata. - -1. Each time python evaluates ``ts.metadata`` (e.g., using ``ts.metadata["SLiM"]``) -a new copy of the metadata dict is decoded and returned. In large SLiM simulations, -this can take seconds, so we should avoid doing it many times. Furthermore, a number -of pyslim functions need to look up information from metadata under the hood. -See [](sec_metadata_using_top_level) for more discussion and examples. -In particular: - - - The method {func}`.node_is_vacant` necessarily uses metadata and acts - only on a single node. This method is now deprecated; - use {func}`.nodes_vacant` instead. - - - Some pyslim methods will accept a pre-extracted metadata dictionary - as an optional ``ts_metadata`` argument; see [](sec_metadata_using_top_level). - Furthermore, {func}`.is_current_version` now accepts top-level metadata directly - as an alternative to the tree sequence. - -2. If you are using `msprime` to generate mutations, you need to use -{func}`.add_mutation_metadata` after generating mutations to add the -information about these that SLiM expects to top-level metadata. -For instance: - -```{code-cell} -next_id = pyslim.next_slim_mutation_id(ts) -ts = pyslim.add_mutation_metadata( - msprime.sim_mutations( - ts, - rate=1e-8, - model=msprime.SLiMMutationModel(type=0, next_id=next_id), - ), - mutation_type=0, -) -``` -Here the ``mutation_type`` argument to {func}`.add_mutation_metadata` -is the important one; the ``type`` argument to ``SLiMMutationModel`` -is now deprecated, and will be effectively ignored. - -3. Instead of looking up metadata for mutations in `mut.metadata`, you need -to pull this information out of top-level metadata using the SLiM ID as a key. -In brief, if `mut` is a mutation, then you should replace -`mut.metadata["mutation_list"][j]` -with `mut_metadata[int(mut.derived_state.split(",")[j])]`, -where `mut_metadata` is the output of {func}`.mutation_metadata`. -For instance, where before you might have done: - -```python -mut = ts.mutation(0) -for k, md in zip(mut.derived_state.split(","), mut.metadata["mutation_list"]): - print(f"SLiM ID: {k}") - print(f"Metadata: {md}") -``` - -Now, you would do: - -```{code-cell} -mut_metadata = pyslim.mutation_metadata(ts) -mut = ts.mutation(0) -for k in mut.derived_state.split(","): - md = mut_metadata[int(k)] - print(f"SLiM ID: {k}") - print(f"Metadata: {md}") -``` - -The function {func}`.mutation_metadata` pulls information out of -`ts.metadata["SLiM_mutation_list"]`. It is useful for two reasons: -first, it puts the information into a dict, so you can look up information -using the SLiM mutation ID instead of searching through the list to find it. -Second, it caches the information: every time you access -`ts.metadata["SLiM_mutation_list"]`, it makes a new, decoded copy -of the entire metadata dictionary. This can be **very slow** if it is done -repeatedly. - -## 1.1 - -Release 1.1 goes along with SLiM v5, which introduces multichromosome support. -See [](sec_overview_vacant_nodes) for a description of the possibility of "vacant" nodes. - -1. Most importantly, if your tree sequence contains vacant nodes, these must -be removed or (better) simply amended to be not marked as samples before certain -operations, including computing statistics or recapitation. -To do this, you might do -```{code-cell} -removed_vacant = pyslim.has_vacant_samples(ts) -if removed_vacant: - ts = pyslim.remove_vacant(ts) -``` -Note that this does not remove the vacant nodes from the tree sequence, it just -removes them from the *sample*, which will make them invisible to most operations. -However, if you use {func}`.recapitate` then this is unnecessary, because -**{func}`.recapitate` does this for you.** - -2. If you *have* removed vacant samples and you wish to reload the tree seqeuence -into SLiM, you'll have to reverse this, like -```{code-cell} -if removed_vacant: - ts = pyslim.restore_vacant(ts) -``` -Note that `remove_vacant` and `restore_vacant` are harmless on tree sequences -without vacant nodes; they're just wrapped in `if` statements to avoid the extra -overhead if not needed. - -3. Replace `node.metadata["is_null"]` with `node.metadata["is_vacant"][0] > 0`. -(Previously, `is_null` contained a boolean; now it contains a list of ints; -for a single-chromosome simulation this will be a single int that will be -either 0 (if vacant) or 1 (if not). - -4. Instead of checking `node.metadata["genome_type"]`, instead consult -`ts.metadata["SLiM"]["this_chromosome"]["type"]`. (It was previously redundant -to have a separate "genome type" entry for every node, anyhow.) - -## 1.0 - -The pyslim 1.0 release coincides with that of SLiM v4, -which introduced a number of changes to SLiM. -pyslim remains backwards compatible, in that pyslim 1.0 -will happily read tree sequences produced by previous versions of SLiM or pyslim, -and will convert them to the current version. -However, previous pyslim code may not work, due to two sets of changes: -(1) much of the functionality originally in pyslim has moved to tskit -(e.g., metadata processing), and (2) minor changes to terminology in SLiM v4 -("generation" is now "tick"). - -Converting previous code should be straightforward, as there are exact replacements. -The most important changes are to remove calls to `pyslim.load( )` or `SlimTreeSequence( )`, -and change "generation=" arguments to "tick=". - -In more detail, to upgrade code you should: - -1. Change `pyslim.load( )` to `tskit.load( )`. -2. Remove calls to `SlimTreeSequence( )`. They are not needed. -3. Change `generation` to `tick` in any arguments to functions, or in metadata. -4. Change `pyslim.annotate_defaults( )` to `pyslim.annotate( )`. - and `pyslim.annotate_defaults_tables( )` to `pyslim.annotate_tables( )`. -5. Change `pyslim.update_tables( )` to `pyslim.update( )`. - -Some methods of SlimTreeSequence are now methods of pyslim that take a tree sequence -as their first argument: - -6. Change `ts.recapitate(...)` to `pyslim.recapitate(ts, ...)`. -7. Change `ts.individuals_alive_at(t)` to `pyslim.individuals_alive_at(ts, t)`. -8. Change `ts.has_individual_parents()` to `pyslim.has_individual_parents(ts)`, - and `ts.individual_parents()` to `pyslim.individual_parents(ts)`. -9. Replace `ts.first_generation_individuals()` with - an appropriate call to `pyslim.individuals_alive_at( )`. -10. Change `ts.mutation_at(...)` to `pyslim.mutation_at(ts, ...)`. - and `ts.nucleotide_at(...)` to `pyslim.nucleotide_at(ts, ...)`. - -Several properties previously provided by SlimTreeSequence are now provided -by TreeSequence (e.g., `ts.individual_times`); so these need no change. -However, these were briefly available as pyslim methods, so would need changing: - -11. Change `pyslim.individual_times(ts)` to `ts.individuals_time`, - `pyslim.individual_populations(ts)` to `ts.individuals_population`, and - `pyslim.individual_locations(ts)` to `ts.individuals_location` - -The change from `pyslim.annotate_defaults( )` to `pyslim.annotate( )` -also entailed some small changes in behavior. Most notably, -since msprime.sim_ancestry() now simulates individuals -by default, annotation does not set up individuals: if you have a tree -sequence without individuals (e.g., produced by msprime.simulate()) then you -need to set up those individuals yourself. - -To update a tree sequence produced by an old version of SLiM to the current one, -use `pyslim.update( )`. (However, note that reading it in to SLiM and -writing it out again might be even easier.) - -Also see notes below for 0.700. - - -## 0.700 - -A number of features that were first introduced in pyslim have been made part of core -tskit functionality. For instance, reference sequence support was provided (although -loosely) inpyslim to support SLiM's nucleotide models, but is now part of a standard -tskit {class}`tskit.TreeSequence`. Similarly, metadata processing in tskit made -code to do this within pyslim obsolete; this "legacy metadata" code has been removed -and instructions for how to migrate your code are [below](sec_legacy_metadata). - -In fact, we are now at the (very good) place where we don't really need -the `pyslim.SlimTreeSequence` class any longer, -and it will soon be deprecated. -So, pyslim is migrating to be purely functional: instead of providing the SlimTreeSequence -class with specialized methods, all methods will be functions of TreeSequences, -that take in a tree sequence and return something -(a modified tree sequence or some summary of it). -Backwards compatibility will be maintained for some time, but we request that you -switch over sooner, as your code will be cleaner and faster. - -To migrate, you should: - - -1. Replace `ts.slim_generation` with `ts.metadata['SLiM']['generation']`, - and `ts.model_type` with `ts.metadata['SLiM']['model_type']`. -2. Replace `ts.reference_sequence` with `ts.reference_sequence.data`. -3. Replace calls to `ts.recapitate(...)` with `pyslim.recapitate(ts, ...)`, - and similarly with other SlimTreeSequence methods. - -If you encounter difficulties, please post an -[issue](https://github.com/tskit-dev/pyslim/issues) -or [discussion](https://github.com/tskit-dev/pyslim/discussions) on github. - - -(sec_legacy_metadata)= - -## Legacy metadata - -In previous versions of pyslim, -SLiM-specific metadata was provided as customized objects: -for instance, for a node ``n`` provided by a ``SlimTreeSequence``, -we'd have ``n.metadata`` as a ``NodeMetadata`` object, -with attributes ``n.metadata.slim_id`` and ``n.metadata.is_null`` and ``n.metadata.genome_type``. -However, with tskit 0.3, -the capacity to deal with structured metadata -was implemented in {ref}`tskit itself `, -and so pyslim shifted to using the tskit-native metadata tools. -As a result, parsed metadata is provided as a dictionary instead of an object, -so that now ``n.metadata`` would be a dict, -with entries ``n.metadata["slim_id"]`` and ``n.metadata["is_vacant"]`` -(previously, ``n.metadata["is_null"]`` and ``n.metadata["genome_type"]``). -Annotation should be done with tskit methods (e.g., ``packset_metadata``). - -.. note:: - - Until pyslim version 0.600, the old-style metadata was still available, - but this functionality has been removed. - -Here are more detailed notes on how to migrate a script from the legacy -metadata handling. If you run into issues, please ask (open a discussion on github). - -**1.** Use top-level metadata instead of ``slim_provenance``: -previously, information about the model type and the time counter (generation) -in SLiM was provided in the Provenances table, made available through -the ``ts.slim_provenance`` object. This is still available but deprecated, -and should be obtained from the *top-level* metadata object, ``ts.metadata["SLiM"]``. -So, in your scripts ``ts.slim_provenance.model_type`` should be replaced with -``ts.metadata["SLiM"]["model_type"]``, -and (although it's not deprecated), probably ``ts.slim_generation`` should -probably be replaced with -``ts.metadata["SLiM"]["generation"]``. - -**2.** Switch metadata objects to dicts: -if ``md`` is the ``metadata`` property of a population, individual, or node, -this means replacing ``md.X`` with ``md["X"]``. -The ``migration_records`` property of population metadata is similarly -a list of dicts rather than a list of objects, so instead of -``ts.population(1).metadata.migration_records[0].source_subpop`` -we would write -``ts.population(1).metadata["migration_records"][0]["source_subpop"]``. - -Mutations were previously a bit different - if ``mut`` is a mutation -(e.g., ``mut = ts.mutation(0)``) -then ``mut.metadata`` was previously a list of MutationMetadata objects. -Now, ``mut.metadata`` is a dict, with a single entry: -``mut.metadata["mutation_list"]`` is a list of dicts, each containing the information -that was previously in the MutationMetadata objects. -So, for instance, instead of ``mut.metadata[0].selection_coeff`` -we would write ``mut.metadata["mutation_list"][0]["selection_coeff"]``. - -**3.** The ``decode_X`` and ``encode_X`` methods are now deprecated, -as this is handled by tskit itself. -For instance, ``encode_node`` would take a NodeMetadata object -and produce the raw bytes necessary to encode it in a Node table, -and ``decode_node`` would do the inverse operation. -This is now handled by the relevant MetadataSchema object: -for nodes one can obtain this as ``nms = ts.tables.nodes.metadata_schema``, -which has the methods ``nms.validate_and_encode_row`` and ``nms.decode_row``. -Decoding is for the most part not necessary, -since the metadata is automatically decoded, -but ``pyslim.decode_node(raw_md)`` could be replaced by ``nms.decode_row(raw_md)``. -Encoding is necessary to modify tables, -and ``pyslim.encode_node(md)`` can be replaced by ``nms.validate_and_encode_row(md)`` -(where furthermore ``md`` should now be a dict rather than a NodeMetadata object). - -**4.** The ``annotate_X_metadata`` methods are deprecated, -as again tskit has tools to do this. -These methods would set the metadata column of a table - -for instance, if ``metadata`` is a list of NodeMetadata objects, then -``annotate_node_metadata(tables, metadata)`` would modify ``tables.nodes`` in place -to contain the (encoded) metadata in the list ``metadata``. -Now, this could be done as follows (where now ``metadata`` is a list of metadata dicts): - -```{code-cell} -metadata = [ {'slim_id': k, 'is_vacant': [0]} - for k in range(tables.nodes.num_rows) ] -nms = tables.nodes.metadata_schema -tables.nodes.packset_metadata( - [nms.validate_and_encode_row(r) for r in metadata] -) -``` - -If speed is an issue, then ``encode_row`` can be substituted for ``validate_and_encode_row``, -but at the risk of missing errors in metadata. - -**5.** the ``extract_X_metadata`` methods are not necessary, -since the metadata in the tables of a TableCollection are automatically decoded. -For instance, ``[ind.metadata["sex"] for ind in tables.individuals]`` will obtain -a list of sexes of the individuals in the IndividualTable. - -:::{warning} - It is our intention to remain backwards-compatible for a time. - However, the legacy code will disappear at some point in the future, - so please migrate over scripts you intend to rely on. -::: diff --git a/docs/tutorial.md b/docs/tutorial.md deleted file mode 100644 index 52cfdf33..00000000 --- a/docs/tutorial.md +++ /dev/null @@ -1,1407 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -```{code-cell} -:tags: [remove-cell] - -import warnings -import pyslim, tskit, msprime -from IPython.display import SVG -import numpy as np -import util - -np.random.seed(1234) -warnings.simplefilter('ignore', msprime.TimeUnitsMismatchWarning) -``` - -```{eval-rst} -.. currentmodule:: pyslim -``` - - -# Tutorial - -This tutorial covers the most common uses of tree sequences in SLiM/pyslim. - -## Recapitation, simplification, and mutation - -Perhaps the most common pyslim operations involve [](sec_tutorial_recapitation), -[](sec_tutorial_simplification), and/or [](sec_tutorial_adding_neutral_mutations). -Below we illustrate all three in the context of running a "hybrid" simulation, combining -both forwards and backwards (coalescent) methods. This hybrid approach is a popular -application of pyslim because coalescent algorithms, although more limited in the degree -of biological realism they can attain, can be much faster than the forwards algorithms -implemented in SLiM. - -A typical use-case is to take an existing SLiM simulation and endow -it with a history derived from a coalescent simulation: this is known as *recapitation*. -For instance, suppose we have a SLiM simulation of a population of 100,000 individuals -that we have run for 10,000 generations without neutral mutations. Now, we wish to -extract whole-genome genotype data for only 1,000 individuals. Here's one way to do it: - - -1. {func}`.recapitate` : - The simulation has likely not reached demographic equilibrium - it has not - *coalesced* entirely; recapitation uses coalescent simulation to provide - a "prior history" for the initial generation of the simulation. - -2. {meth}`simplify() ` : For efficiency, subset the tree - sequence to only the information relevant for those 1,000 individuals - we wish to sample. - -3. {func}`msprime.sim_mutations` : Add neutral mutations to the tree sequence. - -These steps are described below. First, to get something to work with, -you can run this simple SLiM script of a single population of sexual organisms, -fluctuating around 1000 individuals, for 1000 generations: - -```{literalinclude} example_sim.slim -``` - -You can run this in the shell, -setting the random seed so you get exactly the same results -as in the code below: -```{code-cell} -:tags: ["hide-output"] -%%bash -slim -s 23 example_sim.slim -``` - - -(sec_tutorial_recapitation)= - -### Recapitation - - -```{figure} _static/pedigree_recapitate.png ---- -scale: 42% -align: right -name: pedigree_recapitate ---- -Recapitation adds the green nodes by coalescent simulation. -(See [the introduction](sec_left_in_tree_sequence) -for a diagram of the previous state.) -``` - -Although we can initialize a SLiM simulation with the results of a coalescent simulation, -if during the simulation we don't actually use the genotypes for anything, it -can be much more efficient to do this afterwards, hence only doing a coalescent -simulation for the portions of the first-generation ancestors that have -not yet coalesced. (See the SLiM manual for more explanation.) -This is depicted in {numref}`figure {number} `: -imagine that at some sites, some of the samples -don't share a common ancestor within the SLiMulated portion of history (shown in blue). -Recapitation starts at the *top* of the genealogies, -and runs a coalescent simulation back through time -to fill out the rest of genealogical history relevant to the samples. -The green chromosomes are new ancestral nodes that have been added to the tree sequence. -This is important - if we did not do this, -then effectively we are assuming the initial population would be genetically homogeneous, -and so our simulation would have less genetic variation than it should have -(since the component of variation from the initial population would be omitted). - -Doing this is as simple as: - -```{code-cell} -orig_ts = tskit.load("example_sim.trees") -rts = pyslim.recapitate(orig_ts, - recombination_rate=1e-8, - ancestral_Ne=200, random_seed=5) -``` -The warning is harmless; it is reminding us to think about generation time -when recapitating a nonWF simulation (a topic we'll deal with later). - -We can check that this worked as expected, by verifying that after recapitation -all trees have only one root: - -```{code-cell} -orig_max_roots = max(t.num_roots for t in orig_ts.trees()) -recap_max_roots = max(t.num_roots for t in rts.trees()) -print(f"Maximum number of roots before recapitation: {orig_max_roots}\n" - f"After recapitation: {recap_max_roots}") -``` - -The {func}`.recapitate` method -is just a thin wrapper around {func}`msprime.sim_ancestry`, -and you need to set up demography explicitly - for instance, in the example above -we've simulated from an ancestral population of ``Ne=200`` diploids. -If you have more than one population, -you must set migration rates or else coalescence will never happen -(see [](sec_recapitate_with_migration) for an example, -and {func}`.recapitate` for more). - - -#### Recapitation with a nonuniform recombination map - -Above, we recapitated using a uniform genetic map. -But, msprime - like SLiM - can simulate with recombination drawn from an arbitrary genetic map. -Let's say we've already got a recombination map as specified by SLiM, -as a vector of "positions" and a vector of "rates". -msprime also needs vectors of positions and rates, but the format is slightly different. -To use the SLiM values for msprime, we need to do three things: - -1. Add a 0 at the beginning of the positions, -2. add 1 to the last position. - -The reason why msprime "positions" must start with 0 (step 1) is that in SLiM, -a position or "end" indicates the end of a recombination block such that its associated -"rate" applies to everything to the left of that end (see ``initializeRecombinationRate``). -In msprime, we will pass in a {class}`msprime.RateMap`, -which requires two things: - -- ``position``: A list of n+1 positions, starting at 0, and ending in the sequence length over which the RateMap will apply. -- ``rate``: A list of n positive rates that apply between each position. - -So, msprime needs a vector of positions that is 1 longer than what you give SLiM, -but one fewer rate values than positions. - -The reason for step 2 is that intervals for tskit (which msprime uses) -are "closed on the left and open on the right", -which means that the genomic interval from 0.0 to 100.0 includes 0.0 but does not include 100.0. -If SLiM has a final genomic position of 99, then it could have mutations occurring at position 99. -Such mutations would *not* be legal, on the other hand, if we set the tskit sequence length to 99, -since the position 99 would be outside of the interval from 0 to 99. -Said another way, if SLiM's final position is 99, the total sequence length is 100, -and so we need to set the end of the genome to 100. -The upshot is that we need to use SLiM's last position plus one - i.e., -the length of the genome - as the rightmost coordinate. - -For instance, suppose that we have a recombination map file in the following (tab-separated) format: - -```{literalinclude} _static/recomb_rates.tsv -``` - -This describes recombination rates across a 100Mb genome with higher rates on the ends -(for instance, 3.2 and 2.8 cM/Mb in the first and last 15Mb respectively) -and lower rates in the middle (0.25 cM/Mb between 50Mb and 85Mb). -The first column gives the starting position, in bp, -for the window whose recombination rate is given in the second column. -(*Note:* this is *not* a standard format for recombination maps - -it is more usual for the *starting* position to be listed!) - -Here is SLiM code to read this file and set the recombination rates: - -``` -lines = readFile("recomb_rates.tsv"); -header = strsplit(lines[0], "\t"); -if (header[0] != "end_position" - | header[1] != "rate(cM/Mb)") { - stop("Unexpected format!"); -} -rates = NULL; -ends = NULL; -nwindows = length(lines) - 1; -for (line in lines[1:nwindows]) { - components = strsplit(line, "\t"); - ends = c(ends, asInteger(components[0])); - rates = c(rates, asFloat(components[1])); -} -initializeRecombinationRate(rates * 1e-8, ends); -``` - -Now, here's code to take the same recombination map used in SLiM, -and use it for recapitation in msprime: - -```{code-cell} -positions = [] -rates = [] -with open('_static/recomb_rates.tsv', 'r') as file: - header = file.readline().strip().split("\t") - assert(header[0] == "end_position" and header[1] == "rate(cM/Mb)") - for line in file: - components = line.split("\t") - positions.append(float(components[0])) - rates.append(1e-8 * float(components[1])) - -# step 1 -positions.insert(0, 0) -# step 2 -positions[-1] += 1 -assert positions[-1] == orig_ts.sequence_length - -recomb_map = msprime.RateMap(position=positions, rate=rates) -rts = pyslim.recapitate(orig_ts, - recombination_rate=recomb_map, - ancestral_Ne=200, random_seed=7) -assert(max([t.num_roots for t in rts.trees()]) == 1) -``` -(As before, you should *not* usually explicitly set -the random seed in your scripts; we set it here so -the content of this document does not change.) - -:::{note} -Starting from msprime 1.0, the default model of recombination -in msprime is *discrete* - recombinations only occur at integer -locations - which matches SLiM's model of recombination. -::: - - -(sec_tutorial_simplification)= - -### Simplification - -```{figure} _static/pedigree_simplify.png ---- -scale: 42% -align: right -name: pedigree_simplify ---- -The result of simplifying the tree sequence -in figure {numref}`figure {number} ` -to only two of the three samples. -``` - -Probably, your simulations have produced many more fictitious genomes -than you will be lucky enough to have in real life, -so at some point you may want to reduce your dataset to a realistic sample size. -We can get rid of unneeded samples and any extra information from them by using -an operation called *simplification* (this is the same basic approach that SLiM -implements under the hood when outputting a tree sequence, as described in -[the introduction](sec_left_in_tree_sequence)). - -Depicted in the figure at the right is the result of applying an explicit call to -{meth}`tskit.TreeSequence.simplify` to our example tree sequence. -In the call we asked to keep only 4 -genomes (contained in 2 of the individuals in the current generation). This has -substantially simplified the tree sequence, because only information relevant to the -genealogies of the 4 sample nodes has been kept. (Precisely, simplification retains only -nodes of the tree sequence that are branching points of some marginal genealogy -- see -[Kelleher et al 2018](https://doi.org/10.1371/journal.pcbi.1006581) for details.) -While simplification sounds very appealing - it makes things simpler after all - -it is often not necessary in practice, because tree sequences are very compact, -and many operations with them are quite fast. -(It will, however, speed up many operations, so if you plan to do a large number of simulations, -your workflow could benefit from early simplification.) -So, you should probably not make simplification a standard step in your workflow, -only using it if necessary. - -It is important that simplification - if it happens at all - -either (a) comes after recapitation, or (b) is done with the -``keep_input_roots=True`` option (see {meth}`tskit.TreeSequence.simplify`). -This is because simplification removes some of the -ancestral genomes in the first generation, -which are necessary for recapitation, -unless it is asked to "keep the input roots". -If we simplify without this option before recapitating, -some of the first-generation blue chromosomes in the figure on the right -would not be present, so the coalescent simulation would start from a more recent point in time -than it really should. -As an extreme example, suppose our SLiM simulation has a single diploid who has reproduced -by clonal reproduction for 1,000 generations, -so that the final tree sequence is just two vertical lines of descent going back -to the two chromosomes in the initial individual alive 1,000 generations ago. -Recapitation would produce a shared history for these two chromosomes, -that would coalesce some time longer ago than 1,000 generations. -However, if we simplified first, then those two branches going back 1,000 generations would be removed, -since they don't convey any information about the shape of the tree; -and so recapitation might produce a common ancestor more recently than 1,000 generations, -which would be inconsistent with the SLiM simulation. - -After recapitation, -simplification to the history of 100 individuals alive today -can be done with the {meth}`tskit.TreeSequence.simplify` method: - -```{code-cell} -import numpy as np -rng = np.random.default_rng(seed=3) -alive = pyslim.individuals_alive_at(rts, 0) -keep_indivs = rng.choice(alive, 100, replace=False) -keep_nodes = [] -for i in keep_indivs: - keep_nodes.extend(rts.individual(i).nodes) - -sts = rts.simplify(keep_nodes, keep_input_roots=True) - -print(f"Before, there were {rts.num_samples} sample nodes (and {rts.num_individuals} individuals)\n" - f"in the tree sequence, and now there are {sts.num_samples} sample nodes\n" - f"(and {sts.num_individuals} individuals).") -``` - -**Note** that you must pass simplify a list of *node IDs*, not individual IDs. -Here, we used the {func}`.individuals_alive_at` method to obtain the list -of individuals alive today. -Also note that there are *still* more than 100 individuals remaining - 15 non-sample individuals -have not been simplified away, -because they have nodes that are required to describe the genealogies of the samples. -(Since this is a non-Wright-Fisher simulation, -parents and children can be both alive at the same time in the final generation.) - - - -(sec_tutorial_adding_neutral_mutations)= - -### Adding neutral mutations to a SLiM simulation - -```{figure} _static/pedigree_mutate.png ---- -scale: 42% -align: right -name: pedigree_mutate ---- -The tree sequence, with mutations added. -``` - -If you have recorded a tree sequence in SLiM, likely you have not included any neutral mutations, -since it is much more efficient to simply add these on afterwards. -To add these (in a completely equivalent way to having included them during the simulation), -you can use the {func}`msprime.sim_mutations` function, which returns a new tree sequence with additional mutations. -Continuing with the cartoons from above, these are added to each branch of the tree sequence -at the rate per unit time that you request. -We'll add these using the {class}`msprime.SLiMMutationModel`, so that the file can be read back into SLiM, -but any of the other mutation models in msprime could be used. -This works as follows: - -```{code-cell} -next_id = pyslim.next_slim_mutation_id(sts) -ts = pyslim.add_mutation_metadata( - msprime.sim_mutations( - sts, - rate=1e-8, - model=msprime.SLiMMutationModel(type=0, next_id=next_id), - keep=True, - ) -) - -print(f"The tree sequence now has {ts.num_mutations} mutations,\n" - f"and mean pairwise nucleotide diversity is {ts.diversity():0.3e}.") -``` - - -What's going on here? Let's step through the code. - -1. The mutation ``rate = 1e-8``, which adds mutations at a rate of {math}`10^{-8}` per bp. - Unlike previous versions of msprime, this adds mutations using a discrete-sites model, - i.e., only at integer locations (like SLiM). - -2. We're passing ``type=0`` to the mutation model. - This is because SLiM mutations need a "mutation type", - and it makes the most sense if we add a type that was unused in the simulation. - In this example we don't have any existing mutation types, so we can safely use ``type=0``. - -3. We also add ``keep = True``, to keep any existing mutations. - In this example there aren't any, so this isn't strictly necessary, - but this is a good default. - -4. If there are existing SLiM mutations on the tree sequence we need to - make sure any newly added mutations have distinct SLiM IDs, - so we use {func}`.next_slim_mutation_id` to figure out - what the next available ID is, and pass it in. - - -(sec_output)= - -### Writing out genotypes to VCF - -Downstream applications often need input in VCF format, -which we can get with a call to {meth}`tskit.TreeSequence.write_vcf`. -However, if we do that with this tree sequence, we'll get a malformed VCF, -with empty strings in the REF column and a strange comma-separated list of integers -in the ALT column. The reason for this is because we added mutations -using the `SLiMMutationModel`, and has to do with how SLiM stores enough information -in the tree sequence to be able to load it back in. -So, to write out valid VCF with nucleotides for alleles, -we need to (1) if the SLiM simulation was not a nucleotide model, add nucleotides -to the SLiM mutations with {func}`generate_nucleotides`, -and (2) move those nucleotides over into the "ancestral state" -and "derived state" slots of the tree sequence with {func}`convert_alleles`. -If all your mutations in SLiM were nucleotide mutations, you only need to do (2). -And, beware that (2) is an irreversible step: if you write the tree sequence -produced by {func}`convert_alleles` to a file, you can't load that file into SLiM any more. -So, to do this we'll do: - -```{code-cell} -nts = pyslim.generate_nucleotides(ts) -nts = pyslim.convert_alleles(nts) -sample_indivs = np.unique([ts.node(n).individual for n in nts.samples()]) -with open("example_sim.vcf", "w") as vcffile: - nts.write_vcf(vcffile, individuals=sample_indivs[:5]) -``` - -Here we've just extracted genotypes for the first five individuals; -see below for what's going on in that code and what you probably -actually want to do; -see also {meth}`tskit.TreeSequence.write_vcf` for more options. - -For instance, if you want to use the SLiM pedigree IDs for the names in the VCF file, -we could do: - -```{code-cell} -pedigree_ids = [ - f"ind_{ts.individual(i).metadata['pedigree_id']}" for i in sample_indivs -] -with open("example_sim2.vcf", "w") as vcffile: - nts.write_vcf( - vcffile, - individuals=sample_indivs[:5], - individual_names=pedigree_ids[:5], - ) -``` - - -(sec_extracting_individuals)= - -## Extracting SLiM individuals - -Another important thing to be able to do is to extract -individuals from a simulation, -for analysis or for outputting their genotypes, for instance. -This section demonstrates some basic manipulations of individuals. - -### Extracting a sample of individuals - -The first, most common method to extract individuals is simply to get all -those that were alive at a particular time, -using {func}`.individuals_alive_at`. For instance, to get -the list of individual IDs of all those alive at the end of the -simulation (i.e., zero time units ago), we could do: - -```{code-cell} -orig_ts = tskit.load("example_sim.trees") -alive = pyslim.individuals_alive_at(orig_ts, 0) - -print(f"There are {len(alive)} individuals alive in the final generation.") -``` - -Here, ``alive`` is a vector of *individual* IDs, -so one way to take a sample of living individuals -and write their SNPs to a VCF is: - -```{code-cell} -rng = np.random.default_rng(seed=1) -keep_indivs = rng.choice(alive, 100, replace=False) -ts = msprime.sim_mutations(orig_ts, rate=1e-8, random_seed=1) -with open("example_snps.vcf", "w") as vcffile: - ts.write_vcf(vcffile, individuals=keep_indivs) -``` - -If you've done nothing else to the output from SLiM, -then this code will work, -but it does requires all alive individuals to be *samples*. -A situation in which this isn't the case is shown in the next section. - - -### Extracting individuals after simplification - -If the tree sequence has been simplified to retain only information -about a set of focal individuals, -then knowing an individual is alive at the end of the simulation -isn't enough to guarantee we have their entire genome sequence: -there are often individuals retained after simplification with -one or more non-sample nodes. -So, to output genotypes after simplification, we need to also check -that the individuals' nodes are also *samples*. -As mentioned earlier, {meth}`tskit.TreeSequence.simplify` takes a list -of nodes as input: - -```{code-cell} -keep_nodes = [] -for i in keep_indivs: - keep_nodes.extend(orig_ts.individual(i).nodes) -sts = rts.simplify(keep_nodes) -ts = msprime.sim_mutations(sts, rate=1e-8, random_seed=1) -``` -Individuals are retained by simplify if any of their nodes are, -so we would get an alive individual without sample nodes if, for instance, -a parent and two offspring are all alive, and we happen to keep the offspring -but not the parent. -For this reason, if at this point we try to extract genotypes for all of the -alive individuals, we encounter a (somewhat confusing) error: - -```{code-cell} -alive = pyslim.individuals_alive_at(ts, 0) -try: - with open("example_snps.vcf", "w") as vcffile: - ts.write_vcf(vcffile, individuals=alive) -except Exception as e: - print ("Error:") - print (e) -``` - -This is just telling us that some of the individuals we're trying -to write to the VCF have nodes that are not samples. -The reference to "missing" is a red herring: -see {ref}`tskit documentation ` -for what it's talking about. -So, instead of writing out genotypes of everyone alive, -we need to get the list of alive individuals *whose nodes are samples*, -using {meth}`is_sample() `: - -```{code-cell} -indivlist = [] -for i in alive: - ind = ts.individual(i) - if ts.node(ind.nodes[0]).is_sample(): - indivlist.append(i) - # if one node is a sample, the other should be also: - assert ts.node(ind.nodes[1]).is_sample() -with open("example_snps.vcf", "w") as vcffile: - ts.write_vcf(vcffile, individuals=indivlist) -``` - - -### Extracting particular individuals - -Now let's see how to examine other attributes of individuals, -e.g., which subpopulation they're in. -To get another example with discrete subpopulations, -let's run another SLiM simulation, similar to the above -but with two populations exchanging migrants: - -```{literalinclude} migrants.slim -``` - -Let's run it: -```{code-cell} -:tags: ["hide-output"] -%%bash -slim -s 32 migrants.slim -``` - -To count up how many individuals are in each population, -we could do: - -```{code-cell} -orig_ts = tskit.load("migrants.trees") -alive = pyslim.individuals_alive_at(orig_ts, 0) -num_alive = [0 for _ in range(orig_ts.num_populations)] -for i in alive: - ind = orig_ts.individual(i) - ind_population = orig_ts.node(ind.nodes[0]).population - num_alive[ind_population] += 1 - -for pop, num in enumerate(num_alive): - print(f"Number of individuals in population {pop}: {num}") -``` - -:::{note} -Our SLiM script started numbering populations at 1, while tskit starts counting at 0, -so there is an empty "population 0" in a SLiM-produced tree sequence. -::: - - -(sec_recapitate_with_migration)= - -## Recapitation with migration between more than one population - -Following on the last example, -let's recapitate and mutate the tree sequence. -Recall that this recipe had two populations, ``p1`` and ``p2``, -each of size 1000. -Recapitation takes a bit more thought, because if the two populations stay separate, -it will run forever, unable to coalesce. -By default, {func}`.recapitate` *merges* the two populations into a single -one of size ``ancestral_Ne``. -But, if we'd like them to stay separate, we need to inclue migration between them. -Here's how we set up the demography using msprime's tools: - -```{code-cell} -demography = msprime.Demography.from_tree_sequence(orig_ts) -for pop in demography.populations: - # must set their effective population sizes - pop.initial_size = 1000 - -demography.add_migration_rate_change( - time=orig_ts.metadata['SLiM']['tick'], - rate=0.1, source="p1", dest="p2", -) -demography.add_migration_rate_change( - time=orig_ts.metadata['SLiM']['tick'], - rate=0.1, source="p2", dest="p1", -) -rts = pyslim.recapitate( - orig_ts, demography=demography, - recombination_rate=1e-8, - random_seed=4 -) -ts = msprime.sim_mutations( - rts, rate=1e-8, - model=msprime.SLiMMutationModel(type=0), - random_seed=7 -) -``` - -Again, there are *three* populations because SLiM starts counting at 1; -the first population is unused (no migrants can go to it). -Let's compute genetic diversity within and between each of the two populations -(we compute the mean density of pairwise nucleotide differences, -often denoted {math}`\pi` and {math}`d_{xy}`). -To do this, we need to extract the node IDs from the individuals of the two populations -that are alive at the end of the simulation. - -```{code-cell} -pop_nodes = [ts.samples(population=p, time=0) for p in range(ts.num_populations)] -diversity = ts.diversity(pop_nodes[1:]) -divergence = ts.divergence(pop_nodes[1:]) - -print(f"There are {ts.num_mutations} mutations across {ts.num_trees} distinct\n" - f"genealogical trees describing relationships among {ts.num_samples}\n" - f"sampled genomes, with a mean genetic diversity of {diversity[0]:0.3e}\n" - f"and {diversity[1]:0.3e} within the two populations,\n" - f"and a mean divergence of {divergence:0.3e} between them.") -``` - - -## Individual metadata - -Each ``Population``, ``Node``, and ``Individual``, as well as the tree -sequence as a whole, carries additional information stored by SLiM in its ``metadata`` -property. A fuller description of metadata in general is given in [](sec_metadata), -but as a quick introduction, here is the information available -about an individual in the previous example: - -```{code-cell} -:tags: ["remove-output"] -ind = ts.individual(0) -``` -```{code-cell} -:tags: ["remove-input"] -util.pp(ind) -``` - -Some information is generic to individuals in tree sequences of any format: -``id`` (the ID internal to the tree sequence), -``flags`` (described [below](sec_individual_flags)), -``location`` (the [x,y,z] coordinates of the individual), -``nodes`` (an array of the node IDs that represent the genomes of this individual), -and ``time`` (the time, in units of "time ago" that the individual was born). - -Other information, contained in the ``metadata`` field, is specific to tree sequences -produced by SLiM. This is described in more detail in the SLiM manual, but briefly: - -- the ``pedigree_id`` is SLiM's internal ID for the individual, -- ``age`` and ``subpopulation`` are their age and population at the time they - were recorded, or at the time - the simulation stopped if they were still alive (NB: SLiM uses the word - "subpopulation" for what is simply called a "population" in tree-sequence parlance) -- ``sex`` is their sex (as an integer, one of {data}`.INDIVIDUAL_TYPE_FEMALE`, - {data}`.INDIVIDUAL_TYPE_MALE`, or {data}`.INDIVIDUAL_TYPE_HERMAPHRODITE`). -- ``flags`` holds additional information about the individual recorded by SLiM - (currently, only whether the individual has migrated or not: - see [](sec_constants_and_flags)). -- the ``tag`` entries contain the correspondly-named "tags" in SLiM, - and for the logical tags ``tagLX``, the ``tagLX_set`` records whether or not - that tag was "set" (as opposed to remaining unset). - The funny values in ``tag`` and ``tagF`` are those special values that SLiM uses to - record that *those* entries were not set either. -- the ``per_trait`` entry is a list of information, one for each trait in the simulation. - -We can use this metadata in many ways, for example, to create an age distribution by sex: - -```{code-cell} -import numpy as np -max_age = max([ind.metadata["age"] for ind in ts.individuals()]) -age_table = np.zeros((max_age + 1, 2)) -age_labels = { pyslim.INDIVIDUAL_TYPE_FEMALE: 'females', - pyslim.INDIVIDUAL_TYPE_MALE: 'males' } -alive = pyslim.individuals_alive_at(ts, 0) -for i in alive: - ind = ts.individual(i) - age_table[ind.metadata["age"], ind.metadata["sex"]] += 1 - -print(f"number\t{age_labels[0]}\t{age_labels[1]}") -for age, x in enumerate(age_table): - print(f"{age}\t{x[0]}\t{x[1]}") -``` - -We have looked up how to interpret the ``sex`` attribute -by using the values of {data}`.INDIVIDUAL_TYPE_FEMALE` (which is 0) -and {data}`.INDIVIDUAL_TYPE_MALE` (which is 1). -In a simulation without separate sexes, -all individuals would have sex equal to {data}`.INDIVIDUAL_TYPE_HERMAPHRODITE` -(which is -1). - -Several fields associated with individuals are also available as numpy arrays, -across all individuals at once: -{attr}`tskit.TreeSequence.individuals_location`, -{attr}`tskit.TreeSequence.individuals_population`, -{attr}`tskit.TreeSequence.individuals_time` (also see -{func}`.individual_ages` and {func}`.individual_ages_at`). -Using these can sometimes be easier than -iterating over individuals as above. For example, -suppose that we want to randomly sample 10 individuals alive and older than 2 time steps -from each of the populations at the end of the simulation, -and simplify the tree sequence to retain only those individuals. -This can be done using the numpy arrays returned by {func}`.individual_ages` -and `.individuals_population` as follows: - -```{code-cell} -ages = pyslim.individual_ages(ts) -adults = alive[ages[alive] > 2] -pops = [ - [i for i in adults if ts.individual(i).metadata['subpopulation'] == k] - for k in [1, 2] -] -sample_inds = [np.random.choice(pop, 10, replace=False) for pop in pops] -sample_nodes = [] -for samp in sample_inds: - for i in samp: - sample_nodes.extend(ts.individual(i).nodes) -sub_ts = ts.simplify(sample_nodes) -``` - -Note that here we have used the *subpopulation* attribute that SLiM places in metadata -to find out where each individual lives at the end of the simulation. -We might alternatively have used the *population* attribute of Nodes - -but, this would give each individual's *birth* location. - -The resulting tree sequence does indeed have fewer individuals and fewer trees: - -```{code-cell} -print(f"There are {sub_ts.num_mutations} mutations across {sub_ts.num_trees} distinct\n" - f"genealogical trees describing relationships among {sub_ts.num_samples} sampled genomes,\n" - f"with a mean overall genetic diversity of {sub_ts.diversity()}.") -``` - - -## Vacant nodes - -As discussed in [the Overview](sec_overview_vacant_nodes), -if not all individuals have two copies of the chromosome stored in the tree sequence, -then some nodes will be *vacant*, -which means they are merely a placeholder and don't represent actual genetic material. -The presence of these nodes can cause problems. -For instance, running an msprime simulation backwards from -a tree sequence with vacant sample nodes -(as in {numref}`figure {number} ` of the Overview) -would also simulate ancestry of the vacant nodes. -For this reason, {func}`.recapitate` removes these nodes -from the sample before running msprime, -which makes it so their ancestry will not be simulated. -Similarly, at present {ref}`statistics in tskit` -do not account for missing data, so will return incorrect results -if these vacant nodes are not removed from the sample. - -To be clear, the vacant nodes will still be present, -just not marked as samples (i.e., with the ``tskit.NODE_IS_SAMPLE`` -flag removed from their node flags). -Once they are not part of the sample, -they are essentially invisible to most operations. -However, it is helpful to know that they are there. -Why not remove them entirely, e.g., with ``simplify()``? -They are kept because if you wish to read the tree sequence back into SLiM -then you'll need them; -they can put them back in the sample after being removed -with {func}`.restore_vacant`. -If you would like to remove the vacant nodes from the sample for -other reasons, you can use {func}`.remove_vacant`. - - -## Historical individuals - -As we've seen, a basic tree sequence output by SLiM only contains the currently alive -individuals and the ancestral nodes (genomes) required to reconstruct their genetic -relationships. But you might want more than that. For example, there may be individuals -who are not alive any more, but whose complete ancestry you would like to know. Or -perhaps you'd like to know how the final generation relates to particular individuals in -the past. Or it may be that you want to access the spatial location of historical genomes -(which, for technical reasons is linked to individuals, not to genomes). The solution is -to *remember* an individual during the simulation, using the SLiM function -``treeSeqRememberIndividuals()``. Individuals can be Remembered in two ways, as -described below. - - - -```{figure} _static/pedigree_remember.png ---- -scale: 40% -align: right -name: pedigree_remember ---- -Individuals not alive in the last generation may still be present in the tree sequence -if they are either remembered permanently (purple), -or simply retained with ``permanent=F`` (dotted circle). -``` - - - -(sec_remembering_individuals)= - -### Permanently remembering individuals - -By default, a call to ``treeSeqRememberIndividuals()`` will permanently remember one or -more individuals, by marking their nodes as actual samples: the simulated equivalent of -ancient DNA dug out of permafrost, or stored -in an old collecting tube. This means any tree sequence subsequently recorded will always -contain this individual, its nodes (now marked as samples), and its full ancestry. As -with any other sample nodes, any permanently remembered individuals can be removed from -the tree sequence by [](sec_tutorial_simplification). The result of remembering an -individual in the [introductory example](sec_left_in_tree_sequence) is pictured on the right. - - -(sec_retaining_individuals)= - -### Retaining individuals - -Alternatively, you may want to avoid treating historical individuals and their genomes as -actual samples, but temporarily *retain* them as long as they are still relevant to -reconstructing the genetic ancestry of the sample nodes. This can save some computational -burden, as not only will nodes and individuals be removed once they are no longer -ancestral, but also the full ancestry of the retained individuals does not need to be -kept. You can retain individuals in this way by using -``treeSeqRememberIndividuals(..., permanent=F)``. - -Since a retained individual's nodes are not marked as samples, they are subject to the -[normal removal process](sec_left_in_tree_sequence), and it is possible to end up -with an individual containing only one genome, as shown in the diagram. However, as soon -as *both* nodes of a retained individual have been lost, the individual itself is deleted -too. - -Note that by default, nodes are only kept if they mark a coalescent point (MRCA or branch -point) in one or more of the trees in a tree sequence. This can be changed by -initialising tree sequence recording in SLiM using -``treeSeqInitialize(retainCoalescentOnly=F)``. SLiM will then -preserve all retained individuals while they remain in the genealogy, even if their nodes -are not coalescent points in a tree (so-called "unary nodes"). Similarly, if you later -decide to reduce the number of samples via [](sec_tutorial_simplification), -retained individuals will be kept only if they are still MRCAs in the ancestry of the -selected samples. To preserve them even if their nodes are not coalescent points, you -can specify ``ts.simplify(selected_samples, keep_unary_in_individuals=True)``. - -:::{todo} -Add SLiM code which includes retaining and remembering, and perhaps some python code -to show them. -::: - -(sec_remembering_everyone)= - - -### Remembering everyone - -Although not needed to reconstruct full genomic history, it is perfectly possible to -apply ``treeSeqRememberIndividuals()`` to every individual in every generation of a -simulation (i.e. everyone who has ever lived). If you simply mark everyone for temporary -retention, it should not increase the memory burden of your simulation much: most -individuals will be removed as the simulation progresses, since they will not contain -coalescent nodes. However, if you use ``treeSeqInitialize(retainCoalescentOnly=F)``, -the number of individuals in the resulting tree sequence is likely to become very large, -and the efficiencies provided by tree sequence recording will be substantially reduced. -Indeed in this case, retaining will be much the same as permanently remembering everyone -who has ever lived. Nevertheless, if you are willing to sacrifice enough computer memory, -either of these is (perhaps surprisingly) possible, even for medium-sized simulations. - - - -(sec_individual_flags)= - -### Individual flags - -We have seen that an individual can appear in the tree sequence because it was -Remembered, Retained, or alive at the end of the simulation (note these -are not mutually exclusive). The ``Individual.flags`` value stores this information. -For example, to count up the different individual types, we could do this: - -:::{todo} -Update this code with the simulation above so that we have some remembered and -retained individuals present -::: - -```{code-cell} -indiv_types = {"remembered" : 0, - "retained" : 0, - "alive" : 0} -for ind in ts.individuals(): - if ind.flags & pyslim.INDIVIDUAL_REMEMBERED: - indiv_types['remembered'] += 1 - if ind.flags & pyslim.INDIVIDUAL_RETAINED: - indiv_types['retained'] += 1 - if ind.flags & pyslim.INDIVIDUAL_ALIVE: - indiv_types['alive'] += 1 - -for k in indiv_types: - print(f"Number of individuals that are {k}: {indiv_types[k]}") -``` - -:::{note} -In previous versions of SLiM/pyslim, the first generation of individuals were -kept in the tree sequence, to allow [](sec_tutorial_recapitation). With the -addition of the ``keep_input_roots=True`` option to the -[](sec_tutorial_simplification) process, this is no longer necessary, -so these are no longer present, unless you specifically Remember them. -::: - - -## Generating intial diversity with msprime - -Suppose now that we'd like to *start* a SLiM simulation -with the result of a coalescent simulation. -For instance, we might want to do this instead of recapitating -if we wanted to use msprime to generate genetic diversity that -would then be selected on during the SLiM simulation. -To do this, we'll: -1. simulate a tree sequence with msprime, -2. add SLiM information to the nodes and individuals, -3. add SLiM mutations, and -4. write it out to a ``.trees`` file. - -First, we'll (1) run a simulation of 1 Mb of genome sampled in 200 diploids -in a population of 1000 diploids, -and (2) use the {func}`.annotate` function to add default SLiM metadata to the result: -```{code-cell} -demog = msprime.Demography() -demog.add_population(initial_size=1000) -ts = msprime.sim_ancestry( - samples=200, - demography=demog, - recombination_rate=1e-8, - sequence_length=1e6, - random_seed=5) -ts = pyslim.annotate(ts, model_type="nonWF", tick=1) -assert ts.num_individuals == 200 -assert ts.num_samples == 400 -``` -We have set ``tick`` to 1; -this means that as soon as we load the tree sequence into SLiM, -SLiM will set the current time counter to 1. -(If we set ``tick`` to 100, then any script blocks scheduled to happen before 100 -would not execute after loading the tree sequence.) - -We now have 200 diploids (so, 400 sampled nodes). -Here's individual 199, which hsa SLiM metadata: -```{code-cell} -:tags: ["remove-output"] -ind = ts.individual(199) -print(ind) -``` -```{code-cell} -:tags: ["remove-input"] -util.pp(ind) -``` -Looking at the ``metadata`` above, we see the default values are ``age=0`` -hermaphrodites (``sex=-1``), for instance. - -Now let's add SLiM mutations. -These will be neutral, as {func}`msprime.sim_mutations` -doesn't have the ability to dynamically modify the selection coefficients -stored in the mutation metadata. -To modify the mutations to be under selection, -see [](sec_vignette_coalescent_diversity). -```{code-cell} -ts = pyslim.add_mutation_metadata( - msprime.sim_mutations( - ts, rate=1e-8, - model=msprime.SLiMMutationModel(type=0), - random_seed=9 - ) -) -``` -The resulting mutations are in SLiM format. -Now, each `mutation` object in the tree sequence represents -some number of SLiM mutations, whose SLiM IDs are stored in the `derived_state`. -For instance, here's which SLiM mutation(s) the first mutation -in the tree sequence represents: -```{code-cell} -ds = ts.mutation(0).derived_state -print(f"SLiM IDs: {ds}") -``` -To see the information about these, we pull their information out -using {func}`.mutation_metadata`, which provides a dictionary -indexed by the SLiM IDs: -```{code-cell} -:tags: ["remove-output"] -mut_metadata = pyslim.mutation_metadata(ts) -for sid in ds.split(","): - print(mut_metadata[int(sid)]) -``` -```{code-cell} -:tags: ["remove-input"] -for sid in ds.split(","): - util.pp(mut_metadata[int(sid)]) -``` -**Important:** the {func}`.mutation_metadata`-returned dictionary -is indexed by **ints**, not strings, so be sure to convert your -SLiM IDs to ints before looking them up! - -Finally, we write this out to a file that can be loaded in to SLiM: -```{code-cell} -ts.dump("initialize_nonWF.trees") -``` - -Here's a minimal SLiM script that reads in the tree sequence file -and runs it for a bit longer. - -```{literalinclude} neutral_restart.slim -``` - -```{code-cell} -:tags: ["hide-output"] -%%bash -slim -s 123 neutral_restart.slim -``` - -A more in-depth example is provided at [](sec_vignette_coalescent_diversity). -See the SLiM manual for more about this operation. - - -## Nucleotide-based models - -By default, {func}`.annotate` produces standard SLiM mutations, not "nucleotide-based" mutations. -To demonstrate how to further adjust the starting state of the simulation, -we'll further adjust the tree sequence `ts` from the previous section -to add in information about nucleotides. - -First, we need to set the ``nucleotide_based`` property in top-level metadata. -To do this, there are two possibly unfamiliar things: -first, we need to modify the underlying {class}`tskit.TableCollection` -(since tree sequences are immutable); -and second, we have to extract the metadata, modify it, and put it back in -(modifying it in-place will silently do nothing): - -```{code-cell} -tables = ts.dump_tables() -md = tables.metadata -md['SLiM']['nucleotide_based'] = True -tables.metadata = md -ts = tables.tree_sequence() -``` - -Next, we need to generate a reference sequence -and nucleotides for each mutation. -This is easy with {func}`.generate_nucleotides`: - -```{code-cell} -ts = pyslim.generate_nucleotides(ts) -ts.dump("initialize_nonWF_nuc.trees") -ts.reference_sequence.data[:20] -``` - -Now, mutations have a ``nucleotide`` property in metadata that is not ``-1``: - -```{code-cell} -:tags: ["remove-output"] -mut_metadata = pyslim.mutation_metadata(ts) -m = ts.mutation(0) -md = [mut_metadata[int(k)] for k in m.derived_state.split(",")] -print(m) -for x in md: - print(x) -``` - -```{code-cell} -:tags: ["remove-input"] -util.pp(m) -for x in md: - util.pp(x) -``` - -We can see which nucleotide is the derived state produced by each mutation -by indexing the {data}`.NUCLEOTIDES` object: - -```{code-cell} -for k in range(3): - m = ts.mutation(k) - print(f"Mutation {k}: position {ts.site(m.site).position}, time {m.time}") - for sid in m.derived_state.split(","): - md = mut_metadata[int(sid)] - print(f" nucleotide: {pyslim.NUCLEOTIDES[md['nucleotide']]}") -``` - -Here's a script minimally modified from the above to be nucleotide-based: - -```{literalinclude} neutral_restart.slim -``` - -```{code-cell} -:tags: ["hide-output"] -%%bash -slim -s 123 neutral_nucleotide_restart.slim -``` - - -## Extracting information about selected mutations - -Here is a simple SLiM simulation with two types of mutation: -`m1` are deleterious, and `m2` are beneficial. -Let's see how to extract information about these mutations. - -```{literalinclude} selection.slim -``` -```{code-cell} -:tags: ["hide-output"] -%%bash -slim -s 23 selection.slim -``` - -First, let's see how many mutations there are: - -```{code-cell} -ts = tskit.load("selection.trees") -print(f"Number of sites: {ts.num_sites}\n" - f"Number of mutations: {ts.num_mutations}") -``` - -Note that there are more mutations than sites; -that's because some sites have multiple mutations. -The information about the mutation is put in the mutation's metadata. -Here's the first mutation: - -```{code-cell} -:tags: ["remove-output"] -mut_metadata = pyslim.mutation_metadata(ts) -m = ts.mutation(0) -md = [mut_metadata[int(k)] for k in m.derived_state.split(",")] -print(m) -for x in md: - print(x) -``` - -```{code-cell} -:tags: ["remove-input"] -util.pp(m) -for x in md: - util.pp(x) -``` - -Since we haven't explicitly defined any traits in this simulation, -the only trait is fitness, and the `effect_size` listed under `per_trait` -for this mutation is simply its selection coefficient. -Furthermore, `m.site` tells us the ID of the *site* on the genome that the mutation occurred at, -and we can pull up information about that with the `ts.site( )` method: - -```{code-cell} -:tags: ["remove-output"] -s = ts.site(m.site) -md = [ - mut_metadata[int(k)] for m in s.mutations - for k in m.derived_state.split(",") -] -print(s) -for x in md: - print(x) -``` - -```{code-cell} -:tags: ["remove-input"] -util.pp(s) -for x in md: - util.pp(x) -``` - -This mutation occurred at position 54 along the genome (from `site.position`) -which previously had no mutations (since `site.ancestral_state` is the empty string, `''`) -and was given SLiM mutation ID 1997358 (`m.derived_state`). -The metadata (`mut_metadata[1997358]`, a dict) tells us that -the mutation has selection coefficient -0.1129 and occurred in population 1 in generation 999, -which was 0 generations ago. -This is not a nucleotide model, so the nucleotide entry is `-1`. -Note that `m.time` and the `slim_time` entry in metadata are in this case redundant: -they contain the same information, but the first is in tskit time -(i.e., number of steps before the tree sequence was written out) -and the second is using SLiM's internal "tick" counter. - -Also note that each mutation may have associated a *list* of SLiM mutations, -each with their own metadata. -That's because of SLiM's mutation stacking feature. -We know that some sites have more than one mutation, -so to get an example let's pull out one such mutation. - -Let's pull out a mutation that was stacked on top of another one: - -```{code-cell} -:tags: ["remove-output"] -for m in ts.mutations(): - if m.parent != tskit.NULL: - break - -pm = ts.mutation(m.parent) -md = [mut_metadata[int(k)] for k in m.derived_state.split(",")] -pmd = [mut_metadata[int(k)] for k in pm.derived_state.split(",")] - -print(m) -for x in md: - print(x) -print(pm) -for x in pmd: - print(x) -``` - -```{code-cell} -:tags: ["remove-input"] -util.pp(m) -for x in md: - util.pp(x) -util.pp(ts.mutation(m.parent)) -for x in pmd: - util.pp(x) -``` - -This mutation (which is `ts.mutation(330)` in the tree sequence) -was the result of SLiM adding a new mutation of type `m1` and selection coefficient -0.1547 -on top of an existing mutation, of type `m2` and with (whopping) selection coefficient 1.737. -This happened at generation 998 (i.e., at tskit time 1.0 time units ago), -and the older mutation occurred at generation 83 (at tskit time 916 time units ago). -The older mutation has SLiM mutation ID 1994163, -and the newer mutation had SLiM mutation ID 164833, -so the resulting "derived state" is `'1994163,164833'`. - -Now that we understand how SLiM mutations are stored in a tree sequence, -let's look at the allele frequencies. -The allele frequency spectrum for *all* mutations can be obtained using the -{meth}`tskit.TreeSequence.allele_frequency_spectrum` method, -shown here for a sample of size 10 to make the output easy to see: - -```{code-cell} -samps = np.random.choice(ts.samples(), 10, replace=False) -afs = ts.allele_frequency_spectrum([samps], span_normalise=False, polarised=True) -print(afs.astype('int')) -``` - -(The `span_normalise=False` argument gives us counts rather than a density per unit length.) -This shows us that there are 3929 alleles that are found among the tree sequence's samples -that are not present in any of our 10 samples, 585 that are present in just one, etcetera. -The surprisingly large number that are near 50% frequency are perhaps positively selected -and on their way to fixation: we can check if that's true next. -You may have noticed that the sum of the allele frequency spectrum is 5029, -which is not obviously related to the number of mutations (5861) *or* the number of sites (5848). -That's because each derived allele that is inherited by some but not all of the samples -in the tree sequence is counted in the polarised allele frequency spectrum: -Fixed mutations, or mutations that were entirely "overwritten" by subsequent mutations, -do not contribute. -Here's how we can check this: - -```{code-cell} -afs_total = 0 -for v in ts.variants(): - if len(set(v.genotypes)) > 1: - afs_total += len(set(v.genotypes) - set([0])) -print(afs_total, sum(afs)) -``` - -These are equal, verifying our interpretation. - -At time of writing, we don't have a built-in ``allele_frequency`` method, -so we'll use the following snippet: - -```{code-cell} -def allele_counts(ts, sample_sets=None): - if sample_sets is None: - sample_sets = [ts.samples()] - def f(x): - return x - return ts.sample_count_stat(sample_sets, f, len(sample_sets), - span_normalise=False, windows='sites', - polarised=True, mode='site', strict=False) -``` - -This will return an array of counts, one for each site in the tree sequence, -giving the number of *all* nonancestral alleles at that site found in the sample set -(so, lumping together any of the various derived alleles we were looking at above). -Then, we'll separate out the counts in this array to get the derived frequency spectra -separately for sites with (a) only `m1` mutations, (b) only `m2` mutations, -and (c) both (for completeness, if there are any). -First, we need to know which site has which of these three mutation types (m1, m2, or both): - -```{code-cell} -mut_type = np.zeros(ts.num_sites) -for j, s in enumerate(ts.sites()): - mt = [] - for m in s.mutations: - for sid in m.derived_state.split(","): - md = mut_metadata[int(sid)] - mt.append(md["mutation_type"]) - if len(set(mt)) > 1: - mut_type[j] = 3 - else: - mut_type[j] = mt[0] -``` - -Now, we compute the frequency spectrum, and aggregate it -to produce the allele frequency spectrum separately by mutation type. -We'll use the function `np.bincount` to do this efficiently: - -```{code-cell} -freqs = allele_counts(ts, [samps]) -# convert the n x 1 array of floats to a vector of integers -freqs = freqs.flatten().astype(int) -mut_afs = np.zeros((len(samps)+1, 3), dtype='int64') -for k in range(3): - mut_afs[:, k] = np.bincount(freqs[mut_type == k+1], minlength=len(samps) + 1) - -print(mut_afs) -``` - -The first column gives the AFS among these 10 samples for the deleterious alleles, -the second for the beneficial mutations; -the third column for the few sites that had both types of mutation. -Interestingly, there are similar numbers of both types of mutation at intermediate frequency: -perhaps because beneficial mutations are sweeping linked deleterious alleles along with them. -Many fewer benefical alleles are at low frequency, however. - -Finally, let's pull out information on the allele with the largest selection coefficient. - -```{code-cell} -:tags: ["remove-output"] -sel_coeffs = np.array([ - sum(mut_metadata[int(k)]["per_trait"][0]["effect_size"] - for k in m.derived_state.split(",")) - for m in ts.mutations() -]) -which_max = np.argmax(sel_coeffs) -m = ts.mutation(which_max) -print(f"Max selection coefficient: {sel_coeffs[which_max]} for site {m.site}") -ts.site(m.site) -``` - -```{code-cell} -:tags: ["remove-input"] -print(f"Max selection coefficient: {sel_coeffs[which_max]} for site {m.site}") -util.pp(ts.site(m.site)) -``` - -This allele had a whopping selection coefficient of 5.69 -and appeared fairly late in the simulation. -Let's find its frequency in the full population: - -```{code-cell} -full_freqs = allele_counts(ts) -print(f"The allele is found in {full_freqs[m.site][0]} copies\n" - f"out of {ts.num_nodes} genomes.") -``` - -The allele is above 50% in the population, so it is probably on its way to fixation. -Using its SLiM ID (which is shown in its derived state, ``305447``), -we could reload the tree sequence into SLiM, -restart the simulation, and use its ID to track its subsequent progression. - - -## Possibly important technical notes - -Also known as "gotchas". - -1. If you use msprime to simulate a tree sequence, and then use that to initialize a SLiM simulation, - you have to specify the same sequence length in both: as in the examples above, - the ``sequence_length`` argument to {func}`msprime.sim_ancestry` should be equal to the SLiM sequence length - *plus 1.0* (e.g., if the base positions in SLiM are 0 to 99, then there are 100 bases in all, - so the sequence length should be 100). - -2. Make sure to distinguish *individuals* and *nodes*! - ``tskit`` "nodes" correspond to SLiM "genomes". - Individuals in SLiM are diploid, so normally, each has two nodes (but retained - individuals may have nodes removed by simplification: see below). - -3. As described above, the Individual table contains entries for - - 1. the currently alive individuals, - 2. any individuals that have been permanently remembered with - ``treeSeqRememberIndividuals()``, and - 3. any individuals that have been temporarily retained with - ``treeSeqRememberIndividuals(permanent=F)``. Importantly, the nodes in these - individuals are *not* marked as sample nodes, so they can be lost during - simplification. This means that a retained individual may only have one node (but - if both nodes are lost due to simplification, the individual is removed too, and - will not appear in the Individual table). - -4. SLiM requires that the two nodes corresponding to the haplosomes of each individual - are adjacent in the node table, and are sorted by haplosome ID. - SLiM always writes out tree sequences like this, but it is possible to make - tree sequences in python that are legal otherwise but don't satisfy this requirement. diff --git a/docs/vignette_coalescent_diversity.md b/docs/vignette_coalescent_diversity.md deleted file mode 100644 index 43dc4aa8..00000000 --- a/docs/vignette_coalescent_diversity.md +++ /dev/null @@ -1,549 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -```{code-cell} -:tags: [remove-cell] - -import pyslim, tskit, msprime -from IPython.display import SVG -import numpy as np -import matplotlib -import matplotlib.pyplot as plt - -import util -``` - -```{eval-rst} -.. currentmodule:: pyslim -``` - -(sec_vignette_coalescent_diversity)= - -# Vignette: Starting with diversity generated by coalescent simulation - -This vignette shows how to simulate history with msprime, -add SLiM mutations to it and assign them selection coefficients, -then run a SLiM simulation using this as a starting point. - -Simulations of large populations with selection can be costly, -especially if we need to run a lengthy "burn-in" period to get the -genetic diversity for selection to act on. -Sometimes, the precise form of the burn-in is not important, -and so a *neutral* burn-in is acceptable - allowing us to use msprime. -For instance, suppose we'd like to simulate a lab experiment -in which we take high-diversity organisms from the wild and subject them -to selection for a few dozen generations. -Genetic diversity in the wild is certainly not neutral, but then again, -we don't quite know what it *does* look like, so a coalescent simulation -would be better than nothing. The key attribute of reality we'd like to -approximate is the joint distribution of allele frequencies and effect -sizes. If the alleles affect a trait under stabilizing selection, we'd -expect a negative correlation between the two. On the other hand, -if the trait we're selecting on in the lab is not under strong selection -in the wild, there might not be much of a relationship. -This is a simple example, to show how to do this: -the trait under selection is just fitness, -and there is no relationship between allele frequency and effect size. - -The steps will be: - -1. Run a coalescent simulation with msprime. -2. Add SLiM metadata to the nodes, individuals, and populations. -3. Add SLiM mutations with msprime, - and edit the mutation metadata to assign selection coefficients. -4. Run the SLiM portion of the simulation. -5. Do some descriptive analysis of the results of selection. -6. Add neutral mutations to the tree sequence. -7. Do some descriptive analysis of genetic diversity along the genome. - - -## Mutation and recombination maps - -In this model, -we'll also demonstrate how to modulate the mutation and recombination rate -along the genome. We'll do a simple example: a 9MB genome -with three equally-sized domains. -The first and last third of the genome will have high recombination -(5e-8 per generation per bp), -and the middle third will have low recombination -(0.5e-8 per generation per bp). -The mutation rate will be constant (3e-8 per generation per bp), -but a lower proportion of mutations in the middle region are under selection: -on the outside thirds, 1% of mutations are beneficial with a selection coefficient -drawn from an Exponential distribution, -and in the middle third, only 0.1% are (but with the same distribution of selection coefficients). -This implies that the mutation rate *of beneficial mutations* on the ends -is {math}`0.01 \times 30^{-8}` = 3e-10, and in the middle is 3e-11. -We'll simulate these first, and only add the neutral mutations after everything else, -at rates 2.97e-8 on the ends, and 2.997e-8 in the middle. - - -## The coalescent simulation - -First, we'll use msprime -to simulate the demographic history of 2,000 smallish chromosomes -({math}`N_e = 1,0000` diploids) -from a population of 10,000 diploids total. - -```{code-cell} -breaks = [0, 3000000, 6000000, 9000000] -recomb_map = msprime.RateMap( - position = breaks, - rate = [5e-8, 0.5e-8, 5e-8]) -demog_model = msprime.Demography() -demog_model.add_population(initial_size=10000) -ots = msprime.sim_ancestry( - samples=1000, - demography=demog_model, - random_seed=5, - recombination_rate=recomb_map) -``` - -## Annotate everyone - -At this point, we have genealogical information: individuals, -nodes (chromosomes), and relationships between them, -but no genetic diversity; no mutations. -First, we'll add SLiM metadata to all of these things, -a procedure we call "annotating". - -```{code-cell} -ots = pyslim.annotate(ots, model_type="WF", tick=1, stage="late") -``` - -This method adds default metadata to everything that needs it: -in this case, all individuals, all nodes that are part of alive individuals, -and all populations referenced by nodes. -These default values are returned by {func}`.default_slim_metadata` -(e.g., all individuals are hermaphrodite, all chromosomes are autosomal); -see {func}`.annotate` for more information. - -## Add SLiM mutations - -Next, we're going to use the {class}`msprime.SLiMMutationModel` to add mutations -to the tree sequence. These will carry SLiM metadata, but this metadata -will say that the mutations are neutral. So, we'll then need to modify their metadata -after the fact to have selection coefficients drawn from some distribution. -(Remember, our motivation here is that we are using msprime to obtain -a plausible level of functional standing genetic variation -on which selection can act: -imagine that the mutations were (nearly) neutral in the wild, -but then became subject to strong selection in the lab, for some reason.) -We'll want this to be as if we'd done it in a burn-in script in SLiM: - -``` - initialize() { - ... - initializeMutationType("m2", 0.5, "e", 0.04); - initializeMutationRate(3e-10); - } - - fitness(m2) { - return 1.0; - } -``` - -In other words, we'd like to pull selection coefficients from an exponential distribution -with mean 0.04 (but, of course, this is a coalescent simulation, so the -dynamics of the mutations up until this point have been neutral). -Note that the dominance coefficient is *not* stored in the tree sequence: -it gets set in the SLiM recipe -because it's a property of the mutation type, not of individual mutations, in SLiM. -Here's how to add SLiM mutations with msprime: - -```{code-cell} -mut_map = msprime.RateMap( - position=breaks, - rate=[0.03e-8, 0.003e-8, 0.03e-8]) -mut_model = msprime.SLiMMutationModel(type=2) -ots = pyslim.add_mutation_metadata( - msprime.sim_mutations( - ots, - rate=mut_map, - model=mut_model, - keep=True, - random_seed=12), - mutation_type=2, -) -print(f"The tree sequence now has {ots.num_mutations} mutations, at " - f"{ots.num_sites} distinct sites.") -``` - -Note the ``type=2`` argument to {func}`.add_mutation_metadata`: -this means the mutations will be of type "m2" in SLiM (and, so you must -initialize that mutation type in the recipe that loads this tree sequence in). - -Now, we'll assign selection coefficients. -This is easier than in versions of SLiM before 6.0, -because we simply want to assign each mutation an independent selection coefficient -Recall that to accomodate mutation stacking in SLiM, -a mutation metadata entry is in fact a *list* of metadata entries, -one for each of the SLiM mutations that are stacked at this position. -The SLiM IDs of these mutations are available (in the same order) -as a comma-separated list of integers in the derived state of the mutation. -So, in case some SLiM mutations appear in more than one mutation -in the tree sequence, we will build a map from SLiM ID to selection coefficient: -``mut_map[k]`` will give the selection coefficient of the SLiM mutation with -SLiM mutation ID ``k``. - -```{code-cell} -rng = np.random.default_rng(seed=1234) -ts_metadata = ots.metadata -for md in ts_metadata["SLiM_mutation_list"]: - md["per_trait"][0]["effect_size"] = rng.exponential(scale=0.04) - -tables = ots.dump_tables() -tables.metadata = ts_metadata -``` - - -## Load into SLiM - -Before loading the tree sequence into SLiM, we should check the top-level metadata. -We can see this with ``tables.metadata``: - -```{code-cell} -:tags: ['remove-output'] -tables.metadata["SLiM"] -``` -```{code-cell} -:tags: ['remove-input'] -util.pp(tables.metadata["SLiM"]) -``` - -We should edit this to match our planned slimulation - -particularly the ``model_type`` (WF or nonWF) and the ``tick``. -The ``tick`` tells SLiM what value to set the tick counter to -once this tree sequence is loaded. In principle, it can be set to anything, -independently of the times in the tree sequence, -because the times in the tree sequence are measured in units of -"time before the end"; and the ``tick`` that gets -passed to SLiM sets what that "end time" is, in SLiM's time. -However, if you change this, the ``slim_time`` attributes in mutation metadata -will not be accurate. This is harmless, unless you do something with mutations' -times yourself. - -The ``model_type`` is already Wright-Fisher, but just to demonstrate how to -edit the metadata, let's make sure, -and then we'll write the tree sequence to a file. - -```{code-cell} -ts_metadata["SLiM"]["model_type"] = "WF" -tables.metadata = ts_metadata -ots = tables.tree_sequence() -ots.dump("vignette_annotated.init.trees") -``` - -Now for the SLiM recipe. -This simply continues selected mutations as before -(with mutation rate 1e-10 per bp per generation -and the same distribution of fitness effects). -The population size is determined by the number of individuals that were -read in from the tree sequence. -We need to make sure that the genome lengths match, -so we provide that as a constant ``L``, that will be provided at run time. -To facilitate later analysis, we'll also "Remember" the individuals -present at the *start* of the simulation, -so that they will remain in the tree sequence. - -```{literalinclude} reload_annotated.slim -``` - -Note that the simulation only has selected mutations (of type ``m2``), -but as we'll add in type ``m1`` mutations later, -we've declared them in the recipe as a placeholder. - -We could run this on the command line as -``slim -d L=100000000 reload_annotated.slim``, -but this time we'll stay within python, -and obtain the sequence length programatically: -```{code-cell} -import subprocess -msg = subprocess.check_output( - ["slim", "-d", f"L={int(ots.sequence_length - 1)}", - "-s", "5", "reload_annotated.slim"]) -print(msg.decode()) -``` -This runs quickly, since it's only 100 generations. - -## Analyze results - -First, let's look at what mutations are present. -```{code-cell} -ts = tskit.load("vignette_annotated.trees") -num_stacked = np.array([len(m.derived_state.split(",")) for m in ts.mutations()]) -init_time = ts.metadata['SLiM']['tick'] -old_mut = np.array([m.time > init_time - 1 - 1e-12 for m in ts.mutations()]) -assert sum(old_mut) == ots.num_mutations -print(f"There are {ts.num_mutations} present at {ts.num_sites} distinct sites.") -print(f"Of these, {np.sum(num_stacked > 1)} have more than one stacked mutation,") -print(f"and {np.sum(old_mut)} were produced by msprime.") -``` - -Most of the mutations were present as initial diversity, -but a few were added during the course of the simulation. -Along the way we did a consistency check, that the number of "old" mutations -matches the number of mutations we had in the tree sequence we loaded into SLiM. -Since we ran SLiM for 100 time steps, but loaded the tree sequence in during the ``late()`` -stage of time step 1, the "old" mutations are those -from at least 99 units of time ago -(and the 1e-12 is necessary for floating-point error). - -A simple thing to look at next is: how did the selected mutations -change in frequency? We can do this thanks to our having -Remembered the first generation. -First, we'll compute all allele frequencies -among both the first generation and the final generation: - -```{code-cell} -times = list(set(ts.individuals_time)) -times.sort() -print("The times ago at which individuals in the tree sequence were born:", times) -# The times ago at which individuals in the tree sequence were born: [0.0, 100.0] -nodes_by_time = [ts.samples(time=t) for t in times] - -num_nodes = np.array([len(x) for x in nodes_by_time]) -p = ts.sample_count_stat(nodes_by_time, lambda x: x/num_nodes, 2, windows='sites', - strict=False, span_normalise=False, polarised=True) -mut_metadata = pyslim.mutation_metadata(ts) -s = np.array([sum([sum([mut_metadata[int(k)]["per_trait"][0]["effect_size"] - for k in m.derived_state.split(",")]) - for m in site.mutations]) for site in ts.sites()]) -``` - -To do this, we used the `time=t` argument to {meth}`tskit.TreeSequence.samples` -to find the nodes alive at each of the two times (0 and 100 generations ago); -then computed an array ``p`` of allele frequencies, with one row per site, -the first column giving the frequency among the initial generation, -and the second giving the frequency at the end. -We also pull out ``s``, the selection coefficients. -This last bit is a bit complex because each site can have more than one mutation, -and each tree sequence mutation can represent more than one SLiM mutation. -And, the way we've dealt with this is a bit of a hack, -so let's look at that site with multiple mutations: - -```{code-cell} -for j, v in enumerate(ts.variants()): - if len(v.site.mutations) > 1: - print(f"Site {j} has {num_stacked[j]} stacked mutations, " - f"with total derived allele frequency {p[j]} " - f"and sum of selection coefficients {s[j]}.") - print(f"The allele frequencies are:") - for k, a in enumerate(v.alleles): - print(f" '{a}': {sum(v.genotypes == k)}") - print(v.site) -``` - -There were two mutations at this site, both before the SLiM portion of the simulation -started. One happened on the background of the other, -and no genomes either today or in the initial generation carry the first allele in isolation. -Their effects combine in SLiM, so treating this as a single allele is correct. - -Now, we'll plot the initial and final allele frequencies, -with point size and color determined by the selection coefficient: - -```{code-cell} -fig, ax1 = plt.subplots(figsize=(5, 4)) -dp = ax1.scatter(p[:, 1], p[:,0], c=s, s=s*800, label='frequencies') -ax1.set_xlabel("initial allele frequency") -ax1.set_ylabel("final allele frequency") -fig.colorbar(dp, ax=ax1, label='selection coefficient'); -``` - - -Unsurprisingly, mutations that had a large change in allele frequency seem -to be biased towards ones with higher selection coefficients, -and those that were initially present at moderate frequency but were lost -are biased towards smaller selection coefficients. - - -## Add neutral mutations - -In real data, of course, we don't get to observe selection coefficients. -We haven't added in neutral mutations until this point for efficiency - -they are just bookkeeping, and do not affect the course of the simulation -in any way. For this reason, we can add them in after the fact, in a way -that is exactly equivalent to having kept track of them as we went along. - -Recall that out of an overall mutation rate of 3e-8, -we wanted 99% of the mutations to be neutral on the ends of the chromosome, -and 99.9% to be neutral in the middle. -So, we'll now add mutations at these rates, -using the same model of mutation as before. -The code is nearly the same as before, -with a few changes. -We've changed the ``type`` of the mutations -(so that neutral mutations will show up in SLiM as m1, -while selected mutations above were m2), -and we've asked these mutations to have SLiM mutation IDs -beginning at the ID where the previous mutations left off. -(This would be important were we to read this tree sequence -back in to SLiM; mutation IDs must be unique.) -And, importantly, we've added ``keep=True`` so that existing mutations -are not discarded. - -```{code-cell} -neutral_mut_map = msprime.RateMap( - position=breaks, - rate=[2.97e-8, 2.997e-8, 2.97e-8]) -next_id = pyslim.next_slim_mutation_id(ts) -neutral_mut_model = msprime.SLiMMutationModel( - type=1, - next_id=next_id) -mts = pyslim.add_mutation_metadata( - msprime.sim_mutations( - ts, - rate=neutral_mut_map, - model=neutral_mut_model, - keep=True, - random_seed=35), - mutation_type=1, -) -print(f"The tree sequence now has {mts.num_mutations} mutations,") -print(f"at {mts.num_sites} distinct sites.") -``` - -We've now got a lot more mutations! -And, we've got a lot more sites with multiple mutations: - -```{code-cell} -num_alleles = np.array([len(s.mutations) for s in mts.sites()]) -for k in range(1, max(num_alleles)+1): - print(f"There are {sum(num_alleles == k)} sites with {k} distinct alleles.") -``` - -To get a nice a picture of what's happened, -we'll pull out a tree that had a lot of mutations on it, -and print a picture of it, with mutations labeled by their type: - -```{code-cell} -mut_metadata = pyslim.mutation_metadata(mts) -for t in mts.trees(): - mt = [max([mut_metadata[int(k)]['mutation_type'] for k in m.derived_state.split(",")]) for m in t.mutations()] - if t.num_mutations > 12: - break - -ml = {m.id: str(mtype) for mtype, m in zip(mt, t.mutations())} -SVG( - t.draw_svg(mutation_labels=ml, - node_labels={}, - size=(400, 300)) -) -``` - - -On this tree each mutation is marked by a red "x", and labeled with its mutation type: -either "1", for newly added mutations, or "2", for selected mutations present during the SLiM portion. -(Note: this is a large tree, with 68,211 nodes! -But as usual, the main structure is visible -because most nodes coalesce very recently.) - -OK, but how exactly is this working? -Can a neutral mutation be added to a site that previously had a selected mutation? -The short answer is: yes, and new alleles stack on top of -existing alleles, but existing alleles replace new alleles. -This is equivalent to including them as the simulation went along, -by the additivity property of Poisson mutations: -it turns out that the following two ways of generating mutations along the genome -are equivalent: either -(a) placing a random Poisson number with mean {math}`\mu`, -and randomly choosing each one to be non-neutral with probability 0.01, or -(b) placing random, independent Poisson numbers of neutral and non-neutral mutations -with means {math}`0.99\mu` and {math}`0.01\mu` respectively. -Since the neutral ones don't affect the simulation otherwise, -we can add them in afterwards. -Now, when the mutation algorithm in msprime puts down a new mutation -at a site with mutations already existing, -it appends the newly generated SLiM mutation ID to the previous derived state, -and adds the metadata for the new SLiM mutation to the list of metadata -from the previous mutation. -However, it doesn't modify any existing mutations, -so their derived states (and metadata) are unchanged. -The result is that, from the point of view of SLiM, -neutral ("m1") mutations "stack" on top of any other mutations (neutral or selected), -while selected ("m2") mutations stack with each other, but replace any neutral mutations. -This "stacking policy" is not actually exactly implementable in SLiM, -but given that our newly added mutations are meant to be entirely neutral, -seems like a reasonable policy. -If you wanted some other arrangement (e.g., to have m1 stack on top of m2), -you could go through and modify derived states and metadata appropriately. - -Let's check there are any sites with stacked mutations of different types in the simulation. -There are indeed: - -```{code-cell} -:tags: ['remove-output'] -for site in mts.sites(): - if len(site.mutations) > 1: - types = [set([mut_metadata[int(k)]["mutation_type"] for k in mut.derived_state.split(",")]) - for mut in site.mutations] - if max(map(len, types)) > 1: - print(site) - for mut in site.mutations: - print(mut) - for k in mut.derived_state.split(","): - print(mut_metadata[int(k)]) -``` -```{code-cell} -:tags: ['remove-input'] -for site in mts.sites(): - if len(site.mutations) > 1: - types = [set([mut_metadata[int(k)]["mutation_type"] for k in mut.derived_state.split(",")]) - for mut in site.mutations] - if max(map(len, types)) > 1: - util.pp(site) - for mut in site.mutations: - util.pp(mut) - for k in mut.derived_state.split(","): - util.pp(mut_metadata[int(k)]) -``` - -In each of these, a neutral mutation has been put down on top of a selected mutation, -but stacked, so that any samples inheriting either of these mutations carries -the selected mutation. -For more discussion of how this works, see {class}`msprime.SLiMMutationModel`. - - -## Diversity along the genome - -Now that we've correctly added neutral mutations to the tree sequence, -and lengthily digested what exactly happened, -let's have a look at the result. -To do this, we'll compute two standard measures of genetic diversity -in windows along the genome: -nucleotide diverstiy (also called "Tajima's {math}`\pi`" or "mean density of pairwise differences"), -and Tajima's {math}`D` (with no known aliases). -This is easy to do thanks to the (statistics methods in tskit)[https://tskit.dev/tskit/docs/stable/stats.html]. - -```{code-cell} -windows = np.linspace(0, mts.sequence_length, 21) -pi = mts.diversity(mts.samples(), windows=windows) -taj_d = mts.Tajimas_D(mts.samples(), windows=windows) - - -fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(6,3), dpi=300) -mids = windows[1:] - np.diff(windows)/2 -ax1.set_xlabel("chromosome position (bp)") -ax1.set_ylabel("pairwise diversity") -ax1.plot(mids, pi, label="pairwise diversity") -ax2.set_xlabel("chromosome position (bp)") -ax2.set_ylabel("Tajima's D") -ax2.plot(mids, taj_d, label="Tajima's D"); -``` - - -The two statistics are very similar - perhaps unsurprisingly, because Tajima's D is calculated using -pairwise diversity, and we have a very large sample size (here, the entire population). -Tajima's D is negative across the entire genome, as the result of selection. -However, we don't see a strong difference between the three regions, -despite the stronger action of linked selection on the ends. diff --git a/docs/vignette_continuing.md b/docs/vignette_continuing.md deleted file mode 100644 index 8d60e211..00000000 --- a/docs/vignette_continuing.md +++ /dev/null @@ -1,238 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -```{code-cell} -:tags: [remove-cell] - -import pyslim, tskit, msprime -from IPython.display import SVG -import numpy as np -import util - -np.random.seed(1234) -``` - - -(sec_vignette_continuing)= - - -# Vignette: Following up with more coalescent simulation - -Previously, we saw how to use recapitation -to simulate the period *before* a SLiM simulation -with the coalescent simulator, msprime. -We can do the same thing *after* a period of SLiM simulation. -To demonstrate this, -below we'll run a simulation in which - -1. A population evolves neutrally for a long time, but then -2. it experiences strong positive selection on new mutations for 100 generations, and -3. evolves neutrally for another 1000 generations. - -To do this, we'll simulate step (2) first, with SLiM, -then recapitate to add step (1), and then "continue" the simulation using msprime to add in (3). - - -## Positive selection - -Here's a SLiM script that has rapid, strong selection acting genome-wide for 20 generations. -It is perhaps not very realistic, but it's dramatic. - -```{literalinclude} rapid_adaptation.slim -``` -```{code-cell} -%%bash -slim -s 5 rapid_adaptation.slim -``` - -We can see what happened in the GUI, -but let's pull some more statistics out of the tree sequence: -```{code-cell} -ts = tskit.load("rapid_adaptation.trees") - -# allele frequencies -p = ts.sample_count_stat( - [ts.samples()], lambda x: x/20000, 1, windows='sites', - span_normalise=False, polarised=True, strict=False) -print(f"There are {ts.num_sites} segregating sites, of which {np.sum(p > 0.25)}") -print(f"are at frequency above 25%, and {np.sum(p > 0.05)} are above 5%.") -``` - -The selection was, indeed, strong. - -## Recapitation - -Ok, now let's do phase (1), recapitating and mutating the result. -We'll add SLiM mutations with "mutation type" 0 -(so in SLiM these would be called type `m0`), -so first we check that all the existing mutations are of a different type. - -```{code-cell} -rts = pyslim.recapitate(ts, ancestral_Ne=1000, recombination_rate=1e-8, random_seed=6) - -# check type m0 is not used: -mut_metadata = pyslim.mutation_metadata(rts) -mut_types = set([md['mutation_type'] for md in mut_metadata.values()]) -print(f"Keeping {rts.num_mutations} existing mutations of type(s) {mut_types}.") -assert 0 not in mut_types - -# add type m0 mutations -next_id = pyslim.next_slim_mutation_id(rts) -rts = pyslim.add_mutation_metadata( - msprime.sim_mutations( - rts, rate=1e-8, random_seed=7, keep=True, - model=msprime.SLiMMutationModel(type=0, next_id=next_id) - ) -) - -p = rts.sample_count_stat( - [rts.samples()], lambda x: x/20000, 1, windows='sites', - span_normalise=False, polarised=True, strict=False) -print(f"After mutation, there are {rts.num_sites} segregating sites, of which {np.sum(p > 0.25)}") -print(f"are at frequency above 25%, and {np.sum(p > 0.05)} are above 5%.") -``` - -Now, there are more segregating sites - neutral ones. - - -## Continuing the simulation - -To "continue" the simulation neutrally, we'll - -1. simulate the desired period of time in msprime -2. randomly match the initial ancestors in the msprime simulation - with the final individuals of the SLim simulation, and -3. merge the two together, using the {meth}`tskit.TreeSequence.union` method. - - -**(1)** Simulating for a given period of time in msprime requires the ``end_time`` argument -(remembering that this is *time ago*); -we'll do this to simulate an additional 1000 generations. - - -```{code-cell} -new_time = 1000 -demog_model = msprime.Demography() -demog_model.add_population(initial_size=10000, name='pop') -new_ts = msprime.sim_ancestry( - samples={'pop' : 10000}, - demography=demog_model, - end_time=new_time, - sequence_length=rts.sequence_length, - recombination_rate=1e-8, - random_seed=9) -new_ts = msprime.sim_mutations( - new_ts, rate=1e-8, random_seed=10, keep=True, - model=msprime.SLiMMutationModel(type=0) - ) -new_tables = new_ts.dump_tables() -``` - -**(2)** Now we'll pull out the IDs of the nodes from 1000 generations ago, -shift the times in the SLiM tree sequence back 1000 generations, -randomly assign each to a node at the end of the SLiM simulation, -and merge them. - -```{code-cell} -new_nodes = np.where(new_tables.nodes.time == new_time)[0] -print(f"There are {len(new_nodes)} nodes from the start of the new simulation.") - -slim_nodes = rts.samples(time=0) -assert len(slim_nodes) == 20000 - -# randomly give new_nodes IDs in rts -node_map = np.repeat(tskit.NULL, new_tables.nodes.num_rows) -node_map[new_nodes] = np.random.choice(slim_nodes, len(new_nodes), replace=False) - -# shift times: in nodes and mutations -# since tree sequences are not mutable, we do this in the tables directly -# also, unmark the nodes at the end of the SLiM simulation as samples -tables = rts.dump_tables() -tables.nodes.flags = tables.nodes.flags & ~np.uint32(tskit.NODE_IS_SAMPLE) -tables.nodes.time = tables.nodes.time + new_time -tables.mutations.time = tables.mutations.time + new_time - -# merge the two sets of tables -tables.union(new_tables, node_map, - add_populations=False, - check_shared_equality=False) - -# get back the tree sequence -full_ts = tables.tree_sequence() - -p = full_ts.sample_count_stat( - [full_ts.samples()], lambda x: x/20000, 1, - windows='sites', span_normalise=False, - polarised=True, strict=False) -print(f"There are {full_ts.num_sites} segregating sites, of which {np.sum(p > 0.25)}") -print(f"are at frequency above 25%, and {np.sum(p > 0.05)} are above 5%.") -``` - -Well, allele frequencies have drifted. -Don't worry, we'll explain what happened there in a minute. - -Let's do a consistency check. -First, here's the root of the first tree in the recapitated SLiM simulation: -```{code-cell} -:tags: ["remove-output"] -t = rts.first() -assert(t.num_roots == 1) -r = rts.node(t.root) -print(r) -``` -```{code-cell} -:tags: ["remove-input"] -util.pp(r) -``` -Now, here's the root of the first tree *after* continuing -for 1000 generations, which should be the same: -```{code-cell} -:tags: ["remove-output"] -ft = full_ts.first() -assert(ft.num_roots == 1) -fr = full_ts.node(ft.root) -print(fr) -``` -```{code-cell} -:tags: ["remove-input"] -util.pp(fr) -``` -That matches up - the time of what should be the same node in the "continued" tree sequence -is 1000 generations earlier. - -So, what happened with ``union`` back there? -Well, the basic usage is ``tables.union(other, node_map)``, -where ``node_map`` is an array of length equal to the number of nodes in ``other``, -whose entries are either ``tskit.NULL`` or the ID of a node in ``tables``. -The entries that *aren't* NULL indicate that -``union`` should glue together ``tables`` and ``other`` by saying that that pair of nodes are the same. -(So, e.g., if ``node_map[3]`` is equal to ``25``, then it says that node 25 in ``tables`` -is actually the same, really, as node 3 in ``other``.) -We then asked ``union`` to please not create new populations, -since otherwise it would have assigned all the new nodes to a new population. -We also asked it to not "check for overlap equality": -sometimes, when unioning together two tree sequences, -we really expect everything having to do with the set of nodes we're saying are identical -to be identical in the two tree sequences, so ``union`` by default throws an error if it's not. -We don't expect that in this case, because, for instance, -there could be a mutation above one of the terminal nodes in the SLiM tree sequence; -this would clearly not be present in the new tree sequence. - -*Note:* sharp-eyed readers will note that the call to ``sim_mutations`` was not wrapped in -{func}`.add_mutation_metadata`. If we wanted to read this tree sequence into SLiM again -we'd need to add mutation metadata for these last mutations. -The easiest place to do this would be a call to {func}`.add_mutation_metadata_tables` -just after the ``union`` -(thus avoiding an extra conversion to tree sequence); -this will add metadata for only those mutations not already recorded. -We've left that step out of the code here for simplicity. diff --git a/docs/vignette_parallel_phylo.md b/docs/vignette_parallel_phylo.md deleted file mode 100644 index d75eb921..00000000 --- a/docs/vignette_parallel_phylo.md +++ /dev/null @@ -1,339 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 ---- - -```{code-cell} -:tags: [remove-cell] - -import pyslim, tskit, msprime -from IPython.display import SVG -import numpy as np -import pandas as pd -import os -``` - - -(sec_vignette_parallel)= - - -# Vignette: Parallelizing SLiM simulations in a phylogenetic tree - -Imagine you want to simulate the evolutionary history of a group. If there is -no migration between any of the branches in your tree, any branches stemming -from the same node can be simulated in parallel (see {numref}`phylo`). - -```{figure} _static/phylo.png -:height: 200px -:name: phylo - -Example of phylogeny we might want to simulate. Note how branches with the same color can be simulated in parallel when there is no migration. -``` - -To do this, we'll need to do two things: -(1) be able to *simulate* branches in parallel, and -(2) glue the resulting simulations (one per tip) back together. - - -## Simulating the branches - -First, we need to write a SLiM script that will be used for simulating the -history of each branch in our phylogeny. -We will perform a simple simulation, in which each branch can have a different -(but fixed) population size and length (number of ticks). -Also, we will allow deleterious mutations to happen across the entire chromosome -at a fixed rate. - -Here is a SLiM script that would do this: - -```{literalinclude} phylo_bgs.slim -``` - -For each branch, the presence or absence of ``infile`` tells SLiM -whether we want to start it from a previous branch or not. -If so, SLiM will read the previous tree sequence and change the -population size accordingly. -Note that when you read a tree sequence into SLiM, the tick counter will -be updated with the time encoded in the tree sequence, so we need to set the end -of the simulation as the length of the branch (`num_gens`) plus the current -"time" at the end of the loaded tree sequence. -At the end of the simulation, we call `sim.treeSeqRememberIndividuals` right -before saving the resulting tree sequence. This is necessary because we need to -ensure the individuals in the final generation are never dropped from the tree -sequence in future runs of SLiM which are started from the output of the -simulation, as they will later be used to glue the tree sequences together. - -I encoded the phylogeny we will simulate in a simple table, -which we'll use as ``df`` in the code below: - -```{code-cell} -:tags: ["hide-input"] -df = pd.read_csv("_static/phylo.tsv", sep="\t") -df = df.fillna('') -df["infile"] = df.parent + ".trees" -df["outfile"] = df.child + ".trees" -df.loc[df["infile"]==".trees", "infile"] = "" -df["is_leaf"] = ~df.child.isin(df.parent) -df -``` - -With our phylogeny and the simulation parameters, we are ready to run our -simulations. -One way to parallelize the simulation of sister branches is to use `make`. -You do not need to know much about this tool (though it is totally worthwhile -to check it out). -The main idea here is that you can specify dependency between files and `make` -works its magic to run the simulations in the right order. -Here is python code that will write out a makefile from the information in ``df``: - -```{code-cell} -f = open("sims.make", "w") -print(f"all: {' '.join(df.outfile.to_list())}\n", file=f) -for i, row in df.iterrows(): - print(f"{row.outfile}: {row.infile} phylo_bgs.slim", file=f) - print(f"\tslim -d \"infile='{row.infile}'\" -d popsize={row.popsize} " - f"-d \"popname=\'{row.child}\'\" " - f"-d num_gens={row.edgelen} " f"-d \"outfile='{row.child}.trees'\" " - "phylo_bgs.slim\n", - file=f) -f.close() -``` - -Here's the result. Again, don't worry about the details, -but you can see that the file encodes the phylogeny -through a bunch of ``child : parent`` "rules": -```{literalinclude} sims.make -``` - -With the makefile in hand, -we can now run make, specifying the maximum number of simulations -to be run simultaneously the ``-j``. -(Click on the "+" icon to see SLiM's output.) -```{code-cell} -:tags: ["hide-output"] -%%bash -make -f sims.make -j 3 -``` - - -```{dropdown} Click here for how to use python instead of make - -You would have to write a recursion over the branches in your tree (starting -from the root) and then parallelize the runs of sister branches somehow. - -```python -def phylo_recursion(parent, df): - print(parent) - childs = df[df.parent==parent] - print(childs) - if len(childs) == 0: - return - # you could parallelize this loop over childs with same parent - for i, row in childs.iterrows(): - if not os.path.exists(row.outfile): - os.system(f"slim -d \"infile='{row.infile}'\" -d popsize={row.popsize} -d num_gens={row.edgelen} -d \"outfile='{row.child}.trees'\" phylo_bgs.slim") - phylo_recursion(row.child, df) - -phylo_recursion("", df) -``` - -## Putting it all together: unioning the tree sequences - -With the tree sequences in hand, we now need to glue them together. -This can be done using -[**union**](https://tskit.dev/tskit/docs/stable/python-api.html#tskit.TreeSequence.union) -from tskit. -For two tree sequences which share some of its past history is shared, **union** -works by copying the non-shared parts of one of the tree sequence onto the other. -The trickiest part of this operation is defining the parts that are equivalent -in the two tree sequences. For that, you will have to create an array that serves -as a map of node IDs between the two tree sequences. - -Here is a function that will construct a map of the node IDs of two SLiM tree sequences -that correspond to the same chromosomes in SLiM -at any time older than the given time ago at which the two populations split. -Given two tree sequences ``other`` and ``ts``, -the goal here is to find, -for each node born before ``split_time`` ago in ``other``, -the matching node in ``ts``, where we can identify matching using the SLiM ID in metadata. -The code could be made easier to read by iterating over nodes, -but the following numpy-based version is much faster: - -```{code-cell} -def match_nodes(other, ts, split_time): - """ - Given SLiM tree sequences `other` and `ts`, builds a numpy array with length - `other.num_nodes` in which the indexes represent the node id in `other` and the - entries represent the equivalent node id in `ts`. If a node in `other` has no - equivalent in `ts`, then the entry takes the value `tskit.NULL` (-1). The - matching is done by comparing the IDs assigned by SLiM which are kept in - node metadata. This matching of SLiM IDs is *only* done for nodes with time - older than the specified `split_time`. - """ - node_mapping = np.full(other.num_nodes, tskit.NULL) - sids0 = np.array([n.metadata["slim_id"] for n in ts.nodes()]) - sids1 = np.array([n.metadata["slim_id"] for n in other.nodes()]) - alive_before_split1 = (other.tables.nodes.time >= split_time) - is_1in0 = np.isin(sids1, sids0) - both = np.logical_and(alive_before_split1, is_1in0) - sorted_ids0 = np.argsort(sids0) - matches = np.searchsorted( - sids0, - sids1[both], - side='left', - sorter=sorted_ids0 - ) - node_mapping[both] = sorted_ids0[matches] - return node_mapping -``` - -Now we are finally ready to **union** our tree sequences. For that, I wrote a -recursive function that goes through our data frame with the phylogeny and -returns a dictionary with the merged tree sequences from the tip to the root. - -```{code-cell} -merged = { - row.child : { - "ts": tskit.load(row.outfile), - "depth": row.edgelen, - "children": [row.child] - } - for i, row in df[df.is_leaf].iterrows() -} - -def union_children(parent, df, merged): - print(f"Going in: {parent}") - child_rows = df[df.parent == parent] - assert (len(child_rows) == 2) or (len(childs) == 0) - if len(child_rows) == 2: - children = [row.child for _, row in child_rows.iterrows()] - for child in children: - if child not in merged: - union_children(child, df, merged) - split_time = merged[children[0]]["depth"] - assert split_time == merged[children[1]]["depth"] # ultrametric - print(f'Unioning: {children}, Split time: {split_time}') - ts0 = merged[children[0]]["ts"] - ts1 = merged[children[1]]["ts"] - node_map = match_nodes(ts1, ts0, split_time) - tsu = ts0.union(ts1, node_map, check_shared_equality=True) - # the time from tip to start of simulation is split_time plus the - # length of the edge - parent_edgelength = df[df.child==parent].edgelen.item() - merged[parent] = { - "ts": tsu, - "depth": split_time + parent_edgelength, - "children": merged[children[0]]["children"] + merged[children[1]]["children"] - } - -union_children("root", df, merged) -# union of all three species tree sequences is in the root. -tsu = merged["root"]["ts"] -pops = merged["root"]["children"] -``` - -A slightly tricky thing we had to do there was to make sure we kept track of -which population in the union'ed tree sequence corresponds to -which population in our phylogeny. -Happily, we've stored each population's name in its metadata field, -so it's easy to match populations in the tree sequence up to what they're supposed to be. - -Let's make sure we have the right number of present-day samples -in each of the populations. To do this we need to make sure to get -"alive" samples, because recall that we have saved the state of the -population at each species split time. - -```{code-cell} -alive = np.where(np.isclose(tsu.tables.nodes.time, 0))[0] -pop_ids = {} -for pop in tsu.populations(): - if pop.metadata is not None: - pop_ids[pop.metadata['name']] = pop.id - -for name in pops: - pop_samples = tsu.samples(pop_ids[name]) - n_samples = sum(np.isin(pop_samples, alive)) // 2 - print(f"Union-ed tree sequence has {n_samples} samples in population {name},\n" - f"\tand we specified {df[df.child==name].popsize.item()} individuals in our simulations.") - assert n_samples == df[df.child==name].popsize.item() -``` - -Let's do an additional consistency check now, to see if we need to recapitate -(i.e., if some trees haven't coalesced), -and to make sure that all roots are in the root population, -as they should be: -```{code-cell} -# TODO: fix up -# for t in tsu.trees(): -# for r in t.roots: -# assert tsu.node(r).population == pop_ids["root"] - -print(f"Max number of roots: {max([t.num_roots for t in tsu.trees()])}.") -``` - -Finally, we will recapitate the result with a small population size of 100, -in case some trees on the root branch haven't coalesced, -and write out the result: - -```{code-cell} -tsu = pyslim.recapitate(tsu, recombination_rate=1e-8, ancestral_Ne=100) -tsu.dump("final.trees") -``` - -Now we're done, and can analyse the final tree sequence! -Just for fun, I'll look at the trees produced by the simulation. -For instance, we might be curious how often there are -disagreements between the species tree and the simulated gene trees -(also called incomplete lineage sorting, or ILS). - -To make it possible to look at the trees, -I will first simplify the union-ed tree sequence to keep only two diploid -samples per population. - -```{code-cell} -rng = np.random.default_rng(seed=123) -ind_alive = pyslim.individuals_alive_at(tsu, 0) -ind_pops = tsu.individuals_population[ind_alive] -subsample_indivs = [ - rng.choice(ind_alive[ind_pops == pop_ids[name]], 2) - for name in pops -] -subsample_nodes = [ - np.concatenate([tsu.individual(i).nodes for i in x]) - for x in subsample_indivs -] -tsus = tsu.simplify( - np.concatenate(subsample_nodes), - filter_populations=False, -) -pop_labels = {v: k for k, v in pop_ids.items()} -SVG(tsus.draw_svg( - node_labels={ - node.id: pop_labels[node.population] - for node in tsus.nodes() - if not node.time > 0.0 - }, - x_lim=[0,2200], - size=(800, 300), -)) -``` - -:::{note} -A possible gotcha in the code above lies in getting the time units to work out. -Note that in the SLiM script we both save and reload .trees files in the -``late()`` stage of the SLiM life cycle. This is important: if we had reloaded the -files in ``early()``, then each time we did so the "tskit time" and "SLiM time" -would become one step out of sync. This leads to errors either in union (since -if the time units in the two tree sequences do not match, union will raise an error) -or in recapitate (since recapitate assumes that the "top" of the trees are at -the number of generations ago recorded by SLiM in metadata). -::: - diff --git a/docs/vignette_space.md b/docs/vignette_space.md deleted file mode 100644 index 4d0adb46..00000000 --- a/docs/vignette_space.md +++ /dev/null @@ -1,472 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.12 - jupytext_version: 1.9.1 -kernelspec: - display_name: Python 3 - language: python - name: python3 -execution: - timeout: 90 ---- - -```{code-cell} -:tags: [remove-cell] - -import pyslim, tskit, msprime -from IPython.display import SVG -import numpy as np -import util - -np.random.seed(1234) -``` - - -(sec_vignette_space)= - - -# Vignette: A spatial simulation - -Here we'll talk through a typical workflow with pyslim, -which will: - -1. Simulate data with SLiM, remembering some ancestral individuals. -2. Recapitate and mutate. -3. Take a subsample of the modern and ancestral individuals. -4. Get these individual locations and make a map. -5. Compute divergences between individuals, and plot against geographic distance. -6. Write out a VCF file of these individuals' genotypes and other data for use by other programs. - - -## Simulation - -Here is a simple spatial SLiM recipe that simulates 1000 individuals on a spatial landscape. -The focus of this vignette is not on SLiM, so we won't go into detail here. -Here are notes: - -1. It does not have *any* mutations: we'll add these on afterwards. -2. There is local fecundity regulation of population density: individuals with more neighbors - have fewer offspring. -3. We run the simulation for 2000 time steps, and "remember" everyone who is alive at time step 1000. - -```{literalinclude} vignette_space.slim -``` -```{code-cell} -%%bash -slim -s 23 vignette_space.slim -``` - -Ok, now let's have a quick look at the output: - -```{code-cell} -import tskit -slim_ts = tskit.load("spatial_sim.trees") -print(f"The tree sequence has {slim_ts.num_trees} trees\n" - f"on a genome of length {slim_ts.sequence_length},\n" - f"{slim_ts.num_individuals} individuals, {slim_ts.num_samples} 'sample' genomes,\n" - f"and {slim_ts.num_mutations} mutations.") -``` - -It makes sense we have no mutations: we haven't added any yet. -The tree sequence is recording the relationship between 5,424 genomes (the "samples"), -which requires 37,095 distinct trees along the genome. -Individuals are diploid, which explains why the number of individuals -is equal to half the number of samples. -Let's have a look at how old those individuals are, -by tabulating when they were born: - -```{code-cell} -import numpy as np -individual_times = slim_ts.individuals_time -for t in np.unique(individual_times): - print(f"There are {np.sum(individual_times == t)} individuals from time {t}.") -``` - -These "times" record the birth times of each individual. -These are *tskit* times, which are in units of "time ago", -so for instance, there are 343 individuals born one time unit before the end of the simulation -and 167 born two time units before the end of the simulation. -(This confusing choice of units is because tskit was developed for msprime, a coalescent simulator.) -This also tells us that there's a bunch of individuals born around 1000 time steps ago, -when we asked SLiM to Remember everyone alive at the time, -and some more in the past few time steps, i.e., the present. -This is a non-Wright-Fisher simulation, -and so individuals may live for more than one time step (even up to age 10, it seems). -Let's check that all these individuals are alive at either (a) today or (b) 1000 time steps ago. - -```{code-cell} -slim_ts_md = slim_ts.metadata -for t in [0, 1000]: - alive = pyslim.individuals_alive_at(slim_ts, t, ts_metadata=slim_ts_md) - print(f"There were {len(alive)} individuals alive {t} time steps in the past.") -``` - -We can add these numbers to get the total number of individuals, so this all checks out. -On a separate note: here we used the optional ``ts_metadata`` argument -to {func}`.individuals_alive_at`. -This is because {func}`.individuals_alive_at` is a function that requires top-level metadata use, -which entails overhead that can be avoided by pre-extracting the metadata and passing it in. -Here this is not important because it is only run twice, but this would be essential -if we had a longer list of times. - - -## Recapitation and mutation - -Next, we want to (a) simulate some ancestral diversity and (b) add in neutral mutations. -Please see [Haller et al (2019)](https://onlinelibrary.wiley.com/doi/abs/10.1111/1755-0998.12968>) -for the why and how of these steps. -But, first let's see if recapitation is necessary: -on how much of the genome is the tree sequence not coalesced? -In other words, recapitation adds diversity present in the initial generation; -will it make a difference? -In fact, *no* segments of the genome have coalesced: - -```{code-cell} -print(f"Number of trees with only one root: {sum([t.num_roots == 1 for t in slim_ts.trees()])}\n" - f"Number with more than one root: {sum([t.num_roots > 0 for t in slim_ts.trees()])}") -``` - -Next, we will: - -1. Recapitate, running a coalescent simulation to build ancestral trees. -2. Mutate, adding neutral variation. -3. Save the resulting tree sequence to disk for future use. - -We *won't* simplify, since we may as well keep around all the information. -But, if we did (e.g., if we were running a large number of simulations), -we would need to pass ``keep_input_roots=True`` to allow recapitation. - -:::{note} - The units of time in the tree sequence are SLiM's "time steps", and - so are not necessarily equal to the mean generation time in a - non-Wright-Fisher model. Per-generation rates need to be divided by the - mean generation time, which can be measured in SLiM. -::: - -```{code-cell} -recap_ts = pyslim.recapitate(slim_ts, recombination_rate=1e-8, ancestral_Ne=1000) -ts = pyslim.add_mutation_metadata( - msprime.sim_mutations( - recap_ts, - rate=1e-8, - model=msprime.SLiMMutationModel(type=0), - keep=True, - ) -) -ts.dump("spatial_sim.recap.trees") - -print(f"The tree sequence now has {ts.num_trees} trees,\n" - f" and {ts.num_mutations} mutations.") -``` -See [](sec_tutorial_adding_neutral_mutations) for discussion of the options to -{func}`msprime.sim_mutations`. - - -We will have no further use for ``slim_ts`` or for ``recap_ts``; -we've just given them separate names for tidyness. -And, since the original SLiM mutation had no mutations, we didn't need to specify ``keep=True`` -in {func}`sim_mutations `, but if we *had* put down selected mutations with SLiM -we'd probably want to keep them around. - - -## Take a sample of individuals - -Now it's time to compute some things. -In real life we don't get to work with *everyone* usually, -so we'll take a subset of individuals. -The range we have simulated has width and height 35 units, -with a population density of around 1 per unit area. -We'll get genomes to work with by pulling out - -1. All the modern individuals in the five squares of width 5 in the corners of the range - and the center, and -2. Five individuals sampled randomly from everyone alive 1000 time steps ago. - -```{code-cell} - -np.random.seed(23) - -alive = pyslim.individuals_alive_at(ts, 0) -locs = ts.individuals_location[alive, :] - -W = 35 -w = 5 -groups = { - 'topleft' : alive[np.logical_and(locs[:, 0] < w, locs[:, 1] < w)], - 'topright' : alive[np.logical_and(locs[:, 0] < w, locs[:, 1] > W - w)], - 'bottomleft' : alive[np.logical_and(locs[:, 0] > W - w, locs[:, 1] < w)], - 'bottomright' : alive[np.logical_and(locs[:, 0] > W - w, locs[:, 1] > W - w)], - 'center' : alive[np.logical_and(np.abs(locs[:, 0] - W/2) < w/2, - np.abs(locs[:, 1] - W/2) < w/2)] - } - -old_ones = pyslim.individuals_alive_at(ts, 1000) -groups['ancient'] = np.random.choice(old_ones, size=5) - -for k in groups: - print(f"We have {len(groups[k])} individuals in the {k} group.") -``` - -To keep names associated with each subset of individuals, -we've kept the individuals in a dict, so that for instance -``groups["topleft"]`` is an array of all the individual IDs that are in the top left corner. -The IDs of the ancient individuals we will work with are kept in the array ``ancient``. - -Let's do a quick consistency check, that everyone in ``ancient`` was actually born around 1000 time steps ago: - -```{code-cell} -for i in groups["ancient"]: - ind = ts.individual(i) - # TODO: will work on next tskit release - # assert(ind.time >= 1000 and ind.time < 1020) - time = ts.node(ind.nodes[0]).time - assert(time >= 1000 and time < 1020) -``` -No errors occurred, so that checks out. - -## Plotting locations - -We should check this: plot where these individuals lie -relative to everyone else. -The individuals locations are available as a property of individuals, -but to make things easier, it's also present in a `num_individuals x 3` -numpy array as ``ts.individuals_location``. -(There are three columns because SLiM allows for -`(x, y, z)` coordinates, but we'll just use the first two.) -Since ``groups["topleft"]`` is an array of individual IDs, -we can pull out the locations of the "topleft" individuals -by indexing the rows of the individual location array: -```{code-cell} -print("Locations:") -all_locs = ts.individuals_location -print(all_locs) -print("shape:") -all_locs.shape -print("topleft locations shape:") -all_locs[groups["topleft"], :].shape -``` - -Using this, we can easily plot the locations of all the individuals from today -(on the left) and 1000 time steps ago (on the right). -We have to do a bit of mucking around to set the colors so that they reflect -which group each individual is in. - -```{code-cell} -import matplotlib -import matplotlib.pyplot as plt - -group_order = ['topleft', 'topright', 'bottomleft', 'bottomright', 'center', 'ancient'] -ind_colors = np.repeat(0, ts.num_individuals) -for j, k in enumerate(group_order): - ind_colors[groups[k]] = 1 + j - -old_locs = ts.individuals_location[old_ones, :] - -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 6), dpi=300) -ax1.set_title("today") -ax1.scatter(locs[:,0], locs[:,1], s=20, c=ind_colors[alive]) -ax2.set_title("long ago") -ax2.scatter(old_locs[:, 0], old_locs[:, 1], s=20, c=ind_colors[old_ones]); -``` - - -## Isolation by distance - -Now, let's look at *isolation by distance*, i.e., -let's compare geographic and genetic distances. -Here, "genetic distance" will be mean pairwise sequence divergence. -First, we'll compute mean genetic distance between each of our five groups. - -The first thing we need to do is some bookkeeping. -So far, we've just worked with *individuals*, -but tree sequence tools, in particular the statistics computation methods from tskit, -are designed to work with *genomes*, also known as "nodes". -So, first we need to pull out the *node IDs* corresponding to the individuals we want. -The things that make up a tree sequence - individuals, nodes, mutations, etcetera - -can generally be examined individually. -For instance, here's what we have for the the first "ancient" individual: - -```{code-cell} -:tags: ["remove-output"] -print(ts.individual(groups['ancient'][0])) -``` -```{code-cell} -:tags: ["remove-input"] -util.pp(ts.individual(groups['ancient'][0])) -``` - -Notice that among other things, each individual carries around a list of their node IDs, -i.e., their genomes. -We need to put these all in a list of lists, -so that, for instance, the first element of the list will have the node IDs of all the genomes -of the individuals in the "topleft" group. -And, since we kept the individual IDs in a dict, which are unordered, -we'll have to do some extra work to make sure we keep track of order. - -```{code-cell} -sampled_nodes = [[] for _ in groups] -for j, k in enumerate(group_order): - for ind in groups[k]: - sampled_nodes[j].extend(ts.individual(ind).nodes) -``` - -Let's do a consistency check: the number of nodes in each element of this list -should be twice the number of individuals in the corresponding list. -```{code-cell} -print([len(groups[k]) for k in groups]) -print([len(u) for u in sampled_nodes]) -``` -For instance, in the 'topleft' corner there are 12 diploids, -with 24 nodes. That checks out. - -Now, we can compute the matrix of pairwise mean sequence divergences -between and within these sets. -This is done using the {meth}`ts.divergence ` method. - -```{code-cell} - -pairs = [(i, j) for i in range(6) for j in range(6)] -group_div = ts.divergence(sampled_nodes, indexes=pairs).reshape((6, 6)) - -print("\t" + "\t".join(group_order)) -for i, group in enumerate(group_order): - print(f"{group_order[i]}:\t" + "\t".join(map(str, np.round(group_div[i], 7)))) -``` - - -That's nice, but to look at isolation by distance, -we should actually separate out the individuals. -To do that, we need to create a list of lists of nodes -whose j-th entry is the nodes belonging to the j-th individual, -and to keep track of which group each one belongs to. - -```{code-cell} -ind_nodes = [] -ind_group = [] -ind_ids = [] -for j, group in enumerate(group_order): - for ind in groups[group]: - ind_ids.append(ind) - ind_nodes.append(ts.individual(ind).nodes) - ind_group.append(group_order[j]) - -nind = len(ind_ids) -pairs = [(i, j) for i in range(nind) for j in range(i, nind)] -ind_div = ts.divergence(ind_nodes, indexes=pairs) -``` - -Here we've only computed divergences in the *upper triangle* of the pairwise divergence matrix, -with heterozygosities on the diagonal. -We'll also need pairwise geographic distances: - -```{code-cell} -geog_dist = np.repeat(0.0, len(pairs)) -locs = ts.individuals_location -for k, (i, j) in enumerate(pairs): - geog_dist[k] = np.sqrt(np.sum( - (locs[ind_ids[i], :2] - - locs[ind_ids[j], :2])**2 - )) -``` - -Let's check that makes sense: distances of individuals from themselves should be zero. - -```{code-cell} -for (i, j), x in zip(pairs, geog_dist): - if i == j: - assert(x == 0) -``` - -Python does not complain, which is good. -Now let's plot genetic distance against geographic distance. - -```{code-cell} -pair_colors = np.repeat(0, len(pairs)) -for k, (i, j) in enumerate(pairs): - if ind_group[i] == "ancient" or ind_group[j] == "ancient": - pair_colors[k] = 1 - -fig = plt.figure(figsize=(6, 6), dpi=300) -ax = fig.add_subplot(111) -ax.scatter(geog_dist, 1e3 * ind_div, s=20, alpha=0.5, - c=pair_colors) -ax.set_xlabel("geographic distance") -ax.set_ylabel("genetic distance (diffs/Kb)"); -``` - - -Since we multiplied ``ind_div`` by 1,000, -the units of genetic distance are in mean number of nucleotide differences per kilobase. -It is clear that closer samples are more closely related, -and the distinct clusters corresponding to the five sampled boxes are visible. -Furthermore, ancient samples are generally more distantly diverged. - - -## VCF output - -Now we want to write out these data for analysis with other programs. -To do this, and make sure that everything stays nicely cross-referenced, -we're going to loop through the sampled individuals, writing their information to a file, -while at the same time constructing a list of individual IDs, -whose genomes we will write out to a VCF file. - -```{code-cell} - -indivlist = [] -indivnames = [] -with open("spatial_sim_individuals.txt", "w") as indfile: - indfile.writelines("\t".join(["vcf_label", "tskit_id", "slim_id"] - + ["birth_time_ago", "age", "x", "y"]) + "\n") - for group in group_order: - for i in groups[group]: - indivlist.append(i) - ind = ts.individual(i) - vcf_label = f"tsk_{ind.id}" - indivnames.append(vcf_label) - time = ts.node(ind.nodes[0]).time - data = [vcf_label, str(ind.id), str(ind.metadata["pedigree_id"]), str(time), - str(ind.metadata["age"]), str(ind.location[0]), str(ind.location[1])] - indfile.writelines("\t".join(data) + "\n") - -with open("spatial_sim_genotypes.vcf", "w") as vcffile: - ts.write_vcf(vcffile, individuals=indivlist, individual_names=indivnames) -``` - - -## More information - -1. The distinction between "nodes" (i.e., genomes) and "individuals" can be confusing, - as well as the idea of "samples". - Please see the - {ref}`the tskit data model` - for more explanation about these concepts. - -2. The general interface for computing statistics (explaining, for instance, the "indexes" - argument above) is described in {ref}`the tskit documentation` also. - - -## What about simplification? - -The tree sequence we worked with here contains more information than we need, -including the first generation individuals. -If we wanted to remove this, we could have used the -{meth}`simplify ` method, -which reduced the tree sequence to the minimal required to record the information -about a provided set of nodes. -In the workflow above we didn't ever *simplify* the tree sequence, -because we didn't need to. -Because simplify reorders nodes and removes unused individuals and populations, -it requires an extra layer of bookkeeping. -Such relabeling also makes it harder to compare results across different analyses -of the same data. - -Simplifying the tree sequence down to the nodes of the individuals -in our "groups" would not change any subsequent analysis (except perhaps -removing monomorphic sites in the VCF output), -and would speed up computation of diversity. -Since the calculation was fast already, it wasn't worth it in this case, -but for much larger tree sequences it could be worth the extra code complexity. - From 986038f18989137c7c23cef7cb15e1ff6aebd43c Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 20:53:43 -0700 Subject: [PATCH 4/9] . --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e02f6d69..2cb2172d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,7 +11,7 @@ jobs: Docs: permissions: contents: read - uses: petrelharp/tskit.github/.github/workflows/docs.yml@a3405f892599a52b1166993f886ee45de44a4eca + uses: petrelharp/tskit.github/.github/workflows/docs.yml@e713548d92c7d8ddf45b6f94b7a05efb698d151d with: install-slim: true install-slim-branch: "multitrait" From 31a9be39ab974536946dc3c222b7a0543ae64118 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 20:59:21 -0700 Subject: [PATCH 5/9] . --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2cb2172d..190366d9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,7 +11,7 @@ jobs: Docs: permissions: contents: read - uses: petrelharp/tskit.github/.github/workflows/docs.yml@e713548d92c7d8ddf45b6f94b7a05efb698d151d + uses: petrelharp/tskit.github/.github/workflows/docs.yml@c259da6b531656aae8206d5a25511a860a2746a3 with: install-slim: true install-slim-branch: "multitrait" From 332c3d3e0c3c5e89913a6727a514bef110d69707 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 21:02:57 -0700 Subject: [PATCH 6/9] . --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 190366d9..b6a13fc7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,7 +11,7 @@ jobs: Docs: permissions: contents: read - uses: petrelharp/tskit.github/.github/workflows/docs.yml@c259da6b531656aae8206d5a25511a860a2746a3 + uses: petrelharp/tskit.github/.github/workflows/docs.yml@a2c5f3a87b26d6a45c75294b221bdf8d6fdc7d31 with: install-slim: true install-slim-branch: "multitrait" From 7a6621d7fb8d956999ae4d31b3a170b8e14ca920 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 21:09:14 -0700 Subject: [PATCH 7/9] . --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b6a13fc7..377c2d40 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,7 +11,7 @@ jobs: Docs: permissions: contents: read - uses: petrelharp/tskit.github/.github/workflows/docs.yml@a2c5f3a87b26d6a45c75294b221bdf8d6fdc7d31 + uses: petrelharp/tskit.github/.github/workflows/docs.yml@3a63d1a5bb0cbccfbc79566ade6f868368268b59 with: install-slim: true install-slim-branch: "multitrait" From 100ca694e1ccbb81f56ba4de0bfb291e08e247f7 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 21:18:32 -0700 Subject: [PATCH 8/9] . --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 377c2d40..15e2d1f0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,7 +11,7 @@ jobs: Docs: permissions: contents: read - uses: petrelharp/tskit.github/.github/workflows/docs.yml@3a63d1a5bb0cbccfbc79566ade6f868368268b59 + uses: petrelharp/tskit.github/.github/workflows/docs.yml@e41a80d68523fe86c1cbddd1d4ad2991e699b7c8 with: install-slim: true install-slim-branch: "multitrait" From 736e395be3a34e3a5b262dbf6a78bede348c24dc Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 13 Aug 2026 21:30:02 -0700 Subject: [PATCH 9/9] . --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 15e2d1f0..8ba8d278 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,7 +11,7 @@ jobs: Docs: permissions: contents: read - uses: petrelharp/tskit.github/.github/workflows/docs.yml@e41a80d68523fe86c1cbddd1d4ad2991e699b7c8 + uses: petrelharp/tskit.github/.github/workflows/docs.yml@a35059f7617ec56cb61be0538b4f5554c41f66a1 with: install-slim: true install-slim-branch: "multitrait"