diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4ab716..0562a98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,3 +173,51 @@ jobs: build/_deps/**/*.log if-no-files-found: ignore retention-days: 14 + + # read_idealized_bursts and log_bin_edges import dcio and scalcs at call time, + # so the likelihood library needs nothing but numpy. The cost of that design is + # that the matrix above, which installs neither, cannot exercise either entry + # point -- both shipped with no coverage at all, and a green run said nothing + # about them. This job installs both from git and runs the tests that do. + # + # It builds through scikit-build-core rather than the CMake matrix: pip install + # gives an installed package, which is also what a user has. + dcio-integration: + name: dcio-integration + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Install build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends build-essential swig libeigen3-dev libgmp-dev libmpfr-dev liblapack-dev gfortran ninja-build + + - name: Build and install HJCFIT + run: | + python -m pip install --upgrade pip + pip install . + + # Neither is on PyPI. When they are, these become ordinary pins. + - name: Install the optional record layer + run: | + pip install git+https://github.com/DCPROGS/dcio@master + pip install git+https://github.com/DCPROGS/SCALCS@master + pip install pytest matplotlib + pip list + + # Run from outside the source tree, so the installed package is what is + # imported rather than the checkout. + - name: Tests + env: + MPLBACKEND: Agg + run: | + cp tests/test_dcio_integration.py /tmp/ + cd /tmp + pytest test_dcio_integration.py -v diff --git a/python/__init__.py b/python/__init__.py index 74395d3..4429480 100644 --- a/python/__init__.py +++ b/python/__init__.py @@ -75,9 +75,10 @@ def read_idealized_bursts(filename, tau, tcrit): except ImportError as e: raise ImportError( "read_idealized_bursts needs dcio and scalcs, which are not " - "dependencies of HJCFIT and are not on PyPI. Install them from " - "https://github.com/remislp/dcio and " - "https://github.com/remislp/SCALCS. (Original error: {0})".format(e)) + "dependencies of HJCFIT and are not on PyPI. Install them with: " + "pip install git+https://github.com/DCPROGS/dcio -- and -- " + "pip install git+https://github.com/DCPROGS/SCALCS. " + "(Original error: {0})".format(e)) if not exists(filename): # Not a path: check whether it names one of the sample records, with or diff --git a/tests/test_dcio_integration.py b/tests/test_dcio_integration.py new file mode 100644 index 0000000..88a4cd5 --- /dev/null +++ b/tests/test_dcio_integration.py @@ -0,0 +1,136 @@ +"""HJCFIT's optional dcio-backed entry points. + +read_idealized_bursts and log_bin_edges import dcio (and scalcs) at call time, +so that the likelihood library itself needs nothing but numpy. Nothing +exercised either of them: the delegation added in #180 and the burst reading +reworked in #170 both shipped with no coverage, and CI could not have caught a +break in either because dcio was never installed there. + +Run with dcio and scalcs installed; the CI job that does so is dcio-integration. +""" + +import sys + +import numpy as np +import pytest + +import HJCFIT +from HJCFIT.likelihood._methods import ( + ideal_pdf_scale_factor, + log_bin_edges, +) + +pytest.importorskip("dcio", reason="dcio-backed entry points need dcio") + + +class TestSoftDependency: + """The whole point of the lazy imports: dcio must not be pulled in just by + importing the library.""" + + def test_importing_hjcfit_does_not_import_dcio(self): + out = __import__("subprocess").run( + [sys.executable, "-c", + "import sys, HJCFIT, HJCFIT.likelihood; " + "print('dcio' in sys.modules or 'scalcs' in sys.modules)"], + capture_output=True, text=True, check=True) + assert out.stdout.strip() == "False", ( + "importing HJCFIT pulled in dcio or scalcs; they must stay lazy") + + +class TestReadIdealizedBursts: + + def test_ch82_sample(self): + """Golden values for the shipped CH82 record. + + Any change to dcio's resolution imposition or burst convention moves + these; that is what they are for.""" + bursts = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=4e-3) + assert len(bursts) == 572 + assert sum(len(b) for b in bursts) == 1100 + assert sum(b.sum() for b in bursts) == pytest.approx(4.819128, abs=1e-5) + + def test_every_burst_alternates_open_shut(self): + """Each burst starts and ends on an opening, so its interval count is + odd. The missed-events likelihood is a product of matrices alternating + A->F and F->A; an even-length burst would end on a shut.""" + bursts = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=4e-3) + assert all(len(b) % 2 == 1 for b in bursts) + + def test_tcrit_sign_does_not_change_segmentation(self): + """A negative tcrit is a flag to Log10Likelihood selecting equilibrium + vectors over CHS vectors; the magnitude is the critical time, and the + same number is passed to both.""" + pos = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=4e-3) + neg = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=-4e-3) + assert len(pos) == len(neg) + for a, b in zip(pos, neg): + np.testing.assert_allclose(a, b) + + def test_named_samples_all_readable(self): + for name in ("CH82", "CO", "CCO"): + bursts = HJCFIT.read_idealized_bursts(name, tau=1e-4, tcrit=4e-3) + assert len(bursts) > 0 + + +class TestLogBinEdges: + """Delegates to dcio.analysis.histogram (#180).""" + + def test_edges_cover_the_longest_interval(self): + x = np.random.default_rng(5).exponential(0.005, 2000) + 2.5e-5 + edges, nbdec = log_bin_edges(x, 2.5e-5) + assert x.max() <= edges[-1] + assert edges[0] == pytest.approx(2.5e-5) + + def test_nbdec_from_sample_size(self): + for n, expected in ((200, 5), (800, 8), (2000, 10), (8000, 12)): + x = np.full(n, 0.01) + _, nbdec = log_bin_edges(x, 1e-5) + assert nbdec == expected + + def test_nbdec_bins_span_a_decade(self): + edges, nbdec = log_bin_edges(np.full(2000, 0.01), 1e-5) + assert edges[nbdec] / edges[0] == pytest.approx(10.0) + + +class TestIdealPdfScaleFactor: + """Stays in HJCFIT: it renormalises an ideal pdf onto the resolved + intervals from the Q-matrix survival, which is not what EKDIST's + similarly-named helper computes.""" + + def test_unit_resolution_gives_unit_factor(self): + aa = np.array([[-1000.0]]) + phi = np.array([1.0]) + assert ideal_pdf_scale_factor(0.0, aa, phi) == pytest.approx(1.0) + + def test_factor_exceeds_one_for_positive_resolution(self): + aa = np.array([[-1000.0]]) + phi = np.array([1.0]) + assert ideal_pdf_scale_factor(1e-4, aa, phi) > 1.0 + + +class TestDwellTimeHistogram: + """The consumer of log_bin_edges. Needs matplotlib, which HJCFIT does not + depend on either.""" + + def test_returns_axes(self): + pytest.importorskip("matplotlib") + import matplotlib + matplotlib.use("Agg") + from HJCFIT.likelihood._methods import dwell_time_histogram + + x = np.random.default_rng(6).exponential(0.005, 2000) + 2.5e-5 + ax = dwell_time_histogram(x, 2.5e-5) + assert ax is not None + + def test_bars_account_for_every_interval(self): + pytest.importorskip("matplotlib") + import matplotlib + matplotlib.use("Agg") + from HJCFIT.likelihood._methods import dwell_time_histogram + + x = np.random.default_rng(7).exponential(0.005, 1500) + 2.5e-5 + ax = dwell_time_histogram(x, 2.5e-5) + # the outline is drawn as sqrt(counts); squaring recovers them, and + # each bar contributes its count twice + ydata = ax.lines[0].get_ydata() + assert round((ydata ** 2).sum() / 2) == len(x)