Skip to content
Open
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
146 changes: 146 additions & 0 deletions coverage.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?xml version="1.0" ?>
<coverage version="7.13.3" timestamp="1781269113809" lines-valid="127" lines-covered="119" line-rate="0.937" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.13.3 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources>
<source>/mnt/data/figure2a_fair_project/src</source>
</sources>
<packages>
<package name="." line-rate="0.937" branch-rate="0" complexity="0">
<classes>
<class name="reproduce_figure_2a.py" filename="reproduce_figure_2a.py" complexity="0" line-rate="0.937" branch-rate="0">
<methods/>
<lines>
<line number="7" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="14" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="27" hits="1"/>
<line number="28" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="33" hits="1"/>
<line number="61" hits="1"/>
<line number="62" hits="1"/>
<line number="63" hits="1"/>
<line number="64" hits="1"/>
<line number="65" hits="1"/>
<line number="66" hits="1"/>
<line number="68" hits="1"/>
<line number="69" hits="1"/>
<line number="70" hits="1"/>
<line number="73" hits="1"/>
<line number="91" hits="1"/>
<line number="92" hits="1"/>
<line number="93" hits="1"/>
<line number="94" hits="1"/>
<line number="95" hits="1"/>
<line number="96" hits="0"/>
<line number="98" hits="1"/>
<line number="99" hits="1"/>
<line number="100" hits="1"/>
<line number="101" hits="1"/>
<line number="102" hits="1"/>
<line number="105" hits="1"/>
<line number="131" hits="1"/>
<line number="132" hits="1"/>
<line number="133" hits="1"/>
<line number="135" hits="1"/>
<line number="136" hits="1"/>
<line number="140" hits="1"/>
<line number="141" hits="1"/>
<line number="142" hits="0"/>
<line number="143" hits="0"/>
<line number="145" hits="1"/>
<line number="148" hits="1"/>
<line number="174" hits="1"/>
<line number="175" hits="1"/>
<line number="176" hits="1"/>
<line number="178" hits="1"/>
<line number="179" hits="1"/>
<line number="180" hits="0"/>
<line number="181" hits="0"/>
<line number="183" hits="1"/>
<line number="184" hits="1"/>
<line number="185" hits="1"/>
<line number="187" hits="1"/>
<line number="188" hits="1"/>
<line number="189" hits="1"/>
<line number="192" hits="1"/>
<line number="216" hits="1"/>
<line number="220" hits="1"/>
<line number="223" hits="1"/>
<line number="231" hits="1"/>
<line number="238" hits="1"/>
<line number="277" hits="1"/>
<line number="278" hits="0"/>
<line number="279" hits="0"/>
<line number="280" hits="1"/>
<line number="281" hits="1"/>
<line number="282" hits="1"/>
<line number="284" hits="1"/>
<line number="285" hits="1"/>
<line number="287" hits="1"/>
<line number="288" hits="1"/>
<line number="298" hits="1"/>
<line number="301" hits="1"/>
<line number="302" hits="1"/>
<line number="303" hits="1"/>
<line number="304" hits="1"/>
<line number="306" hits="1"/>
<line number="307" hits="1"/>
<line number="308" hits="1"/>
<line number="310" hits="1"/>
<line number="311" hits="1"/>
<line number="312" hits="1"/>
<line number="313" hits="1"/>
<line number="315" hits="1"/>
<line number="316" hits="1"/>
<line number="318" hits="1"/>
<line number="319" hits="1"/>
<line number="330" hits="1"/>
<line number="331" hits="1"/>
<line number="332" hits="1"/>
<line number="335" hits="1"/>
<line number="366" hits="1"/>
<line number="367" hits="1"/>
<line number="368" hits="1"/>
<line number="369" hits="1"/>
<line number="370" hits="1"/>
<line number="376" hits="1"/>
<line number="377" hits="1"/>
<line number="380" hits="1"/>
<line number="388" hits="1"/>
<line number="391" hits="1"/>
<line number="397" hits="1"/>
<line number="403" hits="1"/>
<line number="408" hits="1"/>
<line number="414" hits="1"/>
<line number="419" hits="1"/>
<line number="422" hits="1"/>
<line number="435" hits="1"/>
<line number="436" hits="1"/>
<line number="437" hits="1"/>
<line number="444" hits="1"/>
<line number="445" hits="1"/>
<line number="448" hits="1"/>
<line number="449" hits="0"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>
38 changes: 38 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[project]
name = "figure2a-reproduction"
version = "0.1.0"
description = "Reproduce Figure 2A from Kelliher et al. 2016 with a FAIR Python workflow."
channels = ["conda-forge"]
platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"]

