Repository: tskit-dev/tskit
Pull request #3157: Add sample_nodes_by_ploidy function
Author: login: benjeffery
Created: 2025-05-07T12:02:13Z | Merged: 2025-05-12T16:29:53Z



Diff:
diff --git a/python/CHANGELOG.rst b/python/CHANGELOG.rst
index 7f1b29ce8d..a54894f4e1 100644
--- a/python/CHANGELOG.rst
+++ b/python/CHANGELOG.rst
@@ -4,6 +4,10 @@
 
 **Features**
 
+- Add ``TreeSequence.sample_nodes_by_ploidy`` method to return the sample nodes
+  in a tree sequence, grouped by a ploidy value.
+  (:user:`benjeffery`, :pr:`3157`)
+
 - Add ``TreeSequence.individuals_nodes`` attribute to return the nodes
   associated with each individual as a numpy array.
   (:user:`benjeffery`, :pr:`3153`)
diff --git a/python/tests/test_highlevel.py b/python/tests/test_highlevel.py
index f703408013..856987b383 100644
--- a/python/tests/test_highlevel.py
+++ b/python/tests/test_highlevel.py
@@ -5584,3 +5584,87 @@ def test_mixed_sample_status(self):
         expected = np.array([[0, 1]])
         assert result.shape == (1, 2)
         assert_array_equal(result, expected)
+
+
+class TestSampleNodesByPloidy:
+    @pytest.mark.parametrize(
+        "n_samples,ploidy,expected",
+        [
+            (6, 2, np.array([[0, 1], [2, 3], [4, 5]])),  # Basic diploid
+            (9, 3, np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])),  # Triploid
+            (5, 1, np.array([[0], [1], [2], [3], [4]])),  # Ploidy of 1
+            (4, 4, np.array([[0, 1, 2, 3]])),  # Ploidy equals number of samples
+        ],
+    )
+    def test_various_ploidy_scenarios(self, n_samples, ploidy, expected):
+        tables = tskit.TableCollection(sequence_length=100)
+        for _ in range(n_samples):
+            tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0)
+        ts = tables.tree_sequence()
+
+        result = ts.sample_nodes_by_ploidy(ploidy)
+        expected_shape = (n_samples // ploidy, ploidy)
+        assert result.shape == expected_shape
+        assert_array_equal(result, expected)
+
+    def test_mixed_sample_status(self):
+        tables = tskit.TableCollection(sequence_length=100)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0)
+        tables.nodes.add_row(flags=0, time=0)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0)
+        tables.nodes.add_row(flags=0, time=0)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0)
+        ts = tables.tree_sequence()
+
+        result = ts.sample_nodes_by_ploidy(2)
+        assert result.shape == (2, 2)
+        expected = np.array([[0, 2], [4, 5]])
+        assert_array_equal(result, expected)
+
+    def test_no_sample_nodes(self):
+        tables = tskit.TableCollection(sequence_length=100)
+        tables.nodes.add_row(flags=0, time=0)
+        tables.nodes.add_row(flags=0, time=0)
+        ts = tables.tree_sequence()
+
+        with pytest.raises(ValueError, match="No sample nodes in tree sequence"):
+            ts.sample_nodes_by_ploidy(2)
+
+    def test_not_multiple_of_ploidy(self):
+        tables = tskit.TableCollection(sequence_length=100)
+        for _ in range(5):
+            tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0)
+        ts = tables.tree_sequence()
+
+        with pytest.raises(ValueError, match="not a multiple of ploidy"):
+            ts.sample_nodes_by_ploidy(2)
+
+    def test_with_existing_individuals(self):
+        tables = tskit.TableCollection(sequence_length=100)
+        tables.individuals.add_row(flags=0, location=(0, 0), metadata=b"")
+        tables.individuals.add_row(flags=0, location=(0, 0), metadata=b"")
+        # Add nodes with individual references but in a different order
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0, individual=1)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0, individual=0)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0, individual=1)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0, individual=0)
+
+        ts = tables.tree_sequence()
+        result = ts.sample_nodes_by_ploidy(2)
+        expected = np.array([[0, 1], [2, 3]])
+        assert_array_equal(result, expected)
+        ind_nodes = ts.individuals_nodes
+        assert not np.array_equal(result, ind_nodes)
+
+    def test_different_node_flags(self):
+        tables = tskit.TableCollection(sequence_length=100)
+        OTHER_FLAG1 = 1 << 1
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE, time=0)
+        tables.nodes.add_row(flags=OTHER_FLAG1, time=0)
+        tables.nodes.add_row(flags=tskit.NODE_IS_SAMPLE | OTHER_FLAG1, time=0)
+        tables.nodes.add_row()
+        ts = tables.tree_sequence()
+        result = ts.sample_nodes_by_ploidy(2)
+        assert result.shape == (1, 2)
+        assert_array_equal(result, np.array([[0, 2]]))
diff --git a/python/tskit/trees.py b/python/tskit/trees.py
index 16838c332e..3b1a410cb0 100644
--- a/python/tskit/trees.py
+++ b/python/tskit/trees.py
@@ -10495,6 +10495,31 @@ def ld_matrix(
             mode=mode,
         )
 
