Repository: cogent3/cogent3 (python)

Issue:
`DistantanceMatrix` should have a method to find max_pair
Method should return the two strings that correspond to the maximally divergent pair.

Working on the underlying numpy array is likely the most performant way to implement this. 

sketch:

```py 
def max_pair(self):

  max_index_flat = np.argmax(self)
  max_index_1, max_index_2 = np.unravel_index(max_index_flat, self.shape)
  max_pair = (self.names[max_index_1], self.names[max_index_2])

  return max pair
```

Pull request:
ENH: add methods to get the max and min pair of a distance matrix, fixes #1626

Patch:
diff --git a/changelog.d/20231212_084420_Gavin.Huttley.md b/changelog.d/20231212_165048_u6096186_develop.md
similarity index 55%
rename from changelog.d/20231212_084420_Gavin.Huttley.md
rename to changelog.d/20231212_165048_u6096186_develop.md
index effa0d789..51cc5e44e 100644
--- a/changelog.d/20231212_084420_Gavin.Huttley.md
+++ b/changelog.d/20231212_165048_u6096186_develop.md
@@ -4,18 +4,15 @@ A new scriv changelog fragment.
 Uncomment the section that is right (remove the HTML comment wrapper).
 -->
 
-
+<!--
 ### Contributors
 
-- Robert McArthur .. again. Thanks @rmcar17!
+- A bullet item for the Contributors category.
 
+-->
 ### ENH
 
-- Added the ever popular Robinson-Fould tree topology measure, but
-  you shouldn't use it except for comparison to the far superior
-  (statistically at least) matching distance measures. These are all
-  available on the tree objects via a new PhyloNode.tree_distance()
-  method.
+- Added methods `min_pair()` and `max_pair()` to `DistanceMatrix`, to return the names of the sequences with minimum and maximum pairwise distance, respectively. 
 
 
 <!--
diff --git a/doc/cookbook/calc_genetic_distance.rst b/doc/cookbook/calc_genetic_distance.rst
index ab79bfacf..0058acfa3 100644
--- a/doc/cookbook/calc_genetic_distance.rst
+++ b/doc/cookbook/calc_genetic_distance.rst
@@ -72,3 +72,49 @@ The following will use the F81 nucleotide substitution model and perform numeric
     d.run(show_progress=False)
     dists = d.get_pairwise_distances()
     dists
+
+*****************************************************
+Get the names of sequences with max pairwise distance
+*****************************************************
+
+Given a ``DistanceMatrix`` object, finding the sequences that have the maximum pairwise distance is achieved through the ``max_pair`` method. 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import load_aligned_seqs
+
+    aln = load_aligned_seqs("data/primate_brca1.fasta", moltype="dna")
+    dists = aln.distance_matrix(calc="tn93", show_progress=False)
+    dists.max_pair()
+    
+To find the maximum distance, index the ``DistanceMatrix`` with the result of ``max_pair``.
+
+.. jupyter-execute::
+    :raises:
+    
+    dists[dists.max_pair()]
+
+*****************************************************
+Get the names of sequences with min pairwise distance
+*****************************************************
+
+Given a ``DistanceMatrix`` object, finding the sequences that have the minimum pairwise distance is achieved through the ``min_pair`` method. 
+
+.. note:: As the distance between a sequence and itself is zero, and this is not informative, ``min_pair`` will return the smallest distance not on the diagonal.
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import load_aligned_seqs
+
+    aln = load_aligned_seqs("data/primate_brca1.fasta", moltype="dna")
+    dists = aln.distance_matrix(calc="tn93", show_progress=False)
+    dists.min_pair()
+
+To find the minimum distance, index the ``DistanceMatrix`` with the result of ``min_pair``.
+
+.. jupyter-execute::
+    :raises:
+
+    dists[dists.min_pair()]
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index d452d6fb6..9df5bc997 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -1652,15 +1652,9 @@ class _SequenceCollectionBase:
 
     def to_moltype(self, moltype):
         """returns copy of self with moltype seqs"""
-        import cogent3
-
         if not moltype:
             raise ValueError(f"unknown moltype '{moltype}'")
 