[dependencies]
python = ">=3.10,<3.13"
pandas = ">=2.2"
openpyxl = ">=3.1"
numpy = ">=1.26"
matplotlib = ">=3.8"

[feature.dev.dependencies]
ruff = ">=0.6"
pyright = ">=1.1"
pytest = ">=8.0"
pytest-cov = ">=5.0"
safety = ">=3.2"
trivy = ">=0.56"

[environments]
default = { features = ["default"] }
dev = { features = ["default", "dev"] }

[tasks]
run-figure = "python src/reproduce_figure_2a.py --input data/pgen.1006453.s002.xlsx --output results/figure_2A_reproduced.png"

[feature.dev.tasks]
lint = "ruff check ."
format = "ruff format ."
typecheck = "pyright src/reproduce_figure_2a.py tests"
test = "pytest"
coverage = "pytest --cov=src --cov-report=term-missing"
safety-check = "safety check"
trivy-check = "trivy fs --scanners secret,vuln,misconfig ."
all-checks = { depends-on = ["lint", "typecheck", "test"] }
29 changes: 29 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[project]
name = "figure2a-reproduction"
version = "0.1.0"
description = "Reproducible Python project to recreate Figure 2A from Kelliher et al. 2016."
authors = [{ name = "Chaker Aloui", email = "aloui-choko@hotmail.fr" }]
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
keywords = ["bioinformatics", "transcriptomics", "reproducibility", "figure"]

[tool.ruff]
line-length = 88
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "D", "N", "UP", "B", "SIM"]
ignore = ["D203", "D212"]

[tool.ruff.lint.pydocstyle]
convention = "numpy"

