Repository: cogent3/cogent3 (language: python)

Issue:
REL: final changes for new release

initialising likelihood function with tree and zero lengths incompatible with Pade
The following code
```python
import pathlib
from cogent3 import get_model, load_aligned_seqs, make_tree

path = pathlib.Path("~/repos/Cogent3/tests/data")

aln = load_aligned_seqs(path / "brca1.fasta", moltype="dna")
aln = aln.take_seqs(["Human", "Rhesus", "Mouse"])
tree = load_tree(str(path / "murphy.tree"))

tree = tree.get_sub_tree(aln.names)

tree = make_tree("(Mouse:0.0,Human:0.0,Rhesus:0.0)")
sm = get_model("ssGN")
lf = sm.make_likelihood_function(tree, expm="pade")
lf.set_alignment(aln)
lf.optimise()
```

Generates

<details>
<summary>Traceback</summary>
ValueError                                Traceback (most recent call last)
/var/folders/rh/t24zj19s7qxd4_4p0n5rqjy00000gn/T/ipykernel_11415/1583999322.py in <module>
     14 lf = sm.make_likelihood_function(tree, expm="pade")
     15 lf.set_alignment(aln)
---> 16 lf.optimise()

~/opt/miniconda3/envs/c39dev/lib/python3.9/site-packages/cogent3/recalculation/scope.py in optimise(self, local, filename, interval, limit_action, max_evaluations, tolerance, global_tolerance, **kw)
    878         lc = self.make_calculator()
    879         try:
--> 880             lc.optimise(**kw)
    881         except MaximumEvaluationsReached as detail:
    882             evals = detail.args[0]

~/opt/miniconda3/envs/c39dev/lib/python3.9/site-packages/cogent3/recalculation/calculation.py in optimise(self, **kw)
    310         x = self.get_value_array()
    311         bounds = self.get_bounds_vectors()
--> 312         maximise(self, x, bounds, **kw)
    313         self.optimised = True
    314 

~/opt/miniconda3/envs/c39dev/lib/python3.9/site-packages/cogent3/util/progress_display.py in f(*args, **kw)
    201         kw["ui"] = CURRENT.context = subcontext
    202         try:
--> 203             result = slow_function(*args, **kw)
    204         finally:
    205             CURRENT.context = parent

~/opt/miniconda3/envs/c39dev/lib/python3.9/site-packages/cogent3/maths/optimisers.py in maximise(f, xinit, bounds, local, filename, interval, max_restarts, max_evaluations, limit_action, tolerance, global_tolerance, ui, return_eval_count, **kw)
    172         )
    173     if not numpy.isfinite(fval):
