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
61 changes: 32 additions & 29 deletions .github/workflows/ci-test.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion notebooks/live_cell_tutorial.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
")"
Expand Down
3 changes: 1 addition & 2 deletions src/cosinor_lite/livecell_cosinor_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 24 additions & 13 deletions src/cosinor_lite/livecell_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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.

Expand All @@ -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
------
Expand All @@ -238,15 +247,16 @@ 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,
x: np.ndarray,
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.

Expand All @@ -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
------
Expand All @@ -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":
Expand Down Expand Up @@ -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,
Expand All @@ -374,7 +385,7 @@ def plot_group_data(
if method != "none":
ax.plot(
x_processed,
y_processed,
trend,
color="black",
linestyle="--",
linewidth=0.8,
Expand Down
16 changes: 12 additions & 4 deletions tests/unit_tests/test_livecell_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -79,21 +81,25 @@ 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:
ds = bioluminescence_dataset
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:
Expand All @@ -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:
Expand Down