[tool.pyright]
pythonVersion = "3.10"
typeCheckingMode = "basic"
include = ["src", "tests"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-report=term-missing --cov-report=xml"
Binary file added results/figure_2A_reproduced.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
158 changes: 158 additions & 0 deletions tests/test_reproduce_figure_2a.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Unit tests for the Figure 2A reproduction module."""

from __future__ import annotations

import sys
from pathlib import Path

import numpy as np
import pandas as pd
import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

from reproduce_figure_2a import ( # noqa: E402
build_expression_matrix,
compute_row_z_scores,
get_time_columns,
plot_figure_2a,
select_and_order_periodic_genes,
)


def make_test_dataframe() -> pd.DataFrame:
"""Create a small expression table with Figure 2A-like columns."""
return pd.DataFrame(
{
"gene": ["gene_a", "gene_b", "gene_c"],
"Figure2A_order_peaktime": [2, np.nan, 1],
0: [1.0, 2.0, 4.0],
5: [2.0, 3.0, 4.0],
10: [3.0, 4.0, 4.0],
},
)


def test_get_time_columns_returns_sorted_numeric_columns() -> None:
"""Check that numeric time columns are detected and sorted."""
dataframe = make_test_dataframe()
assert get_time_columns(dataframe) == [0, 5, 10]


def test_get_time_columns_raises_without_numeric_columns() -> None:
"""Check that missing time columns raise a clear error."""
dataframe = pd.DataFrame({"gene": ["gene_a"]})
with pytest.raises(ValueError, match="No numeric time columns"):
get_time_columns(dataframe)


def test_select_and_order_periodic_genes_uses_descending_order() -> None:
"""Check the descending sort needed to match the published orientation."""
dataframe = make_test_dataframe()
ordered = select_and_order_periodic_genes(dataframe)
assert ordered["gene"].to_list() == ["gene_a", "gene_c"]


def test_select_and_order_periodic_genes_raises_for_missing_column() -> None:
"""Check that a missing order column is rejected."""
dataframe = pd.DataFrame({"gene": ["gene_a"], 0: [1.0]})
with pytest.raises(ValueError, match="Required order column"):
select_and_order_periodic_genes(dataframe)


def test_compute_row_z_scores_nominal_case() -> None:
"""Check row-wise z-score normalization on non-constant rows."""
result = compute_row_z_scores([[1.0, 2.0, 3.0]])
expected = np.array([[-1.22474487, 0.0, 1.22474487]])
np.testing.assert_allclose(result, expected)


def test_compute_row_z_scores_constant_row_becomes_zero() -> None:
"""Check that constant rows do not produce NaN values."""
result = compute_row_z_scores([[4.0, 4.0, 4.0]])
np.testing.assert_allclose(result, np.zeros((1, 3)))


def test_compute_row_z_scores_rejects_invalid_bounds() -> None:
"""Check that invalid plotting bounds raise an error."""
with pytest.raises(ValueError, match="vmin must be lower"):
compute_row_z_scores([[1.0, 2.0]], vmin=1.0, vmax=1.0)


def test_build_expression_matrix_shape() -> None:
"""Check that expression extraction returns the expected shape."""
dataframe = make_test_dataframe()
ordered = select_and_order_periodic_genes(dataframe)
matrix = build_expression_matrix(ordered, [0, 5, 10])
assert matrix.shape == (2, 3)


def test_plot_figure_2a_writes_png(tmp_path: Path) -> None:
"""Check that the plotting function creates an output file."""
output_path = tmp_path / "figure.png"
z_scores = np.array([[0.0, 1.0, -1.0], [1.0, 0.0, -1.0]])
figure = plot_figure_2a(z_scores, [0, 50, 100], output_path, dpi=80)
assert output_path.exists()
assert figure.axes[0].get_ylabel() == "Top Periodic Genes (2)"


def test_plot_figure_2a_rejects_dimension_mismatch(tmp_path: Path) -> None:
"""Check that inconsistent matrix and time dimensions are rejected."""
with pytest.raises(ValueError, match="Number of matrix columns"):
plot_figure_2a(np.array([[1.0, 2.0]]), [0], tmp_path / "figure.png")

from reproduce_figure_2a import ( # noqa: E402
build_argument_parser,
main,
read_expression_table,
reproduce_figure_2a,
)


def test_read_expression_table_rejects_negative_header(tmp_path: Path) -> None:
"""Check that a negative header row is rejected before file reading."""
with pytest.raises(ValueError, match="header_row"):
read_expression_table(tmp_path / "missing.xlsx", header_row=-1)


def test_read_expression_table_rejects_missing_file(tmp_path: Path) -> None:
"""Check that a missing input file raises FileNotFoundError."""
with pytest.raises(FileNotFoundError, match="Input Excel file not found"):
read_expression_table(tmp_path / "missing.xlsx")


def test_reproduce_figure_2a_end_to_end_with_small_excel(tmp_path: Path) -> None:
"""Check the complete workflow on a minimal Excel fixture."""
input_path = tmp_path / "input.xlsx"
output_path = tmp_path / "figure.png"
dataframe = make_test_dataframe()
with pd.ExcelWriter(input_path) as writer:
dataframe.to_excel(writer, sheet_name="Sheet1", index=False, startrow=2)

result_path = reproduce_figure_2a(input_path, output_path)

assert result_path == output_path
assert output_path.exists()


def test_build_argument_parser_defaults() -> None:
"""Check that the CLI parser has usable default arguments."""
parser = build_argument_parser()
args = parser.parse_args([])
assert args.input.name == "pgen.1006453.s002.xlsx"
assert args.output.name == "figure_2A_reproduced.png"
assert not args.no_panel_label


def test_main_returns_zero_with_small_excel(tmp_path: Path) -> None:
"""Check that the command-line entry point succeeds on valid inputs."""
input_path = tmp_path / "input.xlsx"
output_path = tmp_path / "figure.png"
dataframe = make_test_dataframe()
with pd.ExcelWriter(input_path) as writer:
dataframe.to_excel(writer, sheet_name="Sheet1", index=False, startrow=2)

status = main(["--input", str(input_path), "--output", str(output_path)])

assert status == 0
assert output_path.exists()
Loading