--> 174         raise ValueError(
    175             "Initial parameter values must evaluate to a finite value, not %s. %s"
    176             % (fval, x)

ValueError: Initial parameter values must evaluate to a finite value, not -inf. [ 0.          0.          0.          0.          0.          0.3172217
 -0.20899671 -0.49562244  0.          0.          0.        ]
</details>

But removing the `expm="pade"` or setting the value as "eigen" does not have this issue.
Sequence.degap drops sequence annotations
```python
from cogent3 import DNA
from cogent3.core.annotation import Feature

s1 = DNA.make_seq(
    "AAGAAGAAGACCCCCAAAAAAAAAA" "TTTTTTTTTTAAAAAGGGAACCCT", name="seq1"
)

_ = s1.add_annotation(Feature, "exon", "A", [(10, 15)])
assert len(s1.annotations) == len(s1.degap().annotations), "lost the annotations!"
```
It's happening in the `MolType.degap()`. That method needs to check if there are any gaps in the sequence argument, and if not, just return the sequence as is.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 47114a012..b35ed5c7f 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -13,10 +13,11 @@ name: "CodeQL"
 
 on:
   push:
-    branches: [ "develop" ]
+    branches-ignore:
+      - master
   pull_request:
-    # The branches below must be a subset of the branches above
-    branches: [ "develop" ]
+    branches-ignore:
+      - master
   schedule:
     - cron: '39 20 * * 6'
 
diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml
index e01ca5b50..bfaaad9d2 100644
--- a/.github/workflows/linters.yml
+++ b/.github/workflows/linters.yml
@@ -18,7 +18,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v2
         with:
-          python-version: '3.10'
+          python-version: '3.11'
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
diff --git a/.hgtags b/.hgtags
index bd399937e..ef7fe1bd2 100644
--- a/.hgtags
+++ b/.hgtags
@@ -34,3 +34,4 @@ a82da4d3716db1c03ddf7d4bb2e3f5e2a5a143d1 develop
 a82da4d3716db1c03ddf7d4bb2e3f5e2a5a143d1 develop
 088fba384849b26e97c418dc60796d69a722129e develop
 91ccb723aa378107f80ac0fe8cb6f65e94f7833a 2023.7.18a1
+d77104f69e616ed3b077188540901cdb5d968966 2023.9.22a1
diff --git a/changelog.d/templates/title.md.j2 b/changelog.d/templates/title.md.j2
index 4498d17be..6650a80fb 100644
--- a/changelog.d/templates/title.md.j2
+++ b/changelog.d/templates/title.md.j2
@@ -1,1 +1,1 @@
-Changes since release "{{version}}"
\ No newline at end of file
+Changes in release "{{version}}"
\ No newline at end of file
diff --git a/changelog.md b/changelog.md
index 029e49515..87a685db1 100755
--- a/changelog.md
+++ b/changelog.md
@@ -1,6 +1,36 @@
 
+<a id='changelog-2023.9.22a1'></a>
+# Changes in release "2023.9.22a1"
+
+## Contributors
+
+- YapengLang for reporting the bug in computing the ENS for
+  non-stationary processes.
+
+## ENH
+
+- MinimalFastaParser now removes internal spaces
+  in sequence blocks
+
+- Added app.evo.model(tree_func: Optional[Callable]) argument. A callback
+  function assigned to tree_func is passed an alignment and returns a tree.
+  This could be done via loading a tree from disk for that alignment source,
+  or estimation of the tree using the alignment. The argument overrides the
+  other tree related arguments (tree, unique_trees).
+
+## BUG
+
+- The calculation of the expected number of substitutions on a tree branch
+  was incorrect. It was not using the motif probs from the parent node.
+
+- Degapped sequence and collections now retain features
+
+- Fixed issue #993. We provide a new default_length argument to
+  LF.make_likelihood_function() to be applied when a provided tree
+  has zero (or no) lengths. This is set to be 1.
+
 <a id='changelog-2023.7.18a1'></a>
-# Changes since release "2023.7.18a1"
+# Changes in release "2023.7.18a1"
 
 ## Contributers
 
@@ -153,7 +183,7 @@
 
 <a id='changelog-2023.2.12a1'></a>
 
-# Changes since release "2023.2.12a1"
+# Changes in release "2023.2.12a1"
 
 ## Contributors
 
diff --git a/doc/cookbook/DNA_and_RNA_sequences.rst b/doc/cookbook/DNA_and_RNA_sequences.rst
index a07d0995c..6a22020b2 100644
--- a/doc/cookbook/DNA_and_RNA_sequences.rst
+++ b/doc/cookbook/DNA_and_RNA_sequences.rst
@@ -1,12 +1,12 @@
 .. _dna-rna-seqs:
 
 Sequences
-=========
+---------
 
 The ``Sequence`` object provides generic biological sequence manipulation functions, plus functions that are critical for the ``evolve`` module calculations.
 
 Generic molecular types
------------------------
+^^^^^^^^^^^^^^^^^^^^^^^
 
 Sequence properties are affected by the moltype you specify. The default type for a sequence is ``"text"``.
 
@@ -31,16 +31,16 @@ In some circumstances you can also have a ``"bytes"`` moltype, which I'll explic
 .. jupyter-execute::
 
     my_seq
-    
+
 
 
 DNA and RNA sequences
----------------------
+^^^^^^^^^^^^^^^^^^^^^
 
 .. authors, Gavin Huttley, Kristian Rother, Patrick Yannul, Tom Elliott, Tony Walters, Meg Pirrung
 
 Creating a DNA sequence from a string
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""""""""""""""
 
 Sequence properties are affected by the moltype you specify. Here we specify the ``DNA`` ``MolType``.
 
@@ -52,7 +52,7 @@ Sequence properties are affected by the moltype you specify. Here we specify the
     my_seq
 
 Creating a RNA sequence from a string
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -61,7 +61,7 @@ Creating a RNA sequence from a string
     rnaseq = make_seq("ACGUACGUACGUACGU", moltype="rna")
 
 Converting to FASTA format
-^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -71,7 +71,7 @@ Converting to FASTA format
     my_seq
 
 Convert a RNA sequence to FASTA format
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -81,7 +81,7 @@ Convert a RNA sequence to FASTA format
     rnaseq
 
 Creating a named sequence
-^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""
 
 You can also use a convenience ``make_seq()`` function, providing the moltype as a string.
 
@@ -94,7 +94,7 @@ You can also use a convenience ``make_seq()`` function, providing the moltype as
     type(my_seq)
 
 Setting or changing the name of a sequence
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -105,7 +105,7 @@ Setting or changing the name of a sequence
     my_seq
 
 Complementing a DNA sequence
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -115,7 +115,7 @@ Complementing a DNA sequence
     my_seq.complement()
 
 Reverse complementing a DNA sequence
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -124,7 +124,7 @@ Reverse complementing a DNA sequence
 .. _translation:
 
 Translate a ``DnaSequence`` to protein
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -139,7 +139,7 @@ Translate a ``DnaSequence`` to protein
     pep
 
 Converting a DNA sequence to RNA
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -150,7 +150,7 @@ Converting a DNA sequence to RNA
     rnaseq
 
 Convert an RNA sequence to DNA
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -161,7 +161,7 @@ Convert an RNA sequence to DNA
     dnaseq
 
 Testing complementarity
-^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -175,7 +175,7 @@ Testing complementarity
     a.can_pair(a.rc())
 
 Joining two DNA sequences
-^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""
 
 .. jupyter-execute::
 
@@ -187,14 +187,14 @@ Joining two DNA sequences
     long_seq
 
 Slicing DNA sequences
-^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""
 
 .. jupyter-execute::
 
     my_seq[1:6]
 
 Getting 3rd positions from codons
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""""""""""
 
 The easiest approach is to work off the ``cogent3`` ``ArrayAlignment`` object.
 
@@ -207,7 +207,7 @@ The easiest approach is to work off the ``cogent3`` ``ArrayAlignment`` object.
     assert str(pos3) == "GGGG"
 
 Getting 1st and 2nd positions from codons
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""""""""""""""""""
 
 In this instance we can use features.
 
@@ -222,14 +222,14 @@ In this instance we can use features.
     assert str(pos12) == "ATATATAT"
 
 Return a randomized version of the sequence
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
     rnaseq.shuffle()
 
 Remove gaps from a sequence
-^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""""""""""""""""""""""""""
 
 .. jupyter-execute::
 
diff --git a/doc/cookbook/annotation_db.rst b/doc/cookbook/annotation_db.rst
index 34a4d7910..d4678af51 100644
--- a/doc/cookbook/annotation_db.rst
+++ b/doc/cookbook/annotation_db.rst
@@ -83,7 +83,7 @@ We can add a feature to the empty ``BasicAnnotationDb`` we created above. Now th
 .. jupyter-execute::
     :raises:
 
-     anno_db.add_feature(
+    anno_db.add_feature(
                seqid="NC_000908",
                biotype="gene",
                name="interesting_gene",
diff --git a/doc/cookbook/features.rst b/doc/cookbook/features.rst
index f210d39c3..bd02f2701 100644
--- a/doc/cookbook/features.rst
+++ b/doc/cookbook/features.rst
@@ -138,7 +138,7 @@ From a Genbank file
 How to load features and sequence data
 ++++++++++++++++++++++++++++++++++++++
 
-To load the sequence and all 40,578 features from *C. elegans* Chromosome 1, we use the ``load_seq`` function. The loading process takes approximately 1.4 seconds ⚡️
+To load the sequence and all 40,578 features from *C. elegans* Chromosome 1, we use the ``load_seq`` function ⚡️
 
 .. jupyter-execute::
     :raises:
diff --git a/doc/rst2script.py b/doc/rst2script.py
index 94a44dce3..bb2c00b13 100755
--- a/doc/rst2script.py
+++ b/doc/rst2script.py
@@ -8,12 +8,20 @@
 import click
 
 
-not_wsp = re.compile(r"^\S+")
+not_wsp = re.compile(r"\S+")
 code_eval = re.compile(r"(?<=\s{4})([>]{3}|[.]{3})\s")
 code_block = re.compile(r".. (jupyter-execute|doctest)::")
 block_option = re.compile(r"\s+:[a-z\-]+:")
 raise_option = re.compile(r"\s+:raises:")
 plotly_show = re.compile(r"\s*[a-z]+.*\.show\(")
+ipython_magic = re.compile(r"^\s*%[a-zA-Z]+")
+
+
+def _end_of_block(line, indent):
+    if match := not_wsp.search(line):
+        return match.start() == indent
+
+    return False
 
 
 def get_error_type(line):
@@ -51,38 +59,40 @@ def get_code_block_line_numbers(doc):
     lines = []
     in_code_block = False
     start = None
+    indent = 0
     for i, line in enumerate(doc):
-        if code_block.search(line):
+        if hit := code_block.search(line):
             if in_code_block:
-                lines.append((start, i - 1))
+                lines.append((start, i - 1, indent))
             start = i
             in_code_block = True
+            indent = hit.start()
             continue
 
-        if in_code_block and not_wsp.search(line):
-            lines.append((start, i))
+        if in_code_block and _end_of_block(line, indent):
+            lines.append((start, i, indent))
             in_code_block = False
             continue
 
     if in_code_block:
         if i == len(doc) - 1:
             i += 1
-        lines.append((start, i))
+        lines.append((start, i, indent))
 
     return lines
 
 
-def format_block(block):
+def format_block(block, indent):
     """handles exceptions, de-indent, etc..."""
     error_type = get_error_type(block[0])
     format_line = (lambda x: x) if error_type else deindent
-    code = [format_line(l) for l in block if not block_option.search(l)]
+    code = [format_line(l[indent:]) for l in block if not block_option.search(l)]
     if error_type:
         code.insert(0, "try:")
         code.extend([f"except {error_type}:", "    pass"])
 
     for i, l in enumerate(code):
-        if plotly_show.search(l):
+        if plotly_show.search(l) or ipython_magic.search(l):
             # comment out as cannot be executed in script
             code[i] = f"# {l}"
 
@@ -92,8 +102,8 @@ def format_block(block):
 def get_code_blocks(doc: list[str], working_dir: pathlib.Path) -> str:
     coords = get_code_block_line_numbers(doc)
     refactored = [get_path_update(working_dir)]
-    for start, end in coords:
-        code = format_block(doc[start + 1 : end])
+    for start, end, indent in coords:
+        code = format_block(doc[start + 1 : end], indent)
         refactored.extend([""] + code)
 
     return "\n".join(refactored)
diff --git a/pyproject.toml b/pyproject.toml
index 1f760a677..18083d7d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,14 +6,13 @@ build-backend = "flit_core.buildapi"
 name = "cogent3"
 description = """COmparative GENomics Toolkit 3: genomic sequence analysis within notebooks or on compute systems with 1000s of CPUs."""
 # remember to update cogent3/_version.py too!
-version = "2023.7.18a1"
+version = "2023.9.22a1"
 authors = [
     { name = "Gavin Huttley", email = "Gavin.Huttley@anu.edu.au"},
 ]
 keywords = ["biology", "genomics", "statistics", "phylogeny", "evolution", "bioinformatics"]
 readme = "README.md"
 license = { file = "LICENSE" }
-requires-python = ">=3.8,<3.12"
 dependencies = ["chardet",
         "numpy",
         "numba>0.48.0;python_version<'3.9'",
@@ -24,6 +23,8 @@ dependencies = ["chardet",
         "scitrack",
         "tqdm",
         "typing_extensions"]
+# remember to update version in requires-python and classifiers
+requires-python = ">=3.8,<3.12"
 classifiers = [
         "Development Status :: 5 - Production/Stable",
         "Intended Audience :: Science/Research",
@@ -34,6 +35,7 @@ classifiers = [
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
     ]
 
 [project.urls]
@@ -44,9 +46,9 @@ Changelog = "https://github.com/cogent3/cogent3/blob/develop/changelog.md"
 
 [project.optional-dependencies]
 test = [
-    "black==23.7.0",
+    "black==23.9.1",
     "isort==5.12.0",
-    "click==8.1.5", # black needs this pinned
+    "click==8.1.7", # black needs this pinned
     "kaleido",
     "pandas",
     "plotly",
@@ -56,7 +58,7 @@ test = [
     "pytest-xdist",
     "tinydb",
     "nox"]
-doc  = ["click==8.1.5",
+doc  = ["click==8.1.7",
         "ipykernel",
         "ipython",
         "ipywidgets",
@@ -81,8 +83,8 @@ doc  = ["click==8.1.5",
         "sphinxcontrib-bibtex",
         "sphinxcontrib-video"]
 extra = ["pandas", "plotly", "psutil", "kaleido"]
-dev = ["black==23.7.0",
-       "click==8.1.5",
+dev = ["black==23.9.1",
+       "click==8.1.7",
        "flit",
        "ipykernel",
        "ipython",
diff --git a/requirements.txt b/requirements.txt
index c02e576fc..fa0f3c4e6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,13 +1,13 @@
-chardet==5.1.0
-pillow==10.0.0
+chardet==5.2.0
+pillow==10.0.1
 psutil==5.9.5
-numpy==1.25.1
+numpy==1.26.0
 numba>0.48.0;python_version<'3.9'
 numba>0.53; python_version>='3.9'
 numba>0.54; python_version>='3.10'
 numba>=0.57.0; python_version=='3.11'
-scipy==1.11.1
+scipy==1.11.2
 nox==2023.4.22
-plotly==5.15.0
+plotly==5.17.0
 typing_extensions>=3.7
 .[dev]
diff --git a/src/cogent3/_version.py b/src/cogent3/_version.py
index 36df0e589..ebe52a4e3 100644
--- a/src/cogent3/_version.py
+++ b/src/cogent3/_version.py
@@ -1,2 +1,2 @@
 # remember to update version in pyproject.toml too!
-__version__ = "2023.7.18a1"
+__version__ = "2023.9.22a1"
diff --git a/src/cogent3/align/pairwise.py b/src/cogent3/align/pairwise.py
index 96bb6abea..c517092e1 100644
--- a/src/cogent3/align/pairwise.py
+++ b/src/cogent3/align/pairwise.py
@@ -802,7 +802,7 @@ def dp(self, TM, dp_options, cells=None, backward=False):
         if dp_options.viterbi and cells is None:
             encoder = self.pair.get_pointer_encoding(len(T))
             problem_dimensions = self.pair.size + [len(T)]
-            problem_size = numpy.product(problem_dimensions)
+            problem_size = numpy.prod(problem_dimensions)
             memory = problem_size * encoder.bytes / 10**6
             if dp_options.local:
                 msg = "Local alignment"
diff --git a/src/cogent3/align/progressive.py b/src/cogent3/align/progressive.py
index d393470b2..1498c85a8 100644
--- a/src/cogent3/align/progressive.py
+++ b/src/cogent3/align/progressive.py
@@ -9,11 +9,6 @@
 from cogent3.util import warning as c3warn
 
 
-@c3warn.deprecated_args(
-    "2023.8",
-    reason="better name",
-    old_new=[("ests_from_pairwise", "params_from_pairwise")],
-)
 @UI.display_wrap
 def tree_align(
     model: str,
diff --git a/src/cogent3/app/align.py b/src/cogent3/app/align.py
index 29daa1acd..b6b7ada6d 100644
--- a/src/cogent3/app/align.py
+++ b/src/cogent3/app/align.py
@@ -575,20 +575,26 @@ def __init__(
         Parameters
         ----------
         score_matrix : dict
-            scoring dict, defaults to `make_dna_scoring_dict(10, -1, -8)` for DNA
-            and `make_generic_scoring_dict(10, moltype)` for other moltype.
+            scoring dict, defaults to `make_dna_scoring_dict(10, -1, -8)` for
+            DNA and `make_generic_scoring_dict(10, moltype)` for other moltype.
         insertion_penalty : int
             penalty for gap insertion
         extension_penalty : int
             penalty for gap extension
         moltype
-            molecular type of sequences
+            molecular type of sequences, defaults to "dna"
+
+        Note
+        ----
+        If the provided molecular type differs from the moltype of the
+        SequenceCollection to be aligned, the sequences are converted to
+        the provided moltype.
         """
-        moltype = get_moltype(moltype)
+        self.moltype = get_moltype(moltype)
         self._score_matrix = score_matrix or (
             make_dna_scoring_dict(10, -1, -8)
-            if moltype.label == "dna"
-            else make_generic_scoring_dict(10, moltype)
+            if self.moltype.label == "dna"
+            else make_generic_scoring_dict(10, self.moltype)
         )
         self._insertion_penalty = insertion_penalty
         self._extension_penalty = extension_penalty
@@ -601,6 +607,7 @@ def main(self, seqs: UnalignedSeqsType) -> AlignedSeqsType:
                 message="maximum number of two seqs per collection",
                 source=seqs,
             )
