Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub (part 2)
Browse files- tests/test_expression_recovery.R +51 -0
- tests/test_geo_recovery.py +95 -0
tests/test_expression_recovery.R
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Execute the shipped recovery scripts and compare against direct package results.
|
| 2 |
+
suppressPackageStartupMessages({library(limma); library(tximport); library(DESeq2)})
|
| 3 |
+
folder <- tempfile("recovery-test-"); dir.create(folder)
|
| 4 |
+
templates <- normalizePath("app/rnaseq/recovery_templates")
|
| 5 |
+
set.seed(819)
|
| 6 |
+
samples <- paste0("s", 1:6)
|
| 7 |
+
meta <- data.frame(sample = samples, condition = rep(c("control", "test"), each = 3), experimental_unit = samples)
|
| 8 |
+
metadata <- file.path(folder, "metadata.tsv")
|
| 9 |
+
write.table(meta, metadata, sep = "\t", quote = FALSE, row.names = FALSE)
|
| 10 |
+
expression <- matrix(exp(rnorm(1200, mean = 3, sd = 0.4)), nrow = 200, dimnames = list(paste0("g", 1:200), samples))
|
| 11 |
+
expression[1:20, 4:6] <- expression[1:20, 4:6] * 3
|
| 12 |
+
path <- file.path(folder, "expression.tsv")
|
| 13 |
+
write.table(data.frame(gene = rownames(expression), expression), path, sep = "\t", quote = FALSE, row.names = FALSE)
|
| 14 |
+
out <- file.path(folder, "limma")
|
| 15 |
+
status <- system2(file.path(R.home("bin"), "Rscript"), c(file.path(templates, "limma_trend.R"), path, metadata, "control", "test", "FPKM", out))
|
| 16 |
+
stopifnot(status == 0)
|
| 17 |
+
observed <- read.delim(file.path(out, "limma_trend_results.tsv"))
|
| 18 |
+
expected <- topTable(eBayes(lmFit(log2(expression + 1), model.matrix(~factor(meta$condition, levels = c("control", "test")))), trend = TRUE), coef = 2, number = Inf, sort.by = "none")
|
| 19 |
+
stopifnot(max(abs(observed$logFC - expected$logFC)) < 1e-10, max(abs(observed$adj.P.Val - expected$adj.P.Val)) < 1e-10)
|
| 20 |
+
stopifnot(all(file.exists(file.path(out, paste0("volcano.", c("svg", "pdf", "png"))))))
|
| 21 |
+
|
| 22 |
+
# Genuine Salmon file shape with fractional estimated counts and sample-dependent lengths.
|
| 23 |
+
files <- setNames(file.path(folder, paste0(samples, ".sf")), samples)
|
| 24 |
+
for (i in seq_along(files)) {
|
| 25 |
+
counts <- rnbinom(200, mu = if (i > 3) c(rep(350, 20), rep(100, 180)) else rep(100, 200), size = 12) + 0.25
|
| 26 |
+
length <- seq(700, 1695, by = 5)
|
| 27 |
+
effective <- length - 120 + i * 3
|
| 28 |
+
abundance <- counts / effective
|
| 29 |
+
q <- data.frame(Name = paste0("tx", 1:200), Length = length, EffectiveLength = effective, TPM = abundance / sum(abundance) * 1e6, NumReads = counts)
|
| 30 |
+
write.table(q, files[i], sep = "\t", quote = FALSE, row.names = FALSE)
|
| 31 |
+
}
|
| 32 |
+
manifest <- file.path(folder, "quantfiles.tsv"); mapping <- file.path(folder, "tx2gene.tsv")
|
| 33 |
+
write.table(data.frame(sample = samples, quant_file = unname(files)), manifest, sep = "\t", quote = FALSE, row.names = FALSE)
|
| 34 |
+
tx2gene <- data.frame(transcript = paste0("tx", 1:200), gene = paste0("g", 1:200))
|
| 35 |
+
write.table(tx2gene, mapping, sep = "\t", quote = FALSE, row.names = FALSE)
|
| 36 |
+
txout <- file.path(folder, "tximport")
|
| 37 |
+
status <- system2(file.path(R.home("bin"), "Rscript"), c(file.path(templates, "tximport_deseq2.R"), manifest, mapping, metadata, "control", "test", txout))
|
| 38 |
+
stopifnot(status == 0)
|
| 39 |
+
observed_dds <- readRDS(file.path(txout, "dds_with_length_offsets.rds"))
|
| 40 |
+
txi <- tximport(files, type = "salmon", tx2gene = tx2gene)
|
| 41 |
+
meta$condition <- factor(meta$condition, levels = c("control", "test")); rownames(meta) <- meta$sample
|
| 42 |
+
expected_dds <- DESeq(DESeqDataSetFromTximport(txi, meta, ~condition))
|
| 43 |
+
stopifnot(isTRUE(all.equal(normalizationFactors(observed_dds), normalizationFactors(expected_dds))), isTRUE(all.equal(counts(observed_dds), counts(expected_dds))))
|
| 44 |
+
observed <- read.delim(file.path(txout, "deseq2_tximport_results.tsv"))
|
| 45 |
+
expected <- as.data.frame(results(expected_dds, contrast = c("condition", "test", "control"), alpha = 0.05))
|
| 46 |
+
stopifnot(isTRUE(all.equal(observed$log2FoldChange, expected$log2FoldChange)), isTRUE(all.equal(observed$padj, expected$padj)))
|
| 47 |
+
source(file.path(templates, "review_design.R"))
|
| 48 |
+
meta$experimental_unit[2] <- meta$experimental_unit[1]
|
| 49 |
+
write.table(meta, metadata, sep = "\t", quote = FALSE, row.names = FALSE)
|
| 50 |
+
stopifnot(inherits(try(review_design(metadata, samples, "control", "test"), silent = TRUE), "try-error"))
|
| 51 |
+
cat("Expression recovery known-answer tests passed\n")
|
tests/test_geo_recovery.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import csv
|
| 3 |
+
import hashlib
|
| 4 |
+
import importlib.util
|
| 5 |
+
import io
|
| 6 |
+
import zipfile
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from app.rnaseq.geo_counts import GeoCountsError, fetch_matrix, fetch_series
|
| 12 |
+
from app.rnaseq.geo_recovery import TEMPLATES, recovery_bundle
|
| 13 |
+
from app.routers import geo_search
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
SERIES = """^SERIES = GSE1
|
| 17 |
+
!Series_type = Expression profiling by high throughput sequencing
|
| 18 |
+
!Series_title = A normalized study
|
| 19 |
+
!Series_overall_design = Review groups
|
| 20 |
+
!Series_contact_email = researcher@example.org
|
| 21 |
+
!Series_relation = BioProject: https://www.ncbi.nlm.nih.gov/bioproject/PRJNA123
|
| 22 |
+
!Series_sample_id = GSM1
|
| 23 |
+
!Series_sample_id = GSM2
|
| 24 |
+
!Series_sample_id = GSM3
|
| 25 |
+
!Series_sample_id = GSM4
|
| 26 |
+
!Series_supplementary_file = ftp://ftp.ncbi.nlm.nih.gov/geo/series/GSEnnn/GSE1/suppl/expression_FPKM.tsv
|
| 27 |
+
"""
|
| 28 |
+
SAMPLES = "\n".join(f"^SAMPLE = GSM{i}\n!Sample_title = sample{i}\n!Sample_supplementary_file_1 = ftp://ftp.ncbi.nlm.nih.gov/geo/samples/GSMnnn/GSM{i}/suppl/raw_counts.tsv" for i in range(1, 5))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def study():
|
| 32 |
+
def respond(request):
|
| 33 |
+
return httpx.Response(200, text=SERIES if request.url.params["targ"] == "self" else SAMPLES)
|
| 34 |
+
async def run():
|
| 35 |
+
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
|
| 36 |
+
return await fetch_series(client, "GSE1")
|
| 37 |
+
return asyncio.run(run())
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_sample_level_recovery_preserves_sources_without_automatic_merge():
|
| 41 |
+
series = study()
|
| 42 |
+
assert len(series["files"]) == 5
|
| 43 |
+
assert series["files"][0]["analysis_eligible"] is False
|
| 44 |
+
assert series["raw_candidates"] == [f"GSM{i}/raw_counts.tsv" for i in range(1, 5)]
|
| 45 |
+
assert series["read_projects"] == ["PRJNA123"]
|
| 46 |
+
# A complete deposited matrix from a sample supplement can be validated.
|
| 47 |
+
matrix = "gene\tGSM1\tGSM2\tGSM3\tGSM4\ng1\t1\t2\t3\t4\ng2\t5\t6\t7\t8\n"
|
| 48 |
+
async def run():
|
| 49 |
+
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200, text=matrix))) as client:
|
| 50 |
+
return await fetch_matrix(client, series, "GSM1/raw_counts.tsv")
|
| 51 |
+
result, source = asyncio.run(run())
|
| 52 |
+
assert result["columns"] == ["GSM1", "GSM2", "GSM3", "GSM4"]
|
| 53 |
+
assert "/geo/samples/" in source
|
| 54 |
+
assert series["files"][1]["name"] == "GSM1/raw_counts.tsv"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_bundle_includes_source_identity_templates_and_verified_checksums():
|
| 58 |
+
archive = zipfile.ZipFile(io.BytesIO(recovery_bundle(study())))
|
| 59 |
+
assert {"source_record.json", "metadata_to_review.tsv", "author_request_draft.txt", "featurecounts.sh", "featurecounts_to_matrix.py", "tximport_deseq2.R", "limma_trend.R", "review_design.R"}.issubset(archive.namelist())
|
| 60 |
+
for line in archive.read("checksums.sha256").decode().splitlines():
|
| 61 |
+
checksum, name = line.split(" ")
|
| 62 |
+
assert hashlib.sha256(archive.read(name)).hexdigest() == checksum
|
| 63 |
+
rows = list(csv.DictReader(io.StringIO(archive.read("metadata_to_review.tsv").decode()), delimiter="\t"))
|
| 64 |
+
assert len(rows) == 4 and all(not row["condition"] and not row["experimental_unit"] for row in rows)
|
| 65 |
+
assert "researcher@example.org" in archive.read("author_request_draft.txt").decode()
|
| 66 |
+
assert all("/" not in name and ".." not in name for name in archive.namelist())
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_recovery_endpoint_returns_zip(monkeypatch):
|
| 70 |
+
snapshot = study()
|
| 71 |
+
async def fake(client, accession):
|
| 72 |
+
return snapshot
|
| 73 |
+
monkeypatch.setattr(geo_search, "fetch_series", fake)
|
| 74 |
+
response = asyncio.run(geo_search.download_geo_recovery("GSE1"))
|
| 75 |
+
assert response.media_type == "application/zip"
|
| 76 |
+
assert response.headers["content-disposition"].endswith('GSE1_expression_recovery.zip"')
|
| 77 |
+
assert zipfile.is_zipfile(io.BytesIO(response.body))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_wrong_assay_cannot_download_expression_instructions():
|
| 81 |
+
with pytest.raises(GeoCountsError, match="CUT"):
|
| 82 |
+
recovery_bundle({"workflow_issue": "CUT&Tag requires a different workflow"})
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_featurecounts_converter_preserves_integers_and_explicit_column_mapping(tmp_path):
|
| 86 |
+
spec = importlib.util.spec_from_file_location("count_converter", TEMPLATES / "featurecounts_to_matrix.py")
|
| 87 |
+
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
| 88 |
+
native = tmp_path / "counts.tsv"; mapping = tmp_path / "bam_samples.tsv"; output = tmp_path / "raw.tsv"
|
| 89 |
+
native.write_text("# Program: featureCounts\nGeneid\tChr\tStart\tEnd\tStrand\tLength\tb.bam\ta.bam\tc.bam\td.bam\ng1\t1\t1\t10\t+\t10\t1\t2\t3\t4\ng2\t1\t20\t40\t-\t20\t5\t6\t7\t8\n")
|
| 90 |
+
mapping.write_text("bam\tsample\na.bam\tGSM1\nb.bam\tGSM2\nc.bam\tGSM3\nd.bam\tGSM4\n")
|
| 91 |
+
module.convert(native, mapping, output)
|
| 92 |
+
assert output.read_text().splitlines() == ["gene\tGSM2\tGSM1\tGSM3\tGSM4", "g1\t1\t2\t3\t4", "g2\t5\t6\t7\t8"]
|
| 93 |
+
native.write_text(native.read_text().replace("\t1\t2\t3\t4", "\t1.5\t2\t3\t4"))
|
| 94 |
+
with pytest.raises(ValueError, match="integer"):
|
| 95 |
+
module.convert(native, mapping, output)
|