Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

# Cancel a running PR check when new commits are pushed, so a wedged run does not
# keep burning runner hours after it has been superseded. Pushes to main still run
# to completion.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
run-tests:
runs-on: ${{ matrix.os }}
continue-on-error: false
timeout-minutes: 360
# Backstop for a wedged run: the fork/TensorFlow deadlock hangs in native code
# holding the GIL, which pytest-timeout cannot interrupt in either method, so the
# job-level limit is what stops it. Slowest healthy job is ~32 min; 50 leaves
# headroom while capping a hang's cost.
timeout-minutes: 50

strategy:
fail-fast: false
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ namespaces = true
log_cli = true
log_level = "DEBUG"
timeout = 300
# The signal method cannot interrupt a thread blocked in a C call, so a deadlocked
# fork child let the run hang until the job limit killed it hours later. The thread
# method kills the process instead, turning such hangs into a failure with a stack
# dump. Windows already uses it, since it has no SIGALRM.
timeout_method = "thread"
addopts = ["--max-worker-restart=0"]
testpaths = ["src/birdnet_tests"]
norecursedirs = ["src/birdnet_v1_tests"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@

DEFAULT_EMBEDDING_DIM = 6

# start_time/end_time are stored as float32 and, on numpy 1.x, the intermediate
# products are computed in float32 as well (numpy 2 keeps them float64 via NEP 50).
# Speeds that are not exactly representable therefore drift by ~1 ULP, which exceeds
# the assert_allclose default rtol of 1e-7 - that is below float32 eps (~1.19e-7).
_FLOAT32_TIME_RTOL = 1e-6


def create_mock_encoding_tensor(
n_files: int,
Expand Down Expand Up @@ -268,8 +274,12 @@ def test_time_calculations_speedup_one_tenth_no_overlap() -> None:
expected_starts = np.arange(len(structured)) * hop
expected_ends = np.minimum(expected_starts + 3.0 * 0.1, result.input_durations[0])

np.testing.assert_allclose(structured["start_time"], expected_starts)
np.testing.assert_allclose(structured["end_time"], expected_ends)
np.testing.assert_allclose(
structured["start_time"], expected_starts, rtol=_FLOAT32_TIME_RTOL
)
np.testing.assert_allclose(
structured["end_time"], expected_ends, rtol=_FLOAT32_TIME_RTOL
)


def test_time_calculations_speedup_decimal_no_overlap() -> None:
Expand All @@ -289,8 +299,12 @@ def test_time_calculations_speedup_decimal_no_overlap() -> None:
expected_starts + 3.0 * 0.1387434856, result.input_durations[0]
)

np.testing.assert_allclose(structured["start_time"], expected_starts)
np.testing.assert_allclose(structured["end_time"], expected_ends)
np.testing.assert_allclose(
structured["start_time"], expected_starts, rtol=_FLOAT32_TIME_RTOL
)
np.testing.assert_allclose(
structured["end_time"], expected_ends, rtol=_FLOAT32_TIME_RTOL
)


def _test_end_time_clipping_multiple_segments(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
assert_encoding_result_is_close,
ensure_onnxruntime_or_skip,
ensure_torch_or_skip,
ensure_v3_0_torch_backend_or_skip,
)
from birdnet_tests.test_files import TEST_FILE_SHORT

Expand All @@ -23,6 +24,7 @@
def _load_model(backend: _Backend) -> AcousticModelV3_0:
if backend == "pt":
ensure_torch_or_skip()
ensure_v3_0_torch_backend_or_skip()
else:
ensure_onnxruntime_or_skip()

Expand Down Expand Up @@ -84,6 +86,7 @@ def test_v3_0_encode_respects_segment_size(

def test_v3_0_encode_pt_and_onnx_are_close() -> None:
ensure_torch_or_skip()
ensure_v3_0_torch_backend_or_skip()
ensure_onnxruntime_or_skip()

pt_model = load("acoustic", "3.0", "pt", precision="fp32")
Expand All @@ -108,6 +111,7 @@ def test_v3_0_encode_pt_and_onnx_are_close_with_custom_segment_size(
segment_size_s: float,
) -> None:
ensure_torch_or_skip()
ensure_v3_0_torch_backend_or_skip()
ensure_onnxruntime_or_skip()

pt_model = load("acoustic", "3.0", "pt", precision="fp32")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
assert_prediction_result_is_close,
ensure_onnxruntime_or_skip,
ensure_torch_or_skip,
ensure_v3_0_torch_backend_or_skip,
)
from birdnet_tests.test_files import TEST_FILE_SHORT

Expand All @@ -23,6 +24,7 @@
def _load_model(backend: _Backend) -> AcousticModelV3_0:
if backend == "pt":
ensure_torch_or_skip()
ensure_v3_0_torch_backend_or_skip()
else:
ensure_onnxruntime_or_skip()

Expand Down Expand Up @@ -98,6 +100,7 @@ def test_v3_0_predict_respects_segment_size(

def test_v3_0_predict_pt_and_onnx_are_close() -> None:
ensure_torch_or_skip()
ensure_v3_0_torch_backend_or_skip()
ensure_onnxruntime_or_skip()

pt_model = load("acoustic", "3.0", "pt", precision="fp32")
Expand Down Expand Up @@ -130,6 +133,7 @@ def test_v3_0_predict_pt_and_onnx_are_close_with_custom_segment_size(
segment_size_s: float,
) -> None:
ensure_torch_or_skip()
ensure_v3_0_torch_backend_or_skip()
ensure_onnxruntime_or_skip()

pt_model = load("acoustic", "3.0", "pt", precision="fp32")
Expand Down
13 changes: 13 additions & 0 deletions src/birdnet_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import logging

import pytest

from birdnet.utils.logging_utils import get_package_logger

# The v3.0 models are ~520 MiB and Zenodo serves them at roughly 2 MiB/s, so a single
# download takes ~5-6 min. That sits right on the global 300s timeout, which cut the
# downloads off just short of completion instead of letting them finish.
LOAD_MODEL_TIMEOUT_S = 1800


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
for item in items:
if item.get_closest_marker("load_model") is not None:
item.add_marker(pytest.mark.timeout(LOAD_MODEL_TIMEOUT_S))


def pytest_configure() -> None:
loggers = {"tensorflow", "absl", "urllib3"}
Expand Down
8 changes: 8 additions & 0 deletions src/birdnet_tests/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ def ensure_not_intel_macos_or_skip() -> None:
pytest.skip("Test not supported on Intel macOS systems")


def ensure_v3_0_torch_backend_or_skip() -> None:
# torch 2.2.2 is the last release shipping x86 macOS wheels and is too old for the
# v3.0 TorchScript model: loading it succeeds, but the inference workers then die and
# the session is cancelled. The onnx backend works on the same machines.
if check_is_intel_macos():
pytest.skip("Acoustic model v3.0 needs a newer torch than Intel macOS provides")


def ensure_gpu_or_skip_smi() -> None:
gpu_available = False
try:
Expand Down
Loading