+    def sample_nodes_by_ploidy(self, ploidy):
+        """
+        Returns an 2D array of node IDs, where each row has length `ploidy`.
+        This is useful when individuals are not defined in the tree sequence
+        so `TreeSequence.individuals_nodes` cannot be used. The samples are
+        placed in the array in the order which they are found in the node
+        table. The number of sample nodes must be a multiple of ploidy.
+
+        :param int ploidy: The number of samples per individual.
+        :return: A 2D array of node IDs, where each row has length `ploidy`.
+        :rtype: numpy.ndarray
+        """
+        sample_node_ids = np.flatnonzero(self.nodes_flags & tskit.NODE_IS_SAMPLE)
+        num_samples = len(sample_node_ids)
+        if num_samples == 0:
+            raise ValueError("No sample nodes in tree sequence")
+        if num_samples % ploidy != 0:
+            raise ValueError(
+                f"Number of sample nodes {num_samples} is not a multiple "
+                f"of ploidy {ploidy}"
+            )
+        num_samples_per_individual = num_samples // ploidy
+        sample_node_ids = sample_node_ids.reshape((num_samples_per_individual, ploidy))
+        return sample_node_ids
+
     ############################################
     #
     # Deprecated APIs. These are either already unsupported, or will be unsupported in a


Reviews:
user: login: jeromekelleher
body: Nice! Can we do a follow-up commit where we use the new functions in the vcf() method? (Just to see if we're implementing the right interface?)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:24:31Z
user: login: benjeffery
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:30:27Z
user: login: jeromekelleher
body: I don't think we've got the semantics quite right here - it would be surprising if I specified ``individuals=[0, 1]`` and I got back a VCF with zero samples in it. In this case, I think we should raise an error.


state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T12:25:15Z
user: login: jeromekelleher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T13:00:58Z
user: login: jeromekelleher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T13:02:57Z
user: login: benjeffery
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T13:53:32Z
user: login: benjeffery
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T13:53:40Z
user: login: petrelharp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:18:11Z
user: login: benjeffery
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T22:29:27Z
user: login: jeromekelleher
body: I think the semantics are still pretty weird - two of the tests look like the same thing to me, with different errors. See suggested new semantics.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T08:21:08Z
user: login: jeromekelleher
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-12T15:49:20Z

Review comments:
diff_hunk: @@ -10491,6 +10491,31 @@ def ld_matrix(
             mode=mode,
         )
 
+    def sample_nodes_by_ploidy(self, ploidy):
+        """
+        Returns an 2D array of node IDs, where each row has length `ploidy`.
+        This is useful when inidividuals are not defined in the tree sequence
path: python/tskit/trees.py
user: login: jeromekelleher
body: ```suggestion
        This is useful when individuals are not defined in the tree sequence
```
created_at: 2025-05-07T12:23:10Z
author_association: MEMBER
diff_hunk: @@ -10491,6 +10491,31 @@ def ld_matrix(
             mode=mode,
         )
 
+    def sample_nodes_by_ploidy(self, ploidy):
+        """
+        Returns an 2D array of node IDs, where each row has length `ploidy`.
+        This is useful when inidividuals are not defined in the tree sequence
path: python/tskit/trees.py
user: login: benjeffery
body: I sprinkle these in to prove it's not AI. Fixed.
created_at: 2025-05-07T12:30:27Z
author_association: MEMBER
diff_hunk: @@ -6432,14 +6432,9 @@ def write_vcf(
         with sample nodes are included in increasing order of individual ID.
 
         Subsets or permutations of the sample individuals may be specified
-        using the ``individuals`` argument. It is an error to specify any
-        individuals that are not associated with any nodes, or whose
-        nodes are not all samples.
-
-        Mixed-sample individuals (e.g., those associated with one node
-        that is a sample and another that is not) in the data model will
-        result in an error by default. However, such individuals can be
-        excluded using the ``individuals`` argument.
+        using the ``individuals`` argument. And individual specified that
path: python/tskit/trees.py
user: login: jeromekelleher
body: Typo
created_at: 2025-05-08T12:18:39Z
author_association: MEMBER
diff_hunk: @@ -2,6 +2,13 @@
 [0.6.4] - 2025-XX-XX
 --------------------
 
+**Changes**
+
+- Allow mixed sample and non-sample nodes to be output by ```write_vcf``,
path: python/CHANGELOG.rst
user: login: jeromekelleher
body: ```suggestion
- Allow mixed sample and non-sample nodes to be output by ```write_vcf``.
```
created_at: 2025-05-08T12:21:03Z
author_association: MEMBER
diff_hunk: @@ -2,6 +2,13 @@
 [0.6.4] - 2025-XX-XX
 --------------------
 
+**Changes**
+
+- Allow mixed sample and non-sample nodes to be output by ```write_vcf``,
+  and for individuals without nodes to be specified for output, although
+  they will not appear in the VCF.
path: python/CHANGELOG.rst
user: login: jeromekelleher
body: ```suggestion
  Individuals without nodes no longer raise an error and are instead ignored,
  except in the case where they are explicitly specified in the individuals argument.
```
created_at: 2025-05-08T12:23:08Z
author_association: MEMBER
diff_hunk: @@ -2,8 +2,21 @@
 [0.6.4] - 2025-XX-XX
 --------------------
 
+**Changes**
+
+- Allow mixed sample and non-sample nodes to be output by ``write_vcf``.
path: python/CHANGELOG.rst
user: login: jeromekelleher
body: This is no longer grammatical, you've left a deleted clause in
created_at: 2025-05-08T13:00:58Z
author_association: MEMBER
diff_hunk: @@ -6432,14 +6432,9 @@ def write_vcf(
         with sample nodes are included in increasing order of individual ID.
 
         Subsets or permutations of the sample individuals may be specified
-        using the ``individuals`` argument. It is an error to specify any
-        individuals that are not associated with any nodes, or whose
-        nodes are not all samples.
-
-        Mixed-sample individuals (e.g., those associated with one node
-        that is a sample and another that is not) in the data model will
-        result in an error by default. However, such individuals can be
-        excluded using the ``individuals`` argument.
+        using the ``individuals`` argument. An individual specified that
+        is not associated with any nodes will be ignored and not included in the
path: python/tskit/trees.py
user: login: jeromekelleher
body: That's not true any more
created_at: 2025-05-08T13:02:57Z
author_association: MEMBER
diff_hunk: @@ -6432,14 +6432,9 @@ def write_vcf(
         with sample nodes are included in increasing order of individual ID.
 
         Subsets or permutations of the sample individuals may be specified
-        using the ``individuals`` argument. It is an error to specify any
-        individuals that are not associated with any nodes, or whose
-        nodes are not all samples.
-
-        Mixed-sample individuals (e.g., those associated with one node
-        that is a sample and another that is not) in the data model will
-        result in an error by default. However, such individuals can be
-        excluded using the ``individuals`` argument.
+        using the ``individuals`` argument. An individual specified that
+        is not associated with any nodes will be ignored and not included in the
path: python/tskit/trees.py
user: login: benjeffery
body: I thought it was - I added a test in 266f0cb2774abc2c3f86c02c978fb74d8e022c31 to check.
created_at: 2025-05-08T13:53:31Z
author_association: MEMBER
diff_hunk: @@ -2,8 +2,21 @@
 [0.6.4] - 2025-XX-XX
 --------------------
 
+**Changes**
+
+- Allow mixed sample and non-sample nodes to be output by ``write_vcf``.
path: python/CHANGELOG.rst
user: login: benjeffery
body: Whoops, fixed.
created_at: 2025-05-08T13:53:40Z
author_association: MEMBER
diff_hunk: @@ -10491,6 +10486,33 @@ def ld_matrix(
             mode=mode,
         )
 
+    def sample_nodes_by_ploidy(self, ploidy):
+        """
+        Returns an 2D array of node IDs, where each row has length `ploidy`.
+        This is useful when individuals are not defined in the tree sequence
+        so `TreeSequence.individuals_nodes` cannot be used. The samples are
+        placed in the array in the order which they are found in the node
+        table. Ploidy must be a multiple of the number of sample nodes.
path: python/tskit/trees.py
user: login: petrelharp
body: ```suggestion
        table. The number of sample nodes must be a multiple of ploidy.
```
created_at: 2025-05-08T19:18:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10491,6 +10486,33 @@ def ld_matrix(
             mode=mode,
         )
 
+    def sample_nodes_by_ploidy(self, ploidy):
+        """
+        Returns an 2D array of node IDs, where each row has length `ploidy`.
+        This is useful when individuals are not defined in the tree sequence
+        so `TreeSequence.individuals_nodes` cannot be used. The samples are
+        placed in the array in the order which they are found in the node
+        table. Ploidy must be a multiple of the number of sample nodes.
path: python/tskit/trees.py
user: login: benjeffery
body: Thanks!
created_at: 2025-05-08T22:29:27Z
author_association: MEMBER
diff_hunk: @@ -353,13 +354,27 @@ def test_individuals_no_nodes_default_args(self):
             allow_position_zero=True
         )
 
+    def test_individuals_no_nodes_but_specified(self):
+        ts = msprime.sim_ancestry(10, random_seed=2)
+        ts = msprime.sim_mutations(ts, rate=1, random_seed=2)
+        tables = ts.dump_tables()
+        tables.individuals.add_row()
+        ts = tables.tree_sequence()
+        with pytest.raises(ValueError, match="is not associated with any nodes"):
+            ts.as_vcf(
+                allow_position_zero=True,
+                individuals=list(range(ts.num_individuals)) + [ts.num_individuals - 1],
path: python/tests/test_vcf.py
user: login: jeromekelleher
body: The last bit is redundant isn't it?
created_at: 2025-05-09T08:13:06Z
author_association: MEMBER
diff_hunk: @@ -140,54 +139,37 @@ def __make_sample_mapping(self, ploidy, individuals):
             raise ValueError(
                 "Cannot specify ploidy when individuals are present in tables "
             )
-
-        if individuals is None:
-            # Find all sample nodes that reference individuals
-            individuals = np.unique(ts.nodes_individual[ts.samples()])
-            if len(individuals) == 1 and individuals[0] == tskit.NULL:
-                # No samples refer to individuals
-                individuals = None
-            else:
-                # np.unique sorts the argument, so if NULL (-1) is present it
-                # will be the first value.
-                if individuals[0] == tskit.NULL:
-                    raise ValueError(
-                        "Sample nodes must either all be associated with individuals "
-                        "or not associated with any individuals"
-                    )
+        # If there are no individuals, or all the individuals are not associated with
path: python/tskit/vcf.py
user: login: jeromekelleher
body: I think we should make this simpler - just
```
if ploidy is not None and individuals is not None:
    raise ValueError("...")
if individuals is None:

    if ts.num_individuals > 0:
         individuals_nodes = ts.individuals_nodes
    else:
         ploidy = 1 if ploidy is None else ploidy
         individuals_nodes = ts.sample_nodes_by_ploidy(ploidy)

else:
      individuals_nodes = ts.individuals_nodes[individuals]

```

created_at: 2025-05-09T08:20:04Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-05-07T12:10:35Z
body: ## [Codecov](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 89.59%. Comparing base [(`6542fc2`)](https://app.codecov.io/gh/tskit-dev/tskit/commit/6542fc27ef205476e6fb1b1c7c695c8e224b6d7e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) to head [(`a12aab9`)](https://app.codecov.io/gh/tskit-dev/tskit/commit/a12aab911a083ea74ab2aec2861c7961afef21c4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #3157   +/-   ##
=======================================
  Coverage   89.58%   89.59%           
=======================================
  Files          28       28           
  Lines       31885    31895   +10     
  Branches     5855     5857    +2     
=======================================
+ Hits        28565    28575   +10     
  Misses       1888     1888           
  Partials     1432     1432           
```

| [Flag](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) | Coverage Δ | |
|---|---|---|
| [c-tests](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) | `86.66% <ø> (ø)` | |
| [lwt-tests](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) | `80.38% <ø> (ø)` | |
| [python-c-tests](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) | `88.18% <ø> (ø)` | |
| [python-tests](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) | `98.79% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files with missing lines](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev) | Coverage Δ | |
|---|---|---|
| [python/tskit/trees.py](https://app.codecov.io/gh/tskit-dev/tskit/pull/3157?src=pr&el=tree&filepath=python%2Ftskit%2Ftrees.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tskit-dev#diff-cHl0aG9uL3Rza2l0L3RyZWVzLnB5) | `98.84% <100.00%> (+<0.01%)` | :arrow_up: |

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: benjeffery
created_at: 2025-05-07T13:59:37Z
body: I've used the new functions in the VCF code - I've tried to keep the tests unchanged with a couple of exceptions:

- A more descriptive error when `samples % ploidy != 0`
- Mixed sample and non-sample nodes are now allowed.
- It is now allowed that some sample nodes are not associated with individuals.

author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-07T14:04:19Z
body: Huh, `test_vcf.py` is passing, but I didn't check `test_cli.py` locally. Digging in.
author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-07T14:11:16Z
body: Hmm, some of the error semantics in the existing code are a bit odd. I've replicated them for compatibility. For example an individual with no nodes is fine, but not if that individual was specifically asked for. This was only tested in the CLI code.
author_association: MEMBER
user: login: jeromekelleher
created_at: 2025-05-07T14:24:01Z
body: I think it's OK to relax the input requirements a bit now. Making things that used to raise an error work as expected now seems like forward progress to me, and it seems unlikely we'll break much code by doing that (people don't tend to rely on errors much). We just need to document the changes.
author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-07T15:20:23Z
body: Ok, I've simplified things so that the error is only triggered when all the individuals specified have no nodes.
author_association: MEMBER
user: login: jeromekelleher
created_at: 2025-05-07T15:23:28Z
body: What's the output for an individual with no nodes then, missing data?

We should document these corner cases now, in the ``vcf`` method or it'll be forgotten again
author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-08T11:57:08Z
body: I've added some changes to the docs and a changelog.
author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-08T12:33:01Z
body: Fixed up.
author_association: MEMBER
user: login: jeromekelleher
created_at: 2025-05-08T14:00:38Z
body: I'm confused. I thought the semantics was:

- If there's an individual in the individuals table that has no nodes, and ``ts.vcf(individuals=None)``, then that individual gets ignored in the output
- If that individual is references in ``ts.vcf(individuals=some_list)`` then an error is raised

That's what the release notes say to me, but the docstring is not.
author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-08T14:14:07Z
body: The code and docstring are "An individual specified that is not associated with any nodes will be ignored and not included in the output." The changelog before revisions was "allow individuals without nodes to be specified for output, although they will not appear in the VCF" which is the same thing.  I didn't read your revision close enough to realise it meant the opposite. Happy to change the code if you think the erroring on nodeless, specified, individual is better.

author_association: MEMBER
user: login: jeromekelleher
created_at: 2025-05-08T14:53:25Z
body: Yes please
author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-08T22:40:08Z
body: Ok, have tried to be really careful this time! It is now an error again to specify nodeless individuals. I've also realised that it needs to be stated in the changelog that it is now allowed to have sample nodes that have no individual.
author_association: MEMBER
user: login: benjeffery
created_at: 2025-05-12T14:32:08Z
body: As discussed, I've removed the VCF code changes here to reduce this to solely the `split_samples_by_ploidy` function.
author_association: MEMBER