diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index de8bf7f..7672e3e 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -1,42 +1,45 @@ name: CI on: - push: - branches: - - main - pull_request: - branches: - - main - workflow_dispatch: {} + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: {} permissions: - contents: read + contents: read jobs: - ci: - runs-on: ubuntu-latest + ci: + runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: "0.7.19" - enable-cache: true - cache-dependency-glob: | - pyproject.toml - uv.lock + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.7.19" + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock - - name: Install Python - run: uv python install + - name: Install Python + run: uv python install - - name: Install the project - run: uv sync --locked --all-extras --dev + - name: Install the project + run: uv sync --locked --all-extras --dev - - name: Lint (run.sh lint:ci) - run: uv run /bin/bash -x run.sh lint:ci + - name: Lint (run.sh lint:ci) + run: uv run /bin/bash -x run.sh lint:ci - - name: Tests (run.sh test:ci) - run: uv run /bin/bash -x run.sh test:ci + - name: Tests (run.sh test:ci) + run: uv run /bin/bash -x run.sh test:ci + + - name: Build package + run: uv build diff --git a/notebooks/live_cell_tutorial.ipynb b/notebooks/live_cell_tutorial.ipynb index cf7b888..ae805fa 100644 --- a/notebooks/live_cell_tutorial.ipynb +++ b/notebooks/live_cell_tutorial.ipynb @@ -177,7 +177,8 @@ "source": [ "df_export, tmp1_path, fig, tmp2_path = cosinor_analysis.fit_cosinor(\n", " \"group1\",\n", - " method=\"poly2\",\n", + " method=\"moving_average\",\n", + " window=240,\n", " cosinor_model=\"cosinor_damped\",\n", " plot_style=\"line\",\n", ")" diff --git a/src/cosinor_lite/livecell_cosinor_analysis.py b/src/cosinor_lite/livecell_cosinor_analysis.py index c80b55b..0f33d57 100644 --- a/src/cosinor_lite/livecell_cosinor_analysis.py +++ b/src/cosinor_lite/livecell_cosinor_analysis.py @@ -687,13 +687,12 @@ def fit_cosinor( # noqa: PLR0913 x_fit = x_valid[range_mask] y_fit = y_valid[range_mask] - x_processed, y_processed = self.get_trend( + x_processed, _y_trend, y_detrended = self.get_trend( x_fit, y_fit, method=method, window=window, ) - y_detrended = y_fit - y_processed + np.mean(y_fit) if plot_style == "scatter": ax.scatter(x_processed, y_detrended, s=4, alpha=0.8, color=color) diff --git a/src/cosinor_lite/livecell_dataset.py b/src/cosinor_lite/livecell_dataset.py index 2f45d8b..45bcbb5 100644 --- a/src/cosinor_lite/livecell_dataset.py +++ b/src/cosinor_lite/livecell_dataset.py @@ -167,7 +167,7 @@ def get_group2_ids_replicates_data(self) -> tuple[list[str], list[int], np.ndarr data = self.time_series[:, mask] return ids, replicates, data - def linear_trend(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + def linear_trend(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Fits a linear regression model to the provided data and returns the input x values along with the predicted linear trend. @@ -180,15 +180,16 @@ def linear_trend(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.nda Returns ------- - tuple[np.ndarray, np.ndarray] - A tuple containing the original x values and the predicted linear fit values. + tuple[np.ndarray, np.ndarray, np.ndarray] + A tuple containing the original x values, the predicted linear fit values, and the detrended y values. """ model = sm.OLS(y, sm.add_constant(x)).fit() linear_fit = model.predict(sm.add_constant(x)) - return x, linear_fit + y_detrended = y - linear_fit + np.mean(y) + return x, linear_fit, y_detrended - def poly2_trend(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + def poly2_trend(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Fits a second-degree polynomial (quadratic) trend to the given data. @@ -197,16 +198,23 @@ def poly2_trend(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndar y (np.ndarray): 1D array of dependent variable values. Returns: - tuple[np.ndarray, np.ndarray]: + tuple[np.ndarray, np.ndarray, np.ndarray]: - x: The input array of independent variable values. - poly_fit: The fitted quadratic values corresponding to x. + - y_detrended: The detrended y values after removing the polynomial fit. """ coeffs = np.polyfit(x, y, 2) poly_fit = np.polyval(coeffs, x) - return x, poly_fit + y_detrended = y - poly_fit + np.mean(y) + return x, poly_fit, y_detrended - def moving_average_trend(self, x: np.ndarray, y: np.ndarray, window: int = 5) -> tuple[np.ndarray, np.ndarray]: + def moving_average_trend( + self, + x: np.ndarray, + y: np.ndarray, + window: int = 5, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Computes the moving average trend of the input data using a specified window size. @@ -225,6 +233,7 @@ def moving_average_trend(self, x: np.ndarray, y: np.ndarray, window: int = 5) -> A tuple containing: - x values corresponding to valid (finite) moving average points. - The moving average values (trend) for the valid points. + - The detrended y values for the valid points. Raises ------ @@ -238,7 +247,8 @@ def moving_average_trend(self, x: np.ndarray, y: np.ndarray, window: int = 5) -> y_series = pd.Series(y) ma_fit = y_series.rolling(window=window, center=True).mean().to_numpy() good = np.isfinite(x) & np.isfinite(ma_fit) - return x[good], ma_fit[good] + y_detrended = y[good] - ma_fit[good] + np.mean(y[good]) + return x[good], ma_fit[good], y_detrended def get_trend( self, @@ -246,7 +256,7 @@ def get_trend( y: np.ndarray, method: str = "linear", window: int = 5, - ) -> tuple[np.ndarray, np.ndarray]: + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Computes the trend of the given data using the specified method. @@ -272,6 +282,7 @@ def get_trend( A tuple containing: - The x values (possibly unchanged). - The trend values corresponding to y. + - The detrended y values. Raises ------ @@ -280,7 +291,7 @@ def get_trend( """ if method == "none": - return np.asarray(x, float), np.zeros_like(np.asarray(y, float)) + return np.asarray(x, float), np.zeros_like(np.asarray(y, float)), np.asarray(y, float) if method == "linear": return self.linear_trend(x, y) if method == "poly2": @@ -365,7 +376,7 @@ def plot_group_data( x_fit = x[valid_mask] y_fit = y[valid_mask] - x_processed, y_processed = self.get_trend( + x_processed, trend, _y_detrended = self.get_trend( x_fit, y_fit, method=method, @@ -374,7 +385,7 @@ def plot_group_data( if method != "none": ax.plot( x_processed, - y_processed, + trend, color="black", linestyle="--", linewidth=0.8, diff --git a/tests/unit_tests/test_livecell_dataset.py b/tests/unit_tests/test_livecell_dataset.py index f5f5191..2ffb67a 100644 --- a/tests/unit_tests/test_livecell_dataset.py +++ b/tests/unit_tests/test_livecell_dataset.py @@ -67,9 +67,11 @@ def test_linear_trend(bioluminescence_dataset: LiveCellDataset) -> None: mask = np.isfinite(x) & np.isfinite(y) x_valid = x[mask] y_valid = y[mask] - x_fit, y_fit = ds.linear_trend(x_valid, y_valid) + x_fit, y_fit, y_detrended = ds.linear_trend(x_valid, y_valid) assert x_fit.shape == y_fit.shape == x_valid.shape # noqa: S101 assert np.all(np.isfinite(y_fit)) # noqa: S101 + assert y_detrended.shape == y_valid.shape # noqa: S101 + assert np.all(np.isfinite(y_detrended)) # noqa: S101 def test_poly2_trend(bioluminescence_dataset: LiveCellDataset) -> None: @@ -79,10 +81,12 @@ def test_poly2_trend(bioluminescence_dataset: LiveCellDataset) -> None: mask = np.isfinite(x) & np.isfinite(y) x_valid = x[mask] y_valid = y[mask] - x_fit, y_fit = ds.poly2_trend(x_valid, y_valid) + x_fit, y_fit, y_detrended = ds.poly2_trend(x_valid, y_valid) expected = np.polyval(np.polyfit(x_valid, y_valid, 2), x_valid) assert x_fit.shape == y_fit.shape == x_valid.shape # noqa: S101 assert np.allclose(y_fit, expected) # noqa: S101 + assert y_detrended.shape == y_valid.shape # noqa: S101 + assert np.all(np.isfinite(y_detrended)) # noqa: S101 def test_moving_average_trend(bioluminescence_dataset: LiveCellDataset) -> None: @@ -90,10 +94,12 @@ def test_moving_average_trend(bioluminescence_dataset: LiveCellDataset) -> None: x = ds.time y = ds.time_series[:, 0] mask = np.isfinite(x) & np.isfinite(y) - x_ma, y_ma = ds.moving_average_trend(x[mask], y[mask], window=3) + x_ma, y_ma, y_detrended = ds.moving_average_trend(x[mask], y[mask], window=3) assert len(x_ma) == len(y_ma) # noqa: S101 assert len(x_ma) > 0 # noqa: S101 assert np.all(np.isfinite(y_ma)) # noqa: S101 + assert y_detrended.shape == y_ma.shape # noqa: S101 + assert np.all(np.isfinite(y_detrended)) # noqa: S101 def test_moving_average_trend_invalid_window(bioluminescence_dataset: LiveCellDataset) -> None: @@ -112,10 +118,12 @@ def test_get_trend_methods(method: str, bioluminescence_dataset: LiveCellDataset mask = np.isfinite(x) & np.isfinite(y) x_valid = x[mask] y_valid = y[mask] - x_out, y_out = ds.get_trend(x_valid, y_valid, method=method, window=3) + x_out, y_out, y_detrended = ds.get_trend(x_valid, y_valid, method=method, window=3) assert isinstance(x_out, np.ndarray) # noqa: S101 assert isinstance(y_out, np.ndarray) # noqa: S101 assert x_out.shape == y_out.shape # noqa: S101 + assert isinstance(y_detrended, np.ndarray) # noqa: S101 + assert y_detrended.shape == y_out.shape # noqa: S101 if method != "moving_average": assert x_out.shape == x_valid.shape # noqa: S101 else: