From e2045b34bf0cc99af53d276a14264182879fdccb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:53:12 +0000 Subject: [PATCH 1/2] fix(flashdeconv): guard empty heatmap against scipy zero-size reduction A deconvolved heatmap panel for an MS level with no peaks passed an empty frame to scipy's binned_statistic_2d, which raises "zero-size array to reduction operation minimum which has no identity" while computing bin edges, crashing the viewer page. Short-circuit downsample_heatmap when the collected frame is empty, returning the input unchanged (same schema, zero rows) so the panel renders empty instead of crashing. Add a regression test. https://claude.ai/code/session_01NrxFNaVYUhjrLJ8HiS1ZZK --- src/render/compression.py | 7 +++++ tests/test_render_compression.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/test_render_compression.py diff --git a/src/render/compression.py b/src/render/compression.py index 316e5d2..f42576b 100644 --- a/src/render/compression.py +++ b/src/render/compression.py @@ -51,6 +51,13 @@ def downsample_heatmap(data, max_datapoints=20000, rt_bins=400, mz_bins=50, logg # We need to collect here because scipy requires numpy arrays sorted_data = sorted_data.collect() + + # scipy's binned_statistic_2d reduces over the inputs to derive bin edges + # and raises "zero-size array to reduction operation minimum" on empty + # input. With no peaks there is nothing to downsample, so return the input + # unchanged (same schema, zero rows). + if sorted_data.is_empty(): + return data # Count peaks total_count = sorted_data.select(pl.count()).item() diff --git a/tests/test_render_compression.py b/tests/test_render_compression.py new file mode 100644 index 0000000..f7e3593 --- /dev/null +++ b/tests/test_render_compression.py @@ -0,0 +1,50 @@ +""" +Tests for downsample_heatmap, the heatmap point-reduction helper. + +The deconvolved-heatmap panels are built per MS level, and a level with no +peaks (e.g. an MS1-only run that still shows an MS2 panel) yields an empty +frame. That empty frame used to reach scipy's binned_statistic_2d, which +raises "zero-size array to reduction operation minimum which has no identity" +while computing bin edges. downsample_heatmap must short-circuit on empty +input and return an empty, schema-preserving frame instead of crashing. + +The helper is pure polars/numpy/scipy (no Streamlit), so it is unit-testable +without booting the app. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import polars as pl + +from src.render.compression import downsample_heatmap + + +HEATMAP_SCHEMA = {"mass": pl.Float64, "rt": pl.Float64, "intensity": pl.Float64} + + +def test_empty_dataframe_returns_empty_with_schema(): + result = downsample_heatmap(pl.DataFrame(schema=HEATMAP_SCHEMA)).collect() + assert result.is_empty() + assert set(result.columns) >= {"mass", "rt", "intensity"} + + +def test_empty_lazyframe_returns_empty_with_schema(): + result = downsample_heatmap(pl.LazyFrame(schema=HEATMAP_SCHEMA)).collect() + assert result.is_empty() + assert set(result.columns) >= {"mass", "rt", "intensity"} + + +def test_nonempty_input_passes_through_binning(): + df = pl.DataFrame( + { + "mass": [100.0, 200.0, 300.0], + "rt": [1.0, 2.0, 3.0], + "intensity": [10.0, 20.0, 30.0], + } + ) + result = downsample_heatmap(df).collect() + assert set(result.columns) >= {"mass", "rt", "intensity"} + assert result.height <= df.height From a4d0ae3716ca00f84e5e472c068a3c29283354eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 11:12:30 +0000 Subject: [PATCH 2/2] ci: add dedicated unit-test workflow running pytest The existing build-and-test workflow only runs container/k8s deployment smoke tests, so the pytest suite in tests/ never ran in CI. Add a separate Unit Tests workflow (Python 3.11, matching the Dockerfile runtime) that installs the pinned requirements plus pytest and fakeredis, then runs pytest tests/. https://claude.ai/code/session_01NrxFNaVYUhjrLJ8HiS1ZZK --- .github/workflows/unit-tests.yml | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..5f78666 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,37 @@ +name: Unit Tests + +# Runs the Python unit tests in tests/ (pytest). This is intentionally +# separate from build-and-test.yml, whose "test" jobs are container/k8s +# deployment smoke tests rather than pytest. + +on: + push: + branches: [develop] + pull_request: + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + # Match the runtime built in the Dockerfile (python=3.11). + python-version: "3.11" + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + # Pinned runtime deps (pyopenms is needed so ParameterManager imports + # cleanly at collection time) plus test-only deps. fakeredis backs the + # QueueManager/WorkflowManager tests, which pytest.importorskip it. + pip install -r requirements.txt + pip install pytest fakeredis + + - name: Run unit tests + run: pytest tests/ -v