diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index a6f62bed..ebf7a97a 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -61,7 +61,7 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 - name: Build wheels - uses: pypa/cibuildwheel@v2.23.1 + uses: pypa/cibuildwheel@v2.23.3 env: # Can specify per os - e.g. CIBW_BEFORE_ALL_LINUX, CIBW_BEFORE_ALL_MACOS, CIBW_BEFORE_ALL_WINDOWS CIBW_BEFORE_ALL_LINUX: ./build_tools/before_all_linux.sh CIBW_BEFORE_ALL_MACOS: ./build_tools/before_all_mac.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index faca30e4..fc4ff196 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 - name: Build wheels - uses: pypa/cibuildwheel@v2.23.1 + uses: pypa/cibuildwheel@v2.23.3 env: # Can specify per os - e.g. CIBW_BEFORE_ALL_LINUX, CIBW_BEFORE_ALL_MACOS, CIBW_BEFORE_ALL_WINDOWS CIBW_BEFORE_ALL_LINUX: ./build_tools/before_all_linux.sh CIBW_BEFORE_ALL_MACOS: ./build_tools/before_all_mac.sh diff --git a/build_tools/build_iqtree.sh b/build_tools/build_iqtree.sh index 5aedccd6..cdc95266 100755 --- a/build_tools/build_iqtree.sh +++ b/build_tools/build_iqtree.sh @@ -30,8 +30,8 @@ elif [[ "$OSTYPE" == "msys"* || "$OSTYPE" == "cygwin"* ]]; then .. make -j else - echo "Building for Linux." - cmake -DBUILD_LIB=ON .. + echo "Building for linux." + cmake -DBUILD_LIB=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 .. make -j fi diff --git a/changelog.md b/changelog.md index 5978672c..818884e0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,24 @@ + +# Changes in release "0.5.0" + +## Contributors + +- @GavinHuttley added `nj_tree` as a hook for `cogent3.Alignment.quick_tree`. +- @YapengLang handled negative branch lengths from the rapidNJ tree. +- @rmcar17, @thomaskf general maintanence on the piqtree/IQ-TREE sides including work on windows behind the scenes. + +## ENH + +- Add support for Python 3.13, remove support for 3.10 +- IQ-TREE's rapidNJ implementation can be used as a hook for `quick_tree` on `cogent3` alignment objects. Try `Alignment.quick_tree(use_hook="piqtree")`. +- `nj_tree` now by default does not allow negative branch lengths. Set `allow_negative=True` if this behaviour is desired. +- Allow `str` to be used for `model` in `build_tree` and `fit_tree`. The value is automatically coerced into the `Model` class. + +## API + +- Simplify API for `piqtree_phylo` and `piqtree_fit` apps. Both now take a single parameter for the model, matching the parameter for `model` in `build_tree` and `fit_tree`. + # Changes in release "0.4.0" diff --git a/docs/developers/release.md b/docs/developers/release.md index 974a38d9..fae6fdd4 100644 --- a/docs/developers/release.md +++ b/docs/developers/release.md @@ -8,7 +8,7 @@ The documentation is fetched by readthedocs from the "Build Docs" GitHub Action. - The `piqtree` version has been correctly bumped. - The testing, linting and type checking all pass on all supported platforms. -- The code has been throuroughly tested (check what's been missed in the coverage report). +- The code has been thoroughly tested (check what's been missed in the coverage report). - The documentation builds and appears correct. - The documentation has been updated on readthedocs (this must be triggered from readthedocs). - The "Release" GitHub Action has correctly uploaded to Test PyPI. diff --git a/pyproject.toml b/pyproject.toml index 9460368e..f907df5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "piqtree" -dependencies = ["cogent3>=2024.11.29a2", "pyyaml", "requests"] +dependencies = ["cogent3>=2025.5.8a2", "pyyaml", "requests"] requires-python = ">=3.11, <3.14" authors = [{name="Gavin Huttley"}, {name="Robert McArthur"}, {name="Bui Quang Minh "}, {name="Richard Morris"}, {name="Thomas Wong"}] @@ -28,9 +28,9 @@ classifiers = [ "Programming Language :: C++", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Typing :: Typed" ] @@ -43,7 +43,7 @@ Documentation = "https://piqtree.readthedocs.io" [project.optional-dependencies] dev = ["cibuildwheel", "pybind11", "scriv", "piqtree[test]", "piqtree[lint]", "piqtree[typing]"] test = ["pytest", "pytest-cov", "nox"] -lint = ["ruff==0.11.0"] +lint = ["ruff==0.11.9"] typing = ["mypy==1.15.0", "piqtree[stubs]", "piqtree[test]"] stubs = ["types-PyYAML", "types-requests"] extra = ["cogent3[extra]"] diff --git a/src/piqtree/__init__.py b/src/piqtree/__init__.py index 5a91c957..5d7abd5e 100644 --- a/src/piqtree/__init__.py +++ b/src/piqtree/__init__.py @@ -35,7 +35,7 @@ def _add_dll_path() -> None: make_model, ) -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = [ "Model", diff --git a/src/piqtree/_app/__init__.py b/src/piqtree/_app/__init__.py index e70c9de5..32d6cfec 100644 --- a/src/piqtree/_app/__init__.py +++ b/src/piqtree/_app/__init__.py @@ -23,21 +23,13 @@ class piqtree_phylo: @extend_docstring_from(build_tree) def __init__( self, - submod_type: str, - freq_type: str | None = None, - rate_model: str | None = None, + model: Model | str, *, - invariant_sites: bool = False, rand_seed: int | None = None, bootstrap_reps: int | None = None, num_threads: int | None = None, ) -> None: - self._model = Model( - submod_type=submod_type, - invariant_sites=invariant_sites, - rate_model=rate_model, - freq_type=freq_type, - ) + self._model = model self._rand_seed = rand_seed self._bootstrap_reps = bootstrap_reps self._num_threads = num_threads @@ -61,21 +53,13 @@ class piqtree_fit: def __init__( self, tree: cogent3.PhyloNode, - submod_type: str, - freq_type: str | None = None, - rate_model: str | None = None, + model: Model | str, *, rand_seed: int | None = None, num_threads: int | None = None, - invariant_sites: bool = False, ) -> None: self._tree = tree - self._model = Model( - submod_type=submod_type, - invariant_sites=invariant_sites, - rate_model=rate_model, - freq_type=freq_type, - ) + self._model = model self._rand_seed = rand_seed self._num_threads = num_threads @@ -124,8 +108,12 @@ def main( @composable.define_app @extend_docstring_from(nj_tree) -def piqtree_nj(dists: c3_types.PairwiseDistanceType) -> cogent3.PhyloNode: - tree = nj_tree(dists) +def piqtree_nj( + dists: c3_types.PairwiseDistanceType, + *, + allow_negative: bool = False, +) -> cogent3.PhyloNode: + tree = nj_tree(dists, allow_negative=allow_negative) tree.params |= {"provenance": "piqtree"} return tree diff --git a/src/piqtree/iqtree/_tree.py b/src/piqtree/iqtree/_tree.py index 2485db00..a302e6ea 100644 --- a/src/piqtree/iqtree/_tree.py +++ b/src/piqtree/iqtree/_tree.py @@ -11,7 +11,7 @@ from piqtree.exceptions import ParseIqTreeError from piqtree.iqtree._decorator import iqtree_func -from piqtree.model import DnaModel, Model +from piqtree.model import DnaModel, Model, make_model iq_build_tree = iqtree_func(iq_build_tree, hide_files=True) iq_fit_tree = iqtree_func(iq_fit_tree, hide_files=True) @@ -196,7 +196,7 @@ def _process_tree_yaml( def build_tree( aln: c3_types.AlignedSeqsType, - model: Model, + model: Model | str, rand_seed: int | None = None, bootstrap_replicates: int | None = None, num_threads: int | None = None, @@ -209,7 +209,7 @@ def build_tree( ---------- aln : c3_types.AlignedSeqsType The sequence alignment. - model : Model + model : Model | str The substitution model with base frequencies and rate heterogeneity. rand_seed : int | None, optional The random seed - 0 or None means no seed, by default None. @@ -227,6 +227,9 @@ def build_tree( The IQ-TREE maximum likelihood tree from the given alignment. """ + if isinstance(model, str): + model = make_model(model) + if rand_seed is None: rand_seed = 0 # The default rand_seed in IQ-TREE @@ -261,7 +264,7 @@ def build_tree( def fit_tree( aln: c3_types.AlignedSeqsType, tree: cogent3.PhyloNode, - model: Model, + model: Model | str, rand_seed: int | None = None, num_threads: int | None = None, ) -> cogent3.PhyloNode: @@ -276,7 +279,7 @@ def fit_tree( The sequence alignment. tree : cogent3.PhyloNode The topology to fit branch lengths to. - model : Model + model : Model | str The substitution model with base frequencies and rate heterogeneity. rand_seed : int | None, optional The random seed - 0 or None means no seed, by default None. @@ -290,6 +293,9 @@ def fit_tree( A phylogenetic tree with same given topology fitted with branch lengths. """ + if isinstance(model, str): + model = make_model(model) + if rand_seed is None: rand_seed = 0 # The default rand_seed in IQ-TREE @@ -312,7 +318,11 @@ def fit_tree( return tree -def nj_tree(pairwise_distances: c3_types.PairwiseDistanceType) -> cogent3.PhyloNode: +def nj_tree( + pairwise_distances: c3_types.PairwiseDistanceType, + *, + allow_negative: bool = False, +) -> cogent3.PhyloNode: """Construct a neighbour joining tree from a pairwise distance matrix. Parameters @@ -334,4 +344,11 @@ def nj_tree(pairwise_distances: c3_types.PairwiseDistanceType) -> cogent3.PhyloN pairwise_distances.keys(), np.array(pairwise_distances).flatten(), ) - return make_tree(newick_tree) + + tree = make_tree(newick_tree) + + if not allow_negative: + for node in tree.traverse(include_self=False): + node.length = max(node.length, 0) + + return tree diff --git a/src/piqtree/model/_options.py b/src/piqtree/model/_options.py index ebfa3655..4229ad92 100644 --- a/src/piqtree/model/_options.py +++ b/src/piqtree/model/_options.py @@ -3,7 +3,7 @@ import functools from typing import Literal -from cogent3 import _Table, make_table +from cogent3.core.table import Table, make_table from piqtree.model._freq_type import FreqType from piqtree.model._rate_type import ALL_BASE_RATE_TYPES, get_description @@ -40,7 +40,7 @@ def available_models( model_type: Literal["dna", "protein"] | None = None, *, show_all: bool = True, -) -> _Table: +) -> Table: """Return a table showing available substitution models. Parameters @@ -52,7 +52,7 @@ def available_models( Returns ------- - _Table + Table Table with all available models. """ @@ -78,7 +78,7 @@ def available_models( return table -def available_freq_type() -> _Table: +def available_freq_type() -> Table: """Return a table showing available freq type options.""" data: dict[str, list[str]] = {"Freq Type": [], "Description": []} @@ -89,7 +89,7 @@ def available_freq_type() -> _Table: return make_table(data=data, title="Available frequency types") -def available_rate_type() -> _Table: +def available_rate_type() -> Table: """Return a table showing available rate type options.""" data: dict[str, list[str]] = {"Rate Type": [], "Description": []} diff --git a/tests/conftest.py b/tests/conftest.py index 76778ade..7d4a1eb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import pathlib import pytest -from cogent3 import ArrayAlignment, load_aligned_seqs +from cogent3 import Alignment, load_aligned_seqs @pytest.fixture(scope="session") @@ -10,21 +10,27 @@ def DATA_DIR() -> pathlib.Path: @pytest.fixture -def three_otu(DATA_DIR: pathlib.Path) -> ArrayAlignment: +def three_otu(DATA_DIR: pathlib.Path) -> Alignment: aln = load_aligned_seqs(DATA_DIR / "example.fasta", moltype="dna", new_type=True) aln = aln.take_seqs(["Human", "Rhesus", "Mouse"]) return aln.omit_gap_pos(allowed_gap_frac=0) @pytest.fixture -def four_otu(DATA_DIR: pathlib.Path) -> ArrayAlignment: +def four_otu(DATA_DIR: pathlib.Path) -> Alignment: aln = load_aligned_seqs(DATA_DIR / "example.fasta", moltype="dna", new_type=True) aln = aln.take_seqs(["Human", "Chimpanzee", "Rhesus", "Mouse"]) return aln.omit_gap_pos(allowed_gap_frac=0) @pytest.fixture -def five_otu(DATA_DIR: pathlib.Path) -> ArrayAlignment: +def five_otu(DATA_DIR: pathlib.Path) -> Alignment: aln = load_aligned_seqs(DATA_DIR / "example.fasta", moltype="dna", new_type=True) aln = aln.take_seqs(["Human", "Chimpanzee", "Rhesus", "Manatee", "Dugong"]) return aln.omit_gap_pos(allowed_gap_frac=0) + + +@pytest.fixture +def all_otu(DATA_DIR: pathlib.Path) -> Alignment: + aln = load_aligned_seqs(DATA_DIR / "example.fasta", moltype="dna", new_type=True) + return aln.omit_gap_pos(allowed_gap_frac=0) diff --git a/tests/test_app/test_app.py b/tests/test_app/test_app.py index 7bd3aa2f..6470cb9a 100644 --- a/tests/test_app/test_app.py +++ b/tests/test_app/test_app.py @@ -1,21 +1,20 @@ import pytest -from cogent3 import __version__ as cogent3_vers from cogent3 import get_app, make_tree from cogent3.core.new_alignment import Alignment import piqtree -from piqtree import jc_distances +from piqtree import jc_distances, make_model def test_piqtree_phylo(four_otu: Alignment) -> None: expected = make_tree("(Human,Chimpanzee,(Rhesus,Mouse));") - app = get_app("piqtree_phylo", submod_type="JC") + app = get_app("piqtree_phylo", model="JC") got = app(four_otu) assert expected.same_topology(got) def test_piqtree_phylo_support(four_otu: Alignment) -> None: - app = get_app("piqtree_phylo", submod_type="JC", bootstrap_reps=1000) + app = get_app("piqtree_phylo", model=make_model("JC"), bootstrap_reps=1000) got = app(four_otu) supports = [ node.params.get("support", None) @@ -29,7 +28,7 @@ def test_piqtree_fit(three_otu: Alignment) -> None: tree = make_tree(tip_names=three_otu.names) app = get_app("model", "JC69", tree=tree) expected = app(three_otu) - piphylo = get_app("piqtree_fit", tree=tree, submod_type="JC") + piphylo = get_app("piqtree_fit", tree=tree, model="JC") got = piphylo(three_otu) assert got.params["lnL"] == pytest.approx(expected.lnL) @@ -112,7 +111,6 @@ def test_mfinder_result_roundtrip(five_otu: Alignment) -> None: assert str(got.best_aicc) == str(inflated.best_aicc) -@pytest.mark.skipif(cogent3_vers < "2025.3.1", reason="requires cogent3 >= 2025.3.1") @pytest.mark.parametrize("use_hook", [None, "piqtree"]) def test_quick_tree_hook(four_otu: Alignment, use_hook: str | None) -> None: tree = four_otu.quick_tree(use_hook=use_hook) diff --git a/tests/test_iqtree/test_build_tree.py b/tests/test_iqtree/test_build_tree.py index 90cbf8ab..250cba32 100644 --- a/tests/test_iqtree/test_build_tree.py +++ b/tests/test_iqtree/test_build_tree.py @@ -1,7 +1,7 @@ import re import pytest -from cogent3 import ArrayAlignment, make_tree +from cogent3 import Alignment, make_tree import piqtree from piqtree.exceptions import IqTreeError @@ -16,12 +16,13 @@ def check_build_tree( - four_otu: ArrayAlignment, + four_otu: Alignment, dna_model: DnaModel, freq_type: FreqType | None = None, rate_model: RateModel | None = None, *, invariant_sites: bool = False, + coerce_str: bool = False, ) -> None: expected = make_tree("(Human,Chimpanzee,(Rhesus,Mouse));") @@ -32,7 +33,11 @@ def check_build_tree( rate_model=rate_model, ) - got1 = piqtree.build_tree(four_otu, model, rand_seed=1) + got1 = piqtree.build_tree( + four_otu, + str(model) if coerce_str else model, + rand_seed=1, + ) got1 = got1.unrooted() # Check topology assert expected.same_topology(got1.unrooted()) @@ -49,7 +54,7 @@ def check_build_tree( @pytest.mark.parametrize("dna_model", list(DnaModel)[:22]) @pytest.mark.parametrize("freq_type", list(FreqType)) def test_non_lie_build_tree( - four_otu: ArrayAlignment, + four_otu: Alignment, dna_model: DnaModel, freq_type: FreqType, ) -> None: @@ -57,10 +62,15 @@ def test_non_lie_build_tree( @pytest.mark.parametrize("dna_model", list(DnaModel)[22:]) -def test_lie_build_tree(four_otu: ArrayAlignment, dna_model: DnaModel) -> None: +def test_lie_build_tree(four_otu: Alignment, dna_model: DnaModel) -> None: check_build_tree(four_otu, dna_model) +@pytest.mark.parametrize("dna_model", list(DnaModel)[-3:]) +def test_str_build_tree(four_otu: Alignment, dna_model: DnaModel) -> None: + check_build_tree(four_otu, dna_model, coerce_str=True) + + @pytest.mark.parametrize("dna_model", list(DnaModel)[:5]) @pytest.mark.parametrize("invariant_sites", [False, True]) @pytest.mark.parametrize( @@ -74,7 +84,7 @@ def test_lie_build_tree(four_otu: ArrayAlignment, dna_model: DnaModel) -> None: ], ) def test_rate_model_build_tree( - four_otu: ArrayAlignment, + four_otu: Alignment, dna_model: DnaModel, invariant_sites: bool, rate_model: RateModel, @@ -87,12 +97,12 @@ def test_rate_model_build_tree( ) -def test_build_tree_inadequate_bootstrapping(four_otu: ArrayAlignment) -> None: +def test_build_tree_inadequate_bootstrapping(four_otu: Alignment) -> None: with pytest.raises(IqTreeError, match=re.escape("#replicates must be >= 1000")): piqtree.build_tree(four_otu, Model(DnaModel.GTR), bootstrap_replicates=10) -def test_build_tree_bootstrapping(four_otu: ArrayAlignment) -> None: +def test_build_tree_bootstrapping(four_otu: Alignment) -> None: tree = piqtree.build_tree(four_otu, Model(DnaModel.GTR), bootstrap_replicates=1000) supported_node = max(tree.children, key=lambda x: len(x.children)) diff --git a/tests/test_iqtree/test_distance.py b/tests/test_iqtree/test_distance.py index 38b694b5..b3c18485 100644 --- a/tests/test_iqtree/test_distance.py +++ b/tests/test_iqtree/test_distance.py @@ -1,9 +1,9 @@ -from cogent3 import ArrayAlignment +from cogent3 import Alignment from piqtree import jc_distances -def test_jc_distance(five_otu: ArrayAlignment) -> None: +def test_jc_distance(five_otu: Alignment) -> None: dists = jc_distances(five_otu) assert ( diff --git a/tests/test_iqtree/test_fit_tree.py b/tests/test_iqtree/test_fit_tree.py index 91571a63..cf562eaf 100644 --- a/tests/test_iqtree/test_fit_tree.py +++ b/tests/test_iqtree/test_fit_tree.py @@ -1,5 +1,5 @@ import pytest -from cogent3 import ArrayAlignment, get_app, make_tree +from cogent3 import Alignment, get_app, make_tree from cogent3.app.result import model_result from cogent3.core.tree import PhyloNode @@ -78,19 +78,57 @@ def check_branch_lengths(got: PhyloNode, expected: PhyloNode) -> None: (DnaModel.F81, "F81"), ], ) -def test_fit_tree(three_otu: ArrayAlignment, iq_model: DnaModel, c3_model: str) -> None: +def test_fit_tree(three_otu: Alignment, iq_model: DnaModel, c3_model: str) -> None: tree_topology = make_tree(tip_names=three_otu.names) app = get_app("model", c3_model, tree=tree_topology) expected = app(three_otu) - got1 = piqtree.fit_tree(three_otu, tree_topology, Model(iq_model), rand_seed=1) + model = Model(iq_model) + + got1 = piqtree.fit_tree(three_otu, tree_topology, model, rand_seed=1) + check_likelihood(got1, expected) + check_motif_probs(got1, expected.tree) + check_rate_parameters(got1, expected.tree) + check_branch_lengths(got1, expected.tree) + + # Should be within an approximation for any seed + got2 = piqtree.fit_tree(three_otu, tree_topology, model, rand_seed=None) + check_likelihood(got2, expected) + check_motif_probs(got2, expected.tree) + check_rate_parameters(got2, expected.tree) + check_branch_lengths(got2, expected.tree) + + +@pytest.mark.parametrize( + ("iq_model", "c3_model"), + [ + (DnaModel.JC, "JC69"), + (DnaModel.K80, "K80"), + (DnaModel.GTR, "GTR"), + (DnaModel.TN, "TN93"), + (DnaModel.HKY, "HKY85"), + (DnaModel.F81, "F81"), + ], +) +def test_fit_tree_str_model( + three_otu: Alignment, + iq_model: DnaModel, + c3_model: str, +) -> None: + tree_topology = make_tree(tip_names=three_otu.names) + app = get_app("model", c3_model, tree=tree_topology) + expected = app(three_otu) + + model = str(Model(iq_model)) + + got1 = piqtree.fit_tree(three_otu, tree_topology, model, rand_seed=1) check_likelihood(got1, expected) check_motif_probs(got1, expected.tree) check_rate_parameters(got1, expected.tree) check_branch_lengths(got1, expected.tree) # Should be within an approximation for any seed - got2 = piqtree.fit_tree(three_otu, tree_topology, Model(iq_model), rand_seed=None) + got2 = piqtree.fit_tree(three_otu, tree_topology, model, rand_seed=None) check_likelihood(got2, expected) check_motif_probs(got2, expected.tree) check_rate_parameters(got2, expected.tree) diff --git a/tests/test_iqtree/test_model_finder.py b/tests/test_iqtree/test_model_finder.py index fa98865e..ce0af20f 100644 --- a/tests/test_iqtree/test_model_finder.py +++ b/tests/test_iqtree/test_model_finder.py @@ -1,7 +1,7 @@ import multiprocessing import pytest -from cogent3 import ArrayAlignment +from cogent3 import Alignment from piqtree.iqtree import ModelFinderResult, ModelResultValue, model_finder @@ -47,7 +47,7 @@ def test_model_finder_result(model: str) -> None: assert result.model_stats[model].tree_length == 0.678 -def test_model_finder(five_otu: ArrayAlignment) -> None: +def test_model_finder(five_otu: Alignment) -> None: result1 = model_finder(five_otu, rand_seed=1) result2 = model_finder( five_otu, @@ -59,7 +59,7 @@ def test_model_finder(five_otu: ArrayAlignment) -> None: assert str(result1.best_bic) == str(result2.best_bic) -def test_model_finder_restricted_submod(five_otu: ArrayAlignment) -> None: +def test_model_finder_restricted_submod(five_otu: Alignment) -> None: result = model_finder(five_otu, rand_seed=1, model_set={"HKY", "TIM"}) assert str(result.best_aic).startswith(("HKY", "TIM")) assert str(result.best_aicc).startswith(("HKY", "TIM")) diff --git a/tests/test_iqtree/test_nj_tree.py b/tests/test_iqtree/test_nj_tree.py index d3d19495..ae3c68ce 100644 --- a/tests/test_iqtree/test_nj_tree.py +++ b/tests/test_iqtree/test_nj_tree.py @@ -1,12 +1,25 @@ -from cogent3 import ArrayAlignment, make_tree +from cogent3 import Alignment, make_tree from piqtree import jc_distances, nj_tree -def test_nj_tree(five_otu: ArrayAlignment) -> None: +def test_nj_tree(five_otu: Alignment) -> None: expected = make_tree("(((Human, Chimpanzee), Rhesus), Manatee, Dugong);") dists = jc_distances(five_otu) actual = nj_tree(dists) assert expected.same_topology(actual) + + +def test_nj_tree_allow_negative(all_otu: Alignment) -> None: + # a distance matrix can produce trees with negative branch lengths + dists = jc_distances(all_otu) + + # check that all branch lengths are non-negative, by default + tree1 = nj_tree(dists) + assert all(node.length >= 0 for node in tree1.traverse(include_self=False)) + + # check that some branch lengths are negative when allow_negative=True + tree2 = nj_tree(dists, allow_negative=True) + assert any(node.length < 0 for node in tree2.traverse(include_self=False))