+        seqs = seqs.to_moltype(self.moltype)
         seq1, seq2 = seqs.seqs
         aln, score = classic_align_pairwise(
             seq1,
@@ -618,7 +625,7 @@ def main(self, seqs: UnalignedSeqsType) -> AlignedSeqsType:
             extension_penalty=self._extension_penalty,
             sw_score=score,
         )
-        return aln
+        return aln.to_moltype(self.moltype)
 
 
 @define_app
diff --git a/src/cogent3/app/evo.py b/src/cogent3/app/evo.py
index 08988f5cb..9ab8afd02 100644
--- a/src/cogent3/app/evo.py
+++ b/src/cogent3/app/evo.py
@@ -55,6 +55,7 @@ def __init__(
         sm,
         tree=None,
         unique_trees=False,
+        tree_func=None,
         name=None,
         optimise_motif_probs=False,
         sm_args=None,
@@ -80,6 +81,9 @@ def __init__(
         unique_trees : bool
             whether to specify a unique tree per alignment. Only applies if
             number of sequences equals 3.
+        tree_func: callable
+            a callable that takes an alignment and returns a Tree instance.
+            Overrides tree and unique_tree settings.
         name : str
             name of the model
         optimise_motif_probs : bool
@@ -124,6 +128,11 @@ def __init__(
         with the optimised likelihood function. In the case of split_codons,
         the result object has a separate entry for each codon position.
         """
+        if tree_func:
+            assert callable(tree_func), "tree_func must be callable or None"
+            tree = None
+            unique_trees = False
+
         self._verbose = verbose
         self._lower = lower
         self._upper = upper
@@ -147,6 +156,7 @@ def __init__(
             split_codons = False
 
         self._tree = interpret_tree_arg(tree)
+        self._tree_func = tree_func
         self._lf_args = deepcopy(lf_args or {})
         if not name:
             name = sm.name or "unnamed model"
@@ -267,8 +277,16 @@ def main(
             return NotCompleted("ERROR", self, msg, source=aln)
 
         evaluation_limit = opt_args.get("max_evaluations", None)
-        if self._tree is None or self._unique_trees:
-            assert len(aln.names) == 3, "to model more than 3, you must provide a tree"
+        if callable(self._tree_func):
+            self._tree = self._tree_func(aln)
+        elif self._tree is None or self._unique_trees:
+            if len(aln.names) > 3:
+                return NotCompleted(
+                    "ERROR",
+                    self,
+                    message="to model more than 3, you must provide a tree",
+                    source=aln,
+                )
             self._tree = make_tree(tip_names=aln.names)
 
         result = model_result(
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index 964d9befa..21c84f821 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -2373,14 +2373,6 @@ def __repr__(self):
     def with_termini_unknown(self):
         return self.__class__(self.map.with_termini_unknown(), self.data)
 
-    @c3warn.deprecated_callable(
-        "2023.8",
-        reason="handled by <collection>.copy_annotations()",
-        is_discontinued=True,
-    )
-    def copy_annotations(self, other):  # pragma: no cover
-        self.data.copy_annotations(other)
-
     @c3warn.deprecated_callable(
         "2023.10", "handled by <collection>.annotate_from_gff()", is_discontinued=True
     )
@@ -4804,18 +4796,6 @@ def get_projected_feature(self, *, seqid: str, feature: Feature) -> Feature:
         self.annotation_db.add_feature(**feature.to_dict())
         return result
 
-    @c3warn.deprecated_callable(
-        "2023.8",
-        reason="use <collection>.get_projected_feature()",
-        is_discontinued=True,
-    )
-    def project_annotation(self, seqid, annot):
-        """projects the alignment coordinate annotation onto seq"""
-        target_aligned = self.named_seqs[seqid]
-        if annot.parent is not self:
-            raise ValueError("Feature does not belong to this alignment")
-        return annot.remapped_to(target_aligned.data, target_aligned.map)
-
     def get_projected_features(self, *, seqid: str, **kwargs) -> list[Feature]:
         """projects all features from other sequences onto seqid"""
         # todo gah should there be a generator version,
@@ -4827,64 +4807,6 @@ def get_projected_features(self, *, seqid: str, **kwargs) -> list[Feature]:
             annots.extend(list(self.get_features(seqid=name, **kwargs)))
         return [self.get_projected_feature(seqid=seqid, feature=a) for a in annots]
 
-    @c3warn.deprecated_callable(
-        "2023.8",
-        reason="use <collection>.get_projected_features()",
-        is_discontinued=True,
-    )
-    def get_projected_annotations(self, *, seqid: str, **kwargs):
-        # todo gah make sure the original sequence is not the seqid
-        aln_annots = list(self.get_features(**kwargs))
-        return [self.project_annotation(seqid, a) for a in aln_annots]
-
-    @c3warn.deprecated_callable(
-        "2023.8",
-        reason="use <collection>.get_features(seqid=<seq_name>)",
-        is_discontinued=True,
-    )
-    def get_annotations_from_seq(
-        self, seq_name, annotation_type="*", **kwargs
-    ):  # pragma: no cover
-        return self.get_features(seqid=seq_name, biotype=annotation_type, **kwargs)
-
-    @c3warn.deprecated_callable(
-        "2023.8", reason="use <collection>.get_features()", is_discontinued=True
-    )
-    def get_annotations_from_any_seq(
-        self, annotation_type="*", **kwargs
-    ):  # pragma: no cover
-        result = []
-        for seq_name in self.names:
-            a = list(
-                self.get_features(seqid=seq_name, biotype=annotation_type, **kwargs)
-            )
-            result.extend(a)
-        return result
-
-    @c3warn.deprecated_callable(
-        "2023.8",
-        reason="handled by <collection>.get_features(seqid=<seqid>)",
-        is_discontinued=True,
-    )
-    def get_by_seq_annotation(self, seq_name, *args):  # pragma: no cover
-        result = []
-        for feature in self.get_features(seqid=seq_name):
-            segment = self[feature]
-            segment.name = '%s "%s" %s to %s of %s' % (
-                feature.biotype,
-                feature.name,
-                feature.map.start,
-                feature.map.end,
-                self.name or "",
-            )
-            result.append(segment)
-        return result
-
-    @c3warn.deprecated_args(
-        "2023.8",
-        reason="consistency with new API",
-        old_new=[("annot_types", "biotypes")],
-    )
     def with_masked_annotations(self, biotypes, mask_char=None, shadow=False):
         """returns an alignment with annot_types regions replaced by mask_char
         if shadow is False, otherwise all other regions are masked.
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 99533b9ae..ddccc04d4 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -122,12 +122,6 @@ def shadow(self):
         }
         return self.__class__(**kwargs)
 
-    @c3warn.deprecated_callable(
-        "2023.8", reason="new method", new="<instance>.shadow()"
-    )
-    def get_shadow(self):  # pragma: no cover
-        return self.shadow()
-
     def __len__(self):
         return len(self.map)
 
diff --git a/src/cogent3/core/moltype.py b/src/cogent3/core/moltype.py
index 366a9547b..9b3b27c3c 100644
--- a/src/cogent3/core/moltype.py
+++ b/src/cogent3/core/moltype.py
@@ -735,11 +735,6 @@ def to_regex(self, seq):
             seq[index] = f"[{''.join(expanded)}]"
         return "".join(seq)
 
-    def gettype(self):  # pragma: no cover
-        """Return the moltype label."""
-        deprecated("method", "gettype", "get_type", "2023.8", "pep8")
-        return self.label
-
     def get_type(self):  # pragma: no cover
         """Return the moltype label"""
         return self.label
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index f396ba164..797d411dd 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -148,24 +148,6 @@ def to_json(self):
         """returns a json formatted string"""
         return json.dumps(self.to_rich_dict())
 
-    def translate(self, *args, **kwargs):  # pragma: no cover
-        """returns the result of call str.translate
-
-        Notes
-        -----
-        This is a string method, nothing to do with translating into a
-        protein sequence.
-        """
-        from cogent3.util.warning import discontinued
-
-        discontinued(
-            "function",
-            "cogent3.core.sequence.Sequence.translate",
-            "2023.8",
-            "Better if user just converts sequence to string.",
-        )
-        return str(self).translate(*args, **kwargs)
-
     def count(self, item):
         """count() delegates to self._seq."""
         return str(self).count(item)
@@ -346,7 +328,11 @@ def disambiguate(self, method="strip"):
 
     def degap(self):
         """Deletes all gap characters from sequence."""
-        return self.__class__(self.moltype.degap(self), name=self.name, info=self.info)
+        result = self.__class__(
+            self.moltype.degap(self), name=self.name, info=self.info
+        )
+        result.annotation_db = self.annotation_db
+        return result
 
     def gap_indices(self):
         """Returns list of indices of all gaps in the sequence, or []."""
@@ -903,11 +889,6 @@ def replace_annotation_db(
 
         self._annotation_db = value
 
-    @deprecated_args(
-        "2023.8",
-        reason="consistent nomenclature",
-        old_new=[("feature_type", "biotype")],
-    )
     def get_features(
         self,
         *,
@@ -1024,13 +1005,6 @@ def _relative_spans(self, spans):
 
         return r_spans
 
-    @deprecated_callable(
-        "2023.8", reason="simpler name", new="<instance>.get_features()"
-    )
-    def get_features_matching(self, **kwargs):
-        """use .get_features()"""
-        return self.get_features(**kwargs)
-
     def make_feature(self, feature: FeatureDataType, *args) -> Feature:
         """
         return an Feature instance from feature data
@@ -1379,26 +1353,10 @@ def __str__(self):
                 result = self.moltype.complement(result)
         return result
 
-    def gettype(self):  # pragma: no cover
-        """Return the sequence type."""
-        deprecated("method", "gettype", "get_type", "2023.8", "pep8")
-        return self.get_type()
-
     def get_type(self):
         """Return the sequence type as moltype label."""
         return self.moltype.label
 
-    def resolveambiguities(self):  # pragma: no cover
-        """Returns a list of tuples of strings."""
-        deprecated(
-            "method",
-            "resolveambiguities",
-            "resolved_ambiguities",
-            "2023.8",
-            "pep8",
-        )
-        return self.resolved_ambiguities()
-
     def resolved_ambiguities(self) -> List[Tuple[str]]:
         """Returns a list of tuples of strings."""
         ambigs = self.moltype.ambiguities
diff --git a/src/cogent3/core/tree.py b/src/cogent3/core/tree.py
index 1c022fe5a..84efc41ab 100644
--- a/src/cogent3/core/tree.py
+++ b/src/cogent3/core/tree.py
@@ -24,6 +24,8 @@
        from a node
     -  stem: the edge immediately preceeding a clade
 """
+from __future__ import annotations
+
 import json
 import numbers
 import re
@@ -1617,6 +1619,10 @@ def lin_rajan_moret(self, tree2) -> float:
 
         return lin_rajan_moret(self, tree2)
 
+    def child_parent_map(self) -> dict[str, str]:
+        """return dict of {<child name>: <parent name>, ...}"""
+        return {e.name: e.parent.name for e in self.postorder(include_self=False)}
+
 
 class PhyloNode(TreeNode):
     def __init__(self, *args, **kwargs):
diff --git a/src/cogent3/evolve/fast_distance.py b/src/cogent3/evolve/fast_distance.py
index 59cb12428..e6b684609 100644
--- a/src/cogent3/evolve/fast_distance.py
+++ b/src/cogent3/evolve/fast_distance.py
@@ -560,16 +560,6 @@ def get_pairwise_distances(self, include_duplicates=True):
         return DistanceMatrix(dists)
 
 
-class PercentIdentityPair(ProportionIdenticalPair):
-    @c3warn.deprecated_callable(
-        "2023.8",
-        reason="name is inaccurate, it's a proportion identy measure",
-        new="ProportionIdenticalPair",
-    )
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-
 class _NucleicSeqPair(_PairwiseDistance):
     """base class pairwise distance calculator for nucleic acid seqs"""
 
@@ -672,16 +662,6 @@ def get_distance_calculator(name, *args, **kwargs):
 
     name is converted to lower case"""
     name = name.lower()
-    if name == "percent":
-        name = "pdist"
-        c3warn.deprecated(
-            "value",
-            old="percent",
-            new="pdist",
-            reason="name is inaccurate, it's a proportion identy measure",
-            version="2023.8",
-        )
-
     if "moltype" in kwargs and kwargs.get("moltype") is None:
         kwargs.pop("moltype")
     if name not in _calculators:
diff --git a/src/cogent3/evolve/likelihood_function.py b/src/cogent3/evolve/likelihood_function.py
index 27b2ed0c3..c4e4badb3 100644
--- a/src/cogent3/evolve/likelihood_function.py
+++ b/src/cogent3/evolve/likelihood_function.py
@@ -736,13 +736,16 @@ def get_lengths_as_ens(self, motif_probs=None):
         """
         if motif_probs is None:
             motif_probs = self.get_motif_probs_by_node()
-        node_names = [n for n in self.tree.get_node_names() if n != "root"]
-        lengths = {e: self.get_param_value("length", edge=e) for e in node_names}
+
+        edge_parent = self.tree.child_parent_map()
+        lengths = {e: self.get_param_value("length", edge=e) for e in edge_parent}
         if not isinstance(self.model, substitution_model.Stationary):
             ens = {}
-            for e in node_names:
+            for e in edge_parent:
                 Q = self.get_rate_matrix_for_edge(e)
-                length = expected_number_subs(motif_probs[e], Q, lengths[e])
+                length = expected_number_subs(
+                    motif_probs[edge_parent[e]], Q, lengths[e]
+                )
                 ens[e] = length
 
             lengths = ens
diff --git a/src/cogent3/evolve/likelihood_tree.py b/src/cogent3/evolve/likelihood_tree.py
index a20d97218..af3cb1c00 100644
--- a/src/cogent3/evolve/likelihood_tree.py
+++ b/src/cogent3/evolve/likelihood_tree.py
@@ -67,7 +67,7 @@ def __init__(self, children, edge_name, alignment=None):
 
         # Derive per-column degree of ambiguity from children's
         ambigs = [child.ambig[index] for (index, child) in self._indexed_children]
-        self.ambig = numpy.product(ambigs, axis=0)
+        self.ambig = numpy.prod(ambigs, axis=0)
 
     def get_site_patterns(self, cols):
         # Recursive lookup of Site Patterns aka Alignment Columns
diff --git a/src/cogent3/evolve/motif_prob_model.py b/src/cogent3/evolve/motif_prob_model.py
index 53a7c08f0..85e194ff5 100644
--- a/src/cogent3/evolve/motif_prob_model.py
+++ b/src/cogent3/evolve/motif_prob_model.py
@@ -167,9 +167,9 @@ def calc_monomer_probs(self, word_probs):
         return monomer_probs
 
     def calc_word_probs(self, monomer_probs):
-        result = numpy.product(monomer_probs.take(self.m2w), axis=-1)
+        result = numpy.prod(monomer_probs.take(self.m2w), axis=-1)
         # maybe simpler but slower, works ok:
-        # result = numpy.product(monomer_probs ** (w2m, axis=-1))
+        # result = numpy.prod(monomer_probs ** (w2m, axis=-1))
         result /= result.sum()
         return result
 
@@ -211,7 +211,7 @@ def calc_word_probs(self, monomer_probs):
             type(monomer_probs),
             self.m2w.shape,
         )
-        result = numpy.product(
+        result = numpy.prod(
             [monomer_probs[i].take(self.m2w[:, i]) for i in positions], axis=0
         )
         result /= result.sum()
diff --git a/src/cogent3/evolve/ns_substitution_model.py b/src/cogent3/evolve/ns_substitution_model.py
index c60aabec1..5211f4ad2 100644
--- a/src/cogent3/evolve/ns_substitution_model.py
+++ b/src/cogent3/evolve/ns_substitution_model.py
@@ -98,7 +98,7 @@ def __init__(self, alphabet, **kw):
         for pred in predicate_order:
             mask = predicate_masks[pred]
             indices = numpy.nonzero(mask)
-            assert numpy.alltrue(mask[indices] == 1)
+            assert numpy.all(mask[indices] == 1)
             self.parameter_order.append(pred)
             self.predicate_indices.append(indices)
 
diff --git a/src/cogent3/evolve/parameter_controller.py b/src/cogent3/evolve/parameter_controller.py
index a33273870..d602cf334 100644
--- a/src/cogent3/evolve/parameter_controller.py
+++ b/src/cogent3/evolve/parameter_controller.py
@@ -57,6 +57,7 @@ def __init__(
         loci=1,
         optimise_motif_probs=False,
         motif_probs_from_align=False,
+        default_length=1.0,
         **kw,
     ):
         # cache of arguments used to construct
@@ -76,7 +77,7 @@ def __init__(
         defn = self.make_likelihood_defn(**kw)
         super(_LikelihoodParameterController, self).__init__(defn)
         self.set_default_param_rules()
-        self.set_default_tree_parameter_rules()
+        self.set_default_tree_parameter_rules(default_length=default_length)
         self.mprobs_from_alignment = motif_probs_from_align
         self.optimise_motif_probs = optimise_motif_probs
         self._name = None
@@ -95,7 +96,7 @@ def save(self, filename):
                     if id(d) in temp:
                         d.values = temp[id(d)]
 
-    def set_default_tree_parameter_rules(self):
+    def set_default_tree_parameter_rules(self, default_length=1.0):
         """Lengths are set to the values found in the tree (if any), and
         free to be optimised independently.
         Other parameters are scoped based on the unique values found in the
@@ -118,14 +119,21 @@ def set_default_tree_parameter_rules(self):
                 for u, value in enumerate(uniq):
                     group = [edge for (edge, i) in list(index.items()) if i == u]
                     self.set_param_rule(par_name, edges=group, init=value)
+
+            # we are setting edge lengths, which is only valid if there is a
+            # continuous-time model on the edge AND the edge is not the root
+            if "expm" not in self.defn_for:  # discrete time only
+                return
+
+            exclude_edges = {"root"}
+            if dp := self.defn_for.get("dpsubs", None):
+                exclude_edges |= {n for _, n in dp.assignments.keys()}
+
             for edge in edges:
-                if edge.length is not None:
-                    try:
-                        self.set_param_rule("length", edge=edge.name, init=edge.length)
-                    except KeyError:
-                        # hopefully due to being a discrete model
-                        warnings.warn("Ignoring tree edge lengths", stacklevel=4)
-                        break
+                if edge.name in exclude_edges:
+                    continue
+                length = edge.length or default_length
+                self.set_param_rule("length", edge=edge.name, init=length)
 
     def set_motif_probs_from_data(
         self,
diff --git a/src/cogent3/evolve/substitution_model.py b/src/cogent3/evolve/substitution_model.py
index d45bb818c..a49dfa753 100644
--- a/src/cogent3/evolve/substitution_model.py
+++ b/src/cogent3/evolve/substitution_model.py
@@ -28,8 +28,10 @@
 >>> model.setparameterrules({'alpha': model.istransition})
 >>> parameter_controller = model.make_likelihood_function(tree)
 """
+from __future__ import annotations
 
 import json
+import typing
 import warnings
 
 from collections.abc import Callable
@@ -41,6 +43,7 @@
 
 from cogent3._version import __version__
 from cogent3.core import genetic_code, moltype
+from cogent3.core.tree import PhyloNode
 from cogent3.evolve import motif_prob_model, parameter_controller, predicate
 from cogent3.evolve.likelihood_tree import make_likelihood_tree_leaf
 from cogent3.evolve.substitution_calculation import (
@@ -107,7 +110,7 @@ def _maxWidthIfTruncated(pars, delim, each):
 
 
 def _isSymmetrical(matrix):
-    return numpy.alltrue(numpy.alltrue(matrix == numpy.transpose(matrix)))
+    return numpy.all(numpy.all(matrix == numpy.transpose(matrix)))
 
 
 class _SubstitutionModel(object):
@@ -303,15 +306,46 @@ def set_param_controller_motif_probs(self, pc, mprobs, **kw):
 
     def make_likelihood_function(
         self,
-        tree,
-        motif_probs_from_align=None,
-        optimise_motif_probs=None,
-        aligned=True,
-        expm=None,
-        digits=None,
-        space=None,
+        tree: PhyloNode,
+        motif_probs_from_align: typing.Optional[bool] = None,
+        optimise_motif_probs: typing.Optional[bool] = None,
+        aligned: bool = True,
+        expm: typing.Optional[str] = None,
+        digits: typing.Optional[int] = None,
+        space: typing.Optional[str] = None,
+        default_length: float = 1.0,
         **kw,
     ):
+        """
+        construct a likelihood function with this substitution model
+
+        Parameters
+        ----------
+        tree
+            a cogent3 tree object, entries in <tree node>.params dict
+            with keys that match model parameters are used as initial
+            values, this includes length.
+        motif_probs_from_align
+            use the motif probabilities from the alignment
+        optimise_motif_probs
+            treat motif probabilities as optimisable parameters, otherwise
+            they are constant
+        aligned
+            works on alignments. When False, used for sequence alignment
+        expm
+            string name of the exponentiation algorithm, e.g. "pade", "either",
+            "eigen", "checked". Defaults to "either" (which uses eigen,
+            switching to pade if numerical errors occur).
+        digits
+            number of decimal places shown in result tables
+        space
+            spaces between columns in result tables
+        default_length
+            default branch length when provided tree has no (or zero) lengths,
+            noting that a positive nonzero is required.
+        kw
+            other keyword args passed through to the likelihood function class
+        """
         if motif_probs_from_align is None:
             motif_probs_from_align = self.motif_probs_from_align
 
@@ -328,7 +362,7 @@ def make_likelihood_function(
             assert alphabet.get_gap_motif() not in alphabet
             klass = parameter_controller.SequenceLikelihoodFunction
 
-        result = klass(self, tree, **kw)
+        result = klass(self, tree, default_length=default_length, **kw)
 
         if self.motif_probs is not None:
             result.set_motif_probs(
@@ -679,7 +713,7 @@ def __init__(self, alphabet, rate_matrix, **kw):
         alphabet = self.get_alphabet()  # as may be altered by recode_gaps etc.
         N = len(alphabet)
         assert rate_matrix.shape == (N, N)
-        assert numpy.alltrue(numpy.diagonal(rate_matrix) == 0)
+        assert numpy.all(numpy.diagonal(rate_matrix) == 0)
         self._instantaneous_mask_f = rate_matrix * 1.0
         self._instantaneous_mask = self._instantaneous_mask_f != 0.0
         self.symmetric = _isSymmetrical(self._instantaneous_mask_f)
@@ -718,12 +752,12 @@ def __init__(self, alphabet, predicates=None, scales=None, **kw):
         # to be equivalent to 1 or more others, or the distance params.
         # Give a clearer error in simple cases like always false or true.
         for name, matrix in list(predicate_masks.items()):
-            if numpy.alltrue((matrix == 0).flat):
+            if numpy.all((matrix == 0).flat):
                 raise ValueError(f"Predicate {name} is always false.")
         predicates_plus_scale = predicate_masks.copy()
         predicates_plus_scale[None] = self._instantaneous_mask
         for name, matrix in list(predicate_masks.items()):
-            if numpy.alltrue((matrix == self._instantaneous_mask).flat):
+            if numpy.all((matrix == self._instantaneous_mask).flat):
                 raise ValueError(f"Predicate {name} is always true.")
         if redundancy_in_predicate_masks(predicate_masks):
             raise ValueError("Redundancy in predicates.")
@@ -742,7 +776,7 @@ def __init__(self, alphabet, predicates=None, scales=None, **kw):
             if not _isSymmetrical(mask):
                 self.symmetric = False
             indices = numpy.nonzero(mask)
-            assert numpy.alltrue(mask[indices] == 1)
+            assert numpy.all(mask[indices] == 1)
             self.parameter_order.append(pred)
             self.predicate_indices.append(indices)
         (self.scale_masks, scale_order) = self._adapt_predicates(scales or [])
diff --git a/src/cogent3/maths/optimisers.py b/src/cogent3/maths/optimisers.py
index f013af166..8cef102fa 100644
--- a/src/cogent3/maths/optimisers.py
+++ b/src/cogent3/maths/optimisers.py
@@ -1,8 +1,12 @@
 """Local or Global-then-local optimisation with progress display
 """
+from __future__ import annotations
 
 import warnings
 
+from functools import singledispatch
+from typing import Tuple
+
 import numpy
 
 from cogent3.util import progress_display as UI
@@ -15,12 +19,33 @@
 LocalOptimiser = Powell
 
 
+@singledispatch
+def _standardise_data(val) -> Tuple[float]:
+    return (str(val),)
+
+
+@_standardise_data.register
+def _(val: numpy.ndarray) -> Tuple[float]:
+    val = val.tolist() if val.ndim else [val.tolist()]
+    return tuple(val)
+
+
+@_standardise_data.register
+def _(val: float) -> Tuple[float]:
+    return (val,)
+
+
 def unsteadyProgressIndicator(display_progress, label="", start=0.0, end=1.0):
     template = "f = % #10.6g  ±  % 9.3e   evals = %6i "
     label = label.rjust(5)
     goal = [1.0e-20]
 
     def _display_progress(remaining, *args):
+        # the first element is a numpy array, which can be
+        # a scalar array, i.e. ndim==0. We use the tolist() method to
+        # cast to a standard python type.
+        v1 = _standardise_data(args[0])
+        args = v1 + args[1:]
         if remaining > goal[0]:
             goal[0] = remaining
         progress = (goal[0] - remaining) / goal[0] * (end - start) + start
@@ -72,7 +97,7 @@ def bounded_function(f, lower_bounds, upper_bounds, report_error=False):
     This is enough to get some unbounded optimisers working on bounded problems"""
 
     def _wrapper(x, **kw):
-        if numpy.alltrue(numpy.logical_and(lower_bounds <= x, x <= upper_bounds)):
+        if numpy.all(numpy.logical_and(lower_bounds <= x, x <= upper_bounds)):
             return f(x, **kw)
         else:
             pos = numpy.logical_or(x <= lower_bounds, x >= upper_bounds)
diff --git a/src/cogent3/parse/fasta.py b/src/cogent3/parse/fasta.py
index 9b9706c1d..255228804 100644
--- a/src/cogent3/parse/fasta.py
+++ b/src/cogent3/parse/fasta.py
@@ -14,9 +14,9 @@
 from cogent3.parse.record import RecordError
 from cogent3.parse.record_finder import LabeledRecordFinder
 from cogent3.util.io import open_
-from cogent3.util.warning import deprecated_args
 
 
+_white_space = re.compile(r"\s+")
 strip = str.strip
 
 Sequence = BYTES.make_seq
@@ -75,7 +75,7 @@ def _faster_parser(
 
         if line[0] in label_char:
             if seq:
-                yield label_to_name(label or ""), "".join(seq)
+                yield label_to_name(label or ""), _white_space.sub("", "".join(seq))
 
             label = line[1:].strip()
             seq = []
@@ -83,7 +83,7 @@ def _faster_parser(
             seq.append(line.strip())
 
     if seq:
-        yield label_to_name(label or ""), "".join(seq)
+        yield label_to_name(label or ""), _white_space.sub("", "".join(seq))
 
 
 def _strict_parser(
@@ -100,7 +100,7 @@ def _strict_parser(
             if label is not None:
                 if not seq:
                     raise RecordError(f"{label} has no data")
-                yield label_to_name(label), "".join(seq)
+                yield label_to_name(label), _white_space.sub("", "".join(seq))
             elif seq:
                 raise RecordError("missing a label")
 
@@ -114,10 +114,9 @@ def _strict_parser(
     if label is None:
         raise RecordError("missing a label")
 
-    yield label_to_name(label), "".join(seq)
+    yield label_to_name(label), _white_space.sub("", "".join(seq))
 
 
-@deprecated_args("2023.8", "faster implementation", discontinued=["finder"])
 def MinimalFastaParser(
     path: PathOrIterableType,
     strict: bool = True,
@@ -127,7 +126,6 @@ def MinimalFastaParser(
     """
     Yields successive sequences from infile as (label, seq) tuples.
 
-    If strict is True (default), .
     Parameters
     ----------
     path
diff --git a/src/cogent3/parse/genbank.py b/src/cogent3/parse/genbank.py
index 4aee0da87..3757fe1d6 100644
--- a/src/cogent3/parse/genbank.py
+++ b/src/cogent3/parse/genbank.py
@@ -329,7 +329,6 @@ def __init__(
         accession=None,
         db=None,
         strand=1,
-        **kwargs,
     ):
         """Returns new LocalLocation object."""
 
@@ -345,28 +344,6 @@ def __init__(
         self.db = db
         self.strand = strand
 
-        dep_arg_map = {
-            "Ambiguity": "ambiguity",
-            "IsBetween": "is_between",
-            "IsBounds": "is_bounds",
-            "Accession": "accession",
-            "Db": "db",
-            "Strand": "strand",
-        }  # map between deprecated argument name and PEP8 compliant argument name
-        for dep_arg, arg in dep_arg_map.items():
-            if dep_arg in kwargs:
-                setattr(self, arg, kwargs.pop(dep_arg))
-
-                from cogent3.util.warning import deprecated
-
-                deprecated(
-                    "argument",
-                    dep_arg,
-                    arg,
-                    "2023.8",
-                    "changed to be PEP8 compliant",
-                )
-
     def __str__(self):
         """Returns self in string format.
 
@@ -423,58 +400,10 @@ def stop(self):
         except TypeError:
             return self._data[-1].stop
 
-    def first(self):  # pragma: no cover
-        from cogent3.util.warning import deprecated
-
-        deprecated(
-            "method",
-            "Location.first",
-            "Location.start",
-            "2023.8",
-            "Warning: '.start' is changed to reflect 0-based coordinated, previous implementation reflected 1-based coordinates",
-        )
-
-        return self.start
-
-    def last(self):  # pragma: no cover
-        from cogent3.util.warning import deprecated
-
-        deprecated(
-            "method",
-            "Location.last",
-            "Location.stop",
-            "2023.8",
-            "Warning: '.stop' is changed to reflect 0-based coordinated, previous implementation reflected 1-based coordinates",
-        )
-        return self.stop
-
 
 class LocationList(list):
     """List of Location objects."""
 
-    def first(self):  # pragma: no cover
-        """Returns first base of self."""
-        from cogent3.util.warning import discontinued
-
-        discontinued(
-            "method",
-            "LocationList.first",
-            "2023.8",
-        )
-
-        return min(self, key=lambda x: x.start).start + 1
-
-    def last(self):  # pragma: no cover
-        """Returns last base of self."""
-        from cogent3.util.warning import discontinued
-
-        discontinued(
-            "method",
-            "LocationList.last",
-            "2023.8",
-        )
-        return max(self, key=lambda x: x.stop).stop + 1
-
     @property
     def strand(self):
         """Returns strand of components: 1=forward, -1=reverse, 0=both"""
@@ -670,15 +599,11 @@ def extract_nt_prot_seqs(rec, wanted=wanted_types):
         print("s :", seq)
 
 
-@c3warn.deprecated_args(
-    "2023.8", reason="no longer relevant", discontinued="add_annotation"
-)
 def RichGenbankParser(
     handle,
     info_excludes=None,
     moltype=None,
     skip_contigs=False,
-    add_annotation=None,
     db: Optional[GenbankAnnotationDb] = None,
     just_seq: bool = False,
 ):
@@ -703,13 +628,11 @@ def RichGenbankParser(
     info_excludes = info_excludes or ["sequence", "features"]
     moltype = get_moltype(moltype) if moltype else None
     for rec in MinimalGenbankParser(handle):
-        info = {}
-        # populate the info object, excluding the sequence
-        for label, value in list(rec.items()):
-            if label in info_excludes:
-                continue
-            info[label] = value
-
+        info = {
+            label: value
+            for label, value in list(rec.items())
+            if label not in info_excludes
+        }
         if moltype is None:
             rec_moltype = rec["mol_type"].lower()
             rec_moltype = (
@@ -725,13 +648,14 @@ def RichGenbankParser(
                 rec["sequence"].upper(), info=info, name=rec["locus"]
             )
         except KeyError:
-            if not skip_contigs:
-                if "contig" in rec:
+            if "contig" in rec:
+                if not skip_contigs:
                     yield rec["locus"], rec["contig"]
-                elif "WGS" in rec:
+            elif "WGS" in rec:
+                if not skip_contigs:
                     yield rec["locus"], rec["WGS"]
-                else:
-                    yield rec["locus"], None
+            elif not skip_contigs:
+                yield rec["locus"], None
             continue
 
         if not just_seq:
diff --git a/src/cogent3/parse/newick.py b/src/cogent3/parse/newick.py
index 4c30018b5..4ae28636d 100644
--- a/src/cogent3/parse/newick.py
+++ b/src/cogent3/parse/newick.py
@@ -60,27 +60,35 @@ def error(self, detail=""):
         return TreeParseError(msg + ". " + detail)
 
     def tokens(self):
-        closing_quote_token = None
         column = 0
         line = 0
         text = None
         closing_quote_token = None
         in_comment = False
-        for token in re.split("""([\\t ]+|\\n|''|""|[]['"(),:;])""", self.text) + [EOT]:
+        comment = []
+        splits = re.split("""([\\t ]+|\\n|''|""|[]['"(),:;\[\]])""", self.text)
+        for token in splits + [EOT]:
             label_complete = False
             token_consumed = True
             self.token = token
             column += len(token or "")
             self.posn = (line, column)
 
-            if token == "":
-                pass
-            elif in_comment:
+            if token == "":  # we have to allow None to pass this
+                continue
+
+            if in_comment:
                 if token is EOT:
                     raise self.error("Ended with unclosed comment")
                 if token == "]":
                     in_comment = False
-            elif closing_quote_token:
+                    text = "".join(comment)
+                    comment = []
+                else:
+                    comment.append(token)
+                    continue
+
+            if closing_quote_token:
                 if token is EOT:
                     raise self.error("Text ended inside quoted label")
                 if token == "\n":
@@ -92,7 +100,7 @@ def tokens(self):
                     if token == closing_quote_token * 2:
                         token = token[0]
                     text += token
-            elif token is EOT or token in "\n[():,;":
+            elif token is EOT or token in "\n[]():,;":
                 if text:
                     text = text.strip()
                     if self.underscore_unmunge and "_" in text:
@@ -103,7 +111,8 @@ def tokens(self):
                     column = 1
                 elif token == "[":
                     in_comment = True
-                else:
+                    token_consumed = False
+                elif token != "]":
                     token_consumed = False
             elif text is not None:
                 text += token
@@ -151,7 +160,7 @@ def parse_string(text, constructor, **kw):
             try:
                 attributes[attr_name] = attr_cast(token)
             except ValueError:
-                raise tokeniser.error(f"Can't convert {attr_name} '{token}'")
+                raise tokeniser.error(f"Can't convert {attr_name!r} '{token}'")
             expected_attribute = None
         elif token == "(":
             if children is not None:
@@ -164,29 +173,27 @@ def parse_string(text, constructor, **kw):
             if "length" in attributes:
                 raise tokeniser.error("Already have a length.")
             expected_attribute = ("length", float)
+        elif token == "[":
+            if "other" in attributes:
+                raise tokeniser.error("Already have a 'other'.")
+            expected_attribute = ("other", lambda x: x.split(","))
         elif token in [")", ";", ",", EOT]:
             nodes.append(constructor(children, name, attributes))
             children = name = expected_attribute = None
             attributes = {}
             if token in sentinals:
-                if stack:
-                    children = nodes
-                    (nodes, sentinals, attributes) = stack.pop()
-                else:
+                if not stack:
                     break
-            elif token == "," and ")" in sentinals:
-                pass
-            else:
+                children = nodes
+                (nodes, sentinals, attributes) = stack.pop()
+            elif token != "," or ")" not in sentinals:
                 raise tokeniser.error(
-                    "Was expecting to end with %s"
-                    % " or ".join([repr(s) for s in sentinals])
+                    f'Was expecting to end with {" or ".join([repr(s) for s in sentinals])}'
                 )
-        else:
-            if name is not None:
-                raise tokeniser.error(f"Already have a name '{name}' for this node.")
-            elif attributes:
-                raise tokeniser.error("name should come before length.")
+        elif name is None:
             name = token
+        else:
+            raise tokeniser.error(f"Already have a name {name!r} for this node.")
     assert not stack, stack
     assert len(nodes) == 1, len(nodes)
     return nodes[0]
diff --git a/src/cogent3/parse/paml_matrix.py b/src/cogent3/parse/paml_matrix.py
index 53c870f95..5e96db14e 100644
--- a/src/cogent3/parse/paml_matrix.py
+++ b/src/cogent3/parse/paml_matrix.py
@@ -31,7 +31,7 @@ def PamlMatrixParser(f):
     matrix = numpy.take(matrix, reorder, 0)
     matrix = numpy.take(matrix, reorder, 1)
 
-    assert numpy.alltrue(matrix == numpy.transpose(matrix))
+    assert numpy.all(matrix == numpy.transpose(matrix))
 
     freqs = dict(list(zip(three_letter_order, freqs)))
 
diff --git a/src/cogent3/parse/sequence.py b/src/cogent3/parse/sequence.py
index 96d352f7a..f2a0346cd 100644
--- a/src/cogent3/parse/sequence.py
+++ b/src/cogent3/parse/sequence.py
@@ -66,26 +66,6 @@ def FromFileParser(f, format, dialign_recode=False, **kw):
         yield name, seq
 
 
-def format_from_filename(filename, format=None):  # pragma: no cover
-    from cogent3.util.warning import deprecated
-
-    deprecated(
-        "function",
-        "cogent3.parse.sequence.format_from_filename",
-        "cogent3.util.misc.get_format_suffixes",
-        "2023.8",
-    )
-    """Detects format based on filename."""
-    if format:
-        return format
-    else:
-        r = _compression.search(filename)
-        if r:
-            filename = filename[: r.start()]
-
-        return filename[filename.rfind(".") + 1 :]
-
-
 PARSERS = {
     "phylip": phylip.MinimalPhylipParser,
     "paml": paml.PamlParser,
diff --git a/src/cogent3/parse/table.py b/src/cogent3/parse/table.py
index 13eb5a8c5..0ad861544 100644
--- a/src/cogent3/parse/table.py
+++ b/src/cogent3/parse/table.py
@@ -22,19 +22,19 @@ def __init__(
         Parameters
         ----------
         row_condition : callable
-            callback that takes an entire line (except header) and returns True/False.
-            A line is kept if condition(line) is True.
+            callback that takes a line with fields defined by columns and
+            returns True/False. A line is kept if condition(line) is True.
+            (The header line is excluded.)
         negate : bool
             A line is kept if condition(line) is False.
         with_header : bool
             when True, first line is taken to be the header. Not
             passed to converter.
         columns
-            series of indices, or column names, to return. If column names provided, with_header must be true.
+            series of indices, or column names, to return. If column names
+            provided, with_header must be true.
         sep : str
             the delimiter separating fields.
-        strip_wspace : bool
-            removes redundant white
         limit : int
             exits after this many lines
 
diff --git a/src/cogent3/recalculation/definition.py b/src/cogent3/recalculation/definition.py
index 7002bb73a..346e08b3c 100644
--- a/src/cogent3/recalculation/definition.py
+++ b/src/cogent3/recalculation/definition.py
@@ -599,7 +599,7 @@ def make_cells(self, input_soup, variable=None):
 # Some simple CalcDefns
 
 # SumDefn = CalcDefn(lambda *args:sum(args), 'sum')
-# ProductDefn = CalcDefn(lambda *args:numpy.product(args), 'product')
+# ProductDefn = CalcDefn(lambda *args:numpy.prod(args), 'product')
 # CallDefn = CalcDefn(lambda func,*args:func(*args), 'call')
 
 
@@ -636,7 +636,7 @@ class ProductDefn(CalculationDefn):
     name = "product"
 
     def calc(self, *args):
-        return numpy.product(args)
+        return numpy.prod(args)
 
 
 class CallDefn(CalculationDefn):


Test patch:
diff --git a/.github/workflows/testing_develop.yml b/.github/workflows/testing_develop.yml
index b66a087b8..f08b4ff47 100644
--- a/.github/workflows/testing_develop.yml
+++ b/.github/workflows/testing_develop.yml
@@ -2,9 +2,11 @@ name: CI
 
 on:
   push:
-    branches: [ "develop", "issue_1328" ]
+    branches-ignore:
+      - master
   pull_request:
-    branches: [ "develop", "issue_1328" ]
+    branches-ignore:
+      - master
 
 jobs:
   tests:
@@ -22,7 +24,7 @@ jobs:
           fetch-depth: 0
 
       # Setup env
-      - uses: "actions/setup-python@v2"
+      - uses: "actions/setup-python@v3"
         with:
             python-version: "${{ matrix.python-version }}"
 
diff --git a/tests/test_app/test_align.py b/tests/test_app/test_align.py
index d30fae92a..9e53d9b4e 100644
--- a/tests/test_app/test_align.py
+++ b/tests/test_app/test_align.py
@@ -689,6 +689,30 @@ def test_smith_waterman_generic_moltype(moltype):
     assert aligner._score_matrix == make_generic_scoring_dict(10, get_moltype(moltype))
 
 
+def test_smith_waterman_no_moltype(seqs):
+    """If no moltype is provided and the SequenceCollection has no specified moltype, the
+    default moltype ('dna') should be used.
+    """
+    aligner = smith_waterman()
+    coll = make_unaligned_seqs(data=[seqs.get_seq("Human"), seqs.get_seq("Bandicoot")])
+    aln = aligner(coll)
+    assert aln.moltype.label == "dna"
+
+
+@pytest.mark.parametrize("moltype_1", ("text", "dna", "rna", "protein", "bytes"))
+@pytest.mark.parametrize("moltype_2", ("text", "dna", "rna", "protein", "bytes"))
+def test_smith_waterman_wrong_moltype(moltype_1, moltype_2):
+    """If the moltypes differ between SW app and SequenceCollection,
+    the SW moltype should be used
+    """
+    aligner = smith_waterman(moltype=moltype_1)
+    coll = make_unaligned_seqs(
+        data={"Human": "AUUCGAUGG", "Bandicoot": "AUUGCCCGAUGG"}, moltype=moltype_2
+    )
+    aln = aligner(coll)
+    assert aln.moltype.label == moltype_1
+
+
 def test_smith_waterman_raises(seqs):
     """SW should fail when given a SequenceCollection that deos not contain 2 seqs"""
     aligner = smith_waterman()
diff --git a/tests/test_app/test_evo.py b/tests/test_app/test_evo.py
index a26d4dec4..42a059989 100644
--- a/tests/test_app/test_evo.py
+++ b/tests/test_app/test_evo.py
@@ -5,6 +5,8 @@
 from unittest import TestCase
 from unittest.mock import MagicMock
 
+import pytest
+
 from numpy.testing import assert_allclose, assert_raises
 
 from cogent3 import (
@@ -32,22 +34,6 @@
 class TestModel(TestCase):
     basedir = "data"
 
-    def test_model_str(self):
-        """correct str representation"""
-        model = evo_app.model("HKY85", time_het="max")
-        got = " ".join(str(model).splitlines())
-        expect = (
-            "model(sm='HKY85', tree=None, unique_trees=False, "
-            "name=None, optimise_motif_probs=False, sm_args=None, lf_args=None, "
-            "time_het='max', param_rules=None, "
-            "opt_args=None, lower=1e-06, upper=50, split_codons=False, "
-            "show_progress=False, verbose=False)"
-        )
-        self.assertEqual(
-            got,
-            expect,
-        )
-
     def test_model_opt_mprob_arg(self):
         """argument controls optimisability of motif prob settings"""
         for mn in ("HKY85", "GN", "CNFGTR"):
@@ -281,25 +267,6 @@ def test_model_hypothesis_result_repr(self):
         pval = str(result).splitlines()[4].split()[-1]
         self.assertTrue(re.search(r"[0-9\.]+e-\d+", pval) is not None)
 
-    def test_hypothesis_str(self):
-        """correct str representation"""
-        model1 = evo_app.model("HKY85")
-        model2 = evo_app.model("HKY85", name="hky85-max-het", time_het="max")
-        hyp = evo_app.hypothesis(model1, model2)
-        got = " ".join(str(hyp).splitlines())
-        expect = (
-            "hypothesis(null=model(sm='HKY85', tree=None, unique_trees=False, "
-            "name=None, optimise_motif_probs=False, sm_args=None, lf_args=None, "
-            "time_het=None, param_rules=None, opt_args=None, lower=1e-06, upper=50, "
-            "split_codons=False, show_progress=False, verbose=False), "
-            "alternates=(model(sm='HKY85', tree=None, unique_trees=False, "
-            "name='hky85-max-het', optimise_motif_probs=False, sm_args=None, lf_args=None, "
-            "time_het='max', param_rules=None, opt_args=None, lower=1e-06, upper=50,"
-            " split_codons=False, show_progress=False, verbose=False),),"
-            " sequential=True, init_alt=None)"
-        )
-        self.assertEqual(got, expect)
-
     def test_split_pos_model(self):
         """model with split codons, access .lf using codon position int"""
         _data = {
@@ -908,3 +875,46 @@ def test_get_app_tree_is_url():
     tree_url = "https://raw.githubusercontent.com/cogent3/cogent3/develop/tests/data/brca1_5.tree"
     mod = get_app("model", "F81", tree=tree_url)
     assert isinstance(mod, evo_app.model)
+
+
+def test_model_str():
+    """correct str representation"""
+    model = evo_app.model("HKY85", time_het="max")
+    got = " ".join(str(model).splitlines())
+    expect_start = "model(sm='HKY85', tree=None, unique_trees=False, "
+    assert got.startswith(expect_start)
+
+
+def test_hypothesis_str():
+    """correct str representation"""
+    model1 = evo_app.model("HKY85")
+    model2 = evo_app.model("HKY85", name="hky85-max-het", time_het="max")
+    hyp = evo_app.hypothesis(model1, model2)
+    got = " ".join(str(hyp).splitlines())
+    expect_start = "hypothesis(null=model(sm='HKY85', tree=None, unique_trees=False, "
+    assert got.startswith(expect_start)
+
+
+def test_model_tree_func(DATA_DIR):
+    dist_cal = get_app("fast_slow_dist", fast_calc="paralinear", moltype="dna")
+    est_tree = get_app("quick_tree", drop_invalid=True)
+    app = dist_cal + est_tree
+    model = get_app(
+        "model",
+        "HKY85",
+        tree_func=app,
+        opt_args=dict(max_evaluations=25, limit_action="ignore"),
+    )
+
+    aln = load_aligned_seqs(DATA_DIR / "brca1_5.paml", moltype="dna")
+    result = model(aln)
+    assert set(result.tree.get_tip_names()) == set(aln.names)
+
+
+def test_model_invalid_tree_func():
+    with pytest.raises(AssertionError):
+        get_app(
+            "model",
+            "HKY85",
+            tree_func="123",
+        )
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index 20ae08f6c..5330b2746 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -3,6 +3,7 @@
 import pytest
 
 from cogent3 import ASCII, DNA, get_moltype, make_aligned_seqs
+from cogent3.core.alignment import Alignment, SequenceCollection
 from cogent3.core.annotation import Feature
 from cogent3.core.annotation_db import BasicAnnotationDb, GffAnnotationDb
 # Complete version of manipulating sequence annotations
@@ -705,3 +706,22 @@ def test_hash_feature(ann_seq):
     (f2,) = list(ann_seq.get_features(biotype="gene"))
     got = {f1, f2}
     assert len(got) == 1
+
+
+def test_seq_degap_preserves_annotations():
+    s1 = DNA.make_seq("AAGAAGAAGACCCCCAAAAAAAAAATTTTTTTTTTAAAAAGGGAACCCT", name="seq1")
+    s1.add_feature(biotype="exon", name="A", spans=[(10, 15)])
+    dg = s1.degap()
+    assert len(s1.annotation_db) == len(dg.annotation_db)
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
+def test_align_degap_preserves_annotations(cls):
+    """get translation works on incomplete codons"""
+    coll = cls(data={"seq1": "GATN--", "seq2": "?GATCT"}, moltype=DNA)
+    db = BasicAnnotationDb()
+    db.add_feature(biotype="exon", name="exon1", spans=[(1, 2)], seqid="seq1")
+    coll.annotation_db = db
+    got = coll.degap()
+    assert got.annotation_db is coll.annotation_db
+    assert len(got.annotation_db) == 1
diff --git a/tests/test_core/test_seq_aln_integration.py b/tests/test_core/test_seq_aln_integration.py
index 6b1fa2f3c..f3ab2f685 100644
--- a/tests/test_core/test_seq_aln_integration.py
+++ b/tests/test_core/test_seq_aln_integration.py
@@ -1,9 +1,7 @@
-#!/usr/bin/env python
+from unittest import TestCase
 
-
-from unittest import TestCase, main
-
-from numpy import alltrue, array, transpose
+from numpy import all as alltrue
+from numpy import array, transpose
 from numpy.testing import assert_equal
 
 from cogent3.core.alignment import Alignment, ArrayAlignment
@@ -256,7 +254,3 @@ def test_the_rest_of_ModelSequence(self):
         You might want to add some tests there..."""
         # note: mostly these are tested in derived classes, for convenience.
         pass
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 33f622053..f49a68e7f 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -2,7 +2,6 @@
 """
 import json
 import os
-import pathlib
 import re
 
 from pickle import dumps
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index 6d8d8647c..baa764b58 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -3,10 +3,11 @@
 import json
 import os
 import pathlib
+import random
 
 from copy import copy, deepcopy
 from tempfile import TemporaryDirectory
-from unittest import TestCase, main
+from unittest import TestCase
 
 import pytest
 
@@ -1727,14 +1728,6 @@ def test_get_edge_names(self):
         ]:
             self.assertEqual(tree.get_edge_names(a, b, True, False, outgroup), result)
 
-    def test_parser(self):
-        """nasty newick"""
-        nasty = "( (A :1.0,'B (b)': 2) [com\nment]pair:3,'longer name''s':4)dash_ed;"
-        nice = "((A:1.0,'B (b)':2.0)pair:3.0,'longer name''s':4.0)dash_ed;"
-        tree = self._maketree(nasty)
-        tidied = tree.get_newick(with_distances=1)
-        self.assertEqual(tidied, nice)
-
     # Likelihood Function Interface
 
     def test_get_edge_names(self):
@@ -2210,3 +2203,26 @@ def test_lrm_method():
     b = make_tree(treestring="(1,((((2,3),4),5),((6,7),((8,9),(10,11)))),12);")
     distance = a.lin_rajan_moret(b)
     assert distance == 8
+
+
+@pytest.mark.parametrize(
+    "nwk", ("(((g,b)gb,(c,d)cd),(e,f),a)", "(a,b,c)", ("(a,(b,(c,d)cd))"))
+)
+def test_child_parent_map(nwk):
+    tree = make_tree(nwk)
+    child_2_parent = tree.child_parent_map()
+    all_edges = tree.get_edge_vector(include_root=False)
+    assert len(child_2_parent) == len(all_edges)
+    assert child_2_parent["a"] == "root"
+    edge = random.choice(all_edges)
+    assert child_2_parent[edge.name] == edge.parent.name
+
+
+def test_parser():
+    """nasty newick"""
+    nasty = "( (A :1.0,'B (b)': 2) [com\nment]pair:3,'longer name''s':4)dash_ed;"
+    nice = "((A:1.0,'B (b)':2.0)pair:3.0,'longer name''s':4.0)dash_ed;"
+    tree = make_tree(treestring=nasty, underscore_unmunge=True)
+    tidied = tree.get_newick(with_distances=1)
+    assert tidied == nice
+    assert tree.get_node_matching_name("pair").params["other"] == ["com\nment"]
diff --git a/tests/test_evolve/test_likelihood_function.py b/tests/test_evolve/test_likelihood_function.py
index d8b35aa81..a232d245d 100644
--- a/tests/test_evolve/test_likelihood_function.py
+++ b/tests/test_evolve/test_likelihood_function.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 """
 Some tests for the likelihood function class.
 
@@ -14,9 +13,10 @@
 import os
 import warnings
 
-from unittest import TestCase, main
+from unittest import TestCase
 
 import numpy
+import pytest
 
 from numpy import dot, ones
 from numpy.testing import assert_allclose
@@ -41,6 +41,7 @@
     ssGN,
 )
 from cogent3.evolve.ns_substitution_model import GeneralStationary
+from cogent3.maths.matrix_exponential_integration import expected_number_subs
 from cogent3.maths.matrix_exponentiation import PadeExponentiator as expm
 from cogent3.maths.stats.information_criteria import aic, bic
 
@@ -1343,10 +1344,10 @@ def test_initialise_from_nested_codon_scoped(self):
 
     def test_get_lengths_as_ens_equal(self):
         """lengths equals ENS for a time-reversible model"""
-        moprobs = numpy.array([0.1, 0.2, 0.3, 0.4])
+        mprobs = numpy.array([0.1, 0.2, 0.3, 0.4])
         length = 0.1
         lf = HKY85().make_likelihood_function(make_tree(tip_names=["a", "b", "c"]))
-        lf.set_motif_probs(moprobs)
+        lf.set_motif_probs(mprobs)
         lf.set_param_rule("kappa", init=1)
         lf.set_param_rule("length", edge="a", init=length)
         len_dict = lf.get_lengths_as_ens()
@@ -1354,10 +1355,10 @@ def test_get_lengths_as_ens_equal(self):
 
     def test_get_lengths_as_ens_not_equal(self):
         """lengths do not equal ENS for a non-reversible model"""
-        moprobs = numpy.array([0.1, 0.2, 0.3, 0.4])
+        mprobs = numpy.array([0.1, 0.2, 0.3, 0.4])
         length = 0.1
         lf = GN().make_likelihood_function(make_tree(tip_names=["a", "b", "c"]))
-        lf.set_motif_probs(moprobs)
+        lf.set_motif_probs(mprobs)
         lf.set_param_rule("length", init=length)
         # setting arbitrary values for GN rate terms
         init = 0.1
@@ -2260,5 +2261,32 @@ def test_simulate_alignment3():
     assert re.sub("[ATCG]", "x", simulated.to_dict()["a"]) == "x??xxxxxx?"
 
 
-if __name__ == "__main__":
-    main()
+def test_get_lengths_as_ens_matches_manual_calc():
+    """lengths as ENS for a non-reversible model matches a manual calculation"""
+    mprobs = numpy.array([0.1, 0.2, 0.3, 0.4])
+    # unequal lengths
+    lf = GN().make_likelihood_function(make_tree("(a:0.1,b:0.1,c:0.1)"))
+    lf.set_motif_probs(mprobs)
+    # setting arbitrary values for GN rate terms
+    init = 0.1
+    for par_name in lf.model.get_param_list():
+        lf.set_param_rule(par_name, init=init)
+        init += 0.1
+
+    len_dict = lf.get_lengths_as_ens()
+    expect = expected_number_subs(
+        mprobs, lf.get_rate_matrix_for_edge("a", calibrated=False), 1.0
+    )
+    assert_allclose(len_dict["a"], expect)
+
+
+@pytest.mark.parametrize("expm", (None, "pade"))
+def test_expm_zero_lengths(expm, DATA_DIR):
+    aln = load_aligned_seqs((DATA_DIR / "brca1_5.paml"), moltype="dna")
+    names = ["Mouse", "Human", "HowlerMon"]
+    aln = aln.take_seqs(names)
+    tree = make_tree((f"({names[0]}:0.0,{names[1]}:0.0,{names[2]}:0.0)"))
+    sm = GTR()
+    lf = sm.make_likelihood_function(tree=tree, expm=expm)
+    lf.set_alignment(aln)
+    lf.optimise(max_evaluations=5, show_progress=False)
diff --git a/tests/test_maths/test_optimisers.py b/tests/test_maths/test_optimisers.py
index 42d1bd2d8..18ace79e7 100644
--- a/tests/test_maths/test_optimisers.py
+++ b/tests/test_maths/test_optimisers.py
@@ -3,7 +3,14 @@
 
 from unittest import TestCase
 
-from cogent3.maths.optimisers import MaximumEvaluationsReached, maximise
+import numpy
+import pytest
+
+from cogent3.maths.optimisers import (
+    MaximumEvaluationsReached,
+    _standardise_data,
+    maximise,
+)
 
 
 def quartic(x):
@@ -106,3 +113,15 @@ def test_checkpointing(self):
         )
         if os.path.exists(filename):
             os.remove(filename)
+
+
+@pytest.mark.parametrize("val", (numpy.array(3.7), numpy.array([3.7]), 3.7))
+def test_standardise_data(val):
+    got = _standardise_data(val)
+    assert got == (3.7,)
+
+
+@pytest.mark.parametrize("val", (37, "37"))
+def test_standardise_data_str(val):
+    got = _standardise_data(val)
+    assert got == ("37",)
diff --git a/tests/test_parse/test_fasta.py b/tests/test_parse/test_fasta.py
index faf5580e6..a83af8e70 100644
--- a/tests/test_parse/test_fasta.py
+++ b/tests/test_parse/test_fasta.py
@@ -424,3 +424,11 @@ def test_seq_startswith_gt_bracket_fail():
     oneseq_w_gt = ">abc\n>CAG\n".split("\n")
     with pytest.raises(RecordError):
         list(MinimalFastaParser(oneseq_w_gt))
+
+
+@pytest.mark.parametrize("sep", (" ", "\t"))
+@pytest.mark.parametrize("strict", (False, True))
+def test_fasta_with_spaces(strict, sep):
+    data = [">A", sep.join(("gaaaa", "tgatt")), ">B", sep.join(("tttga", "gcagg"))]
+    got = dict(MinimalFastaParser(data, strict=strict))
+    assert got == {"A": "gaaaatgatt", "B": "tttgagcagg"}
diff --git a/tests/test_parse/test_tree.py b/tests/test_parse/test_tree.py
index da1dc16c4..3cf1314d2 100644
--- a/tests/test_parse/test_tree.py
+++ b/tests/test_parse/test_tree.py
@@ -1,7 +1,5 @@
 """Unit tests for tree parsers.
 """
-import pathlib
-
 from unittest import TestCase
 
 from cogent3.core.tree import PhyloNode
@@ -276,6 +274,15 @@ def test_gops(self):
         self.assertEqual(str(p), "((xyz):2)abc:3;")
 
 
+def test_make_tree_simple():
+    from cogent3 import make_tree
+
+    tree = make_tree(treestring="(a:0.4,b:0.5,c:0.6)")
+    assert tree.get_node_matching_name("a").length == 0.4
+    assert tree.get_node_matching_name("b").length == 0.5
+    assert tree.get_node_matching_name("c").length == 0.6
+
+
 def test_stores_nhx_data(DATA_DIR):
     """capture extended newick data into <node>.params['other']"""
     with open(DATA_DIR / "nhx.tree") as path:
@@ -292,3 +299,41 @@ def test_stores_nhx_data(DATA_DIR):
             'sDF2_N="6.49"',
             'sN="120.33"',
         }
+
+
+def test_subtree_nhx_data(DATA_DIR):
+    """capture extended newick data into <node>.params['other']"""
+    with open(DATA_DIR / "nhx.tree") as path:
+        tree = DndParser(path)
+
+    subtree = tree.get_sub_tree(["Seal", "Mouse", "Rat", "Platypus", "Opossum"])
+    rodent = subtree.get_connecting_node("Mouse", "Rat")
+    assert set(rodent.params["other"]) == {
+        '&sCF="89.13"',
+        'sCF/sDF1/sDF2="89.13/5.49/5.38"',
+        'sCF_N="107.21"',
+        'sCF_N/sDF1_N/sDF2_N="107.21/6.63/6.49"',
+        'sDF1="5.49"',
+        'sDF1_N="6.63"',
+        'sDF2="5.38"',
+        'sDF2_N="6.49"',
+        'sN="120.33"',
+    }
+
+
+def test_make_tree_with_nhx(DATA_DIR):
+    """capture extended newick data into <node>.params['other']"""
+    from cogent3 import make_tree
+
+    nwk = (
+        '(t1:0.1,(t2:0.2,t3:0.3):0.4[&sCF="81.73"],'
+        '((t4:0.5,t5:0.6):0.7[&sCF="50.68",sDF1="24.42"],'
+        't6:0.8):0.9[&sCF="77.1",sDF1="11.53"]);'
+    )
+    got = make_tree(nwk)
+    vert = got.get_connecting_node("t2", "t3")
+    assert set(vert.params["other"]) == {'&sCF="81.73"'}
+    vert = got.get_connecting_node("t4", "t5")
+    assert set(vert.params["other"]) == {'&sCF="50.68"', 'sDF1="24.42"'}
+    vert = got.get_connecting_node("t5", "t6")
+    assert set(vert.params["other"]) == {'&sCF="77.1"', 'sDF1="11.53"'}