-        moltype = cogent3.get_moltype(moltype)
-        if moltype is self.moltype:
-            return self
-
         data = [s.to_moltype(moltype) for s in self.seqs]
         result = self.__class__(
             data=data, moltype=moltype, name=self.name, info=self.info
@@ -2442,15 +2436,6 @@ class Aligned:
 
     def to_moltype(self, moltype):
         """returns copy of self with moltype seqs"""
-        import cogent3
-
-        if not moltype:
-            raise ValueError(f"unknown moltype '{moltype}'")
-
-        moltype = cogent3.get_moltype(moltype)
-        if moltype is self.moltype:
-            return self
-
         data = self.data.to_moltype(moltype)
         return self.__class__(map=self.map, data=data)
 
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 9828d057b..690f3cffb 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -1163,9 +1163,6 @@ class Sequence(SequenceI):
             raise ValueError(f"unknown moltype '{moltype}'")
 
         moltype = get_moltype(moltype)
-        if moltype is self.moltype:
-            return self
-
         s = moltype.coerce_str(self._seq.value)
         moltype.verify_sequence(s, gaps_allowed=True, wildcards_allowed=True)
         sv = SeqView(s)
diff --git a/src/cogent3/evolve/fast_distance.py b/src/cogent3/evolve/fast_distance.py
index e6b684609..3b73e9af2 100644
--- a/src/cogent3/evolve/fast_distance.py
+++ b/src/cogent3/evolve/fast_distance.py
@@ -1,14 +1,14 @@
 from collections import defaultdict, namedtuple
 from numbers import Number
+from typing import Tuple
 
-import numpy
+import numpy as np
 
 from numpy import array, diag, dot, eye, float64, int32, log, sqrt, zeros
 from numpy.linalg import det, inv
 
 from cogent3._version import __version__
 from cogent3.core.moltype import DNA, RNA, get_moltype
-from cogent3.util import warning as c3warn
 from cogent3.util.dict_array import DictArray
 from cogent3.util.misc import get_object_provenance
 from cogent3.util.progress_display import display_wrap
@@ -800,9 +800,9 @@ class DistanceMatrix(DictArray):
             raise RuntimeError("Must be a square matrix")
         names = array(self.names)
         # NaN is an invalid value
-        cols = numpy.isnan(self.array).sum(axis=0)
+        cols = np.isnan(self.array).sum(axis=0)
         exclude = names[cols != 0].tolist()
-        rows = numpy.isnan(self.array).sum(axis=1)
+        rows = np.isnan(self.array).sum(axis=1)
         exclude += names[rows != 0].tolist()
         exclude = set(exclude)
         keep = set(names) ^ exclude
@@ -810,6 +810,7 @@ class DistanceMatrix(DictArray):
 
     def quick_tree(self, show_progress=False):
         """returns a neighbour joining tree
+
         Returns
         -------
         an estimated Neighbour Joining Tree, note that invalid distances are dropped
@@ -822,3 +823,43 @@ class DistanceMatrix(DictArray):
             raise ValueError("Too few distances to build a treenj")
         dists = dists.to_dict()
         return nj(dists, show_progress=show_progress)
+
+    def max_pair(self) -> Tuple[str, str]:
+        """returns the pair of names with the maximum distance
+
+        Returns
+        -------
+        tuple of the two names corresponding to the maximum distance
+
+        Notes
+        -----
+        In case of multiple occurrences of the maximum values, the names
+        corresponding to the first occurrence are returned.
+        """
+        max_index_flat = np.argmax(self)
+        max_index_1, max_index_2 = np.unravel_index(max_index_flat, self.shape)
+        max_pair = self.names[max_index_1], self.names[max_index_2]
+
+        return max_pair
+
+    def min_pair(self) -> Tuple[str, str]:
+        """returns the pair of names with the minimum distance (excluding the diagonal)
+
+        Returns
+        -------
+        tuple of the two names corresponding to the minimum distance
+
+        Notes
+        -----
+        In case of multiple occurrences of the minimum values, the names
+        corresponding to the first occurrence are returned.
+        """
+        dmat_copy = self.array.copy()
+        np.fill_diagonal(
+            dmat_copy, np.inf
+        )  # Exclude diagonal by setting diagonal elements to infinity
+        min_index_flat = np.argmin(dmat_copy)
+        min_index_1, min_index_2 = np.unravel_index(min_index_flat, self.shape)
+        min_pair = self.names[min_index_1], self.names[min_index_2]
+
+        return min_pair


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index c7336d12f..f9985893c 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -3826,13 +3826,3 @@ def test_init_duplicate_keys_merged(cls):
     seqs = cls(data, remove_duplicate_names=True)
     # the first record encountered is the one retained
     assert seqs.to_dict()["x"] == dict(reversed(data))["x"]
-
-
-@pytest.mark.parametrize("moltype", ("dna", "protein"))
-@pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
-def test_same_moltype(cls, moltype):
-    moltype = get_moltype(moltype)
-    data = dict(s1="ACGTT", s2="ACCTT")
-    seqs = cls(data, moltype=moltype)
-    got = seqs.to_moltype(moltype=moltype)
-    assert got is seqs
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 0f1af2318..084bed310 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -2477,11 +2477,3 @@ def test_index_a_seq_float_fail(cast):
     index = cast(1)
     with pytest.raises(TypeError):
         seq[index]
-
-
-@pytest.mark.parametrize("moltype", ("dna", "protein"))
-def test_same_moltype(moltype):
-    moltype = get_moltype(moltype)
-    seq = moltype.make_seq("TCCAG")
-    got = seq.to_moltype(moltype)
-    assert got is seq
diff --git a/tests/test_evolve/test_distance.py b/tests/test_evolve/test_distance.py
index cf73accf6..8bc121d7b 100644
--- a/tests/test_evolve/test_distance.py
+++ b/tests/test_evolve/test_distance.py
@@ -4,6 +4,7 @@ import warnings
 from unittest import TestCase
 
 import numpy
+import pytest
 
 from numpy.testing import assert_allclose, assert_equal
 
@@ -923,3 +924,79 @@ class DistancesTests(TestCase):
 
 if __name__ == "__main__":
     main()
+
+
+@pytest.fixture
+def min_working_example_dmat():
+    return DistanceMatrix(
+        {
+            ("A", "B"): 1,
+            ("A", "C"): 2,
+            ("B", "C"): 3,
+        }
+    )
+
+
+def test_max_pair_mwe(min_working_example_dmat):
+    assert min_working_example_dmat.max_pair() == ("B", "C")
+
+
+def test_min_pair_mwe(min_working_example_dmat):
+    assert min_working_example_dmat.min_pair() == ("A", "B")
+
+
+def test_max_pair_has_max_val():
+    aln = load_aligned_seqs("data/primate_brca1.fasta", moltype="dna")
+    dmat = aln.distance_matrix()
+    got = dmat[dmat.max_pair()]
+    expect = dmat.array.max()
+    assert got == expect
+
+
+def test_min_pair_has_min_val():
+    aln = load_aligned_seqs("data/primate_brca1.fasta", moltype="dna")
+    dmat = aln.distance_matrix()
+    got = dmat[dmat.min_pair()]
+    numpy.fill_diagonal(dmat.array, numpy.inf)
+    expect = dmat.array.min()
+    assert got == expect
+
+
+def test_min_max_pair_single_pair():
+    dmat = DistanceMatrix({("A", "B"): 2})
+    assert dmat.max_pair() == ("A", "B")
+    assert dmat.min_pair() == ("A", "B")
+
+
+def test_max_pair_tied():
+    dmat = DistanceMatrix(
+        {
+            ("A", "B"): 1,
+            ("A", "C"): 1,
+            ("A", "D"): 3,
+            ("B", "C"): 3,
+            ("B", "D"): 2,
+            ("C", "D"): 2,
+        }
+    )
+
+    got = set(dmat.max_pair())
+    expect = {frozenset(("B", "C")), frozenset(("A", "D"))}
+    assert got in expect
+
+
+def test_min_pair_tied():
+    dmat = DistanceMatrix(
+        {
+            ("A", "B"): 1,
+            ("A", "C"): 1,
+            ("A", "D"): 3,
+            ("B", "C"): 3,
+            ("B", "D"): 2,
+            ("C", "D"): 2,
+        }
+    )
+
+    got = set(dmat.min_pair())
+    expect = {frozenset(("A", "B")), frozenset(("A", "C"))}
+    assert got in expect