Files

1059 lines
42 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
"""RNA-seq context helpers for manifest/status compatibility.
RNA-seq stages operate at experiment/sample scope first. A gene-family member
set can be layered on top for DE/enrichment/expression foreground analyses, but
it is not the same identity as the core RNA-seq experiment.
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Mapping
try:
import yaml # type: ignore
except Exception: # pragma: no cover
yaml = None
def _clean_token(value: Any) -> str:
text = str(value or "").strip()
text = re.sub(r"[^A-Za-z0-9_.-]+", "_", text)
return text.strip("._-") or "unknown"
def species_from_prefix(prefix: str) -> str:
prefix = str(prefix or "").strip()
if not prefix:
return "unknown"
return re.split(r"[._]", prefix, maxsplit=1)[0] or prefix
GENERIC_RNASEQ_PREFIXES = {"", "at", "ath", "target", "multi", "rnaseq", "genome", "genome_prepare"}
def _clean_project_value(value: Any) -> str:
text = str(value or "").strip()
return "" if text.lower() in {"", "none", "null", "undefined", "nan", "-"} else text
def _is_gene_family_like_prefix(value: str) -> bool:
value = _clean_project_value(value)
return "." in value or value.lower() in {"target", "multi"}
def _looks_like_reference_version(value: str) -> bool:
text = _clean_project_value(value)
if not text:
return False
lowered = text.lower()
if lowered in GENERIC_RNASEQ_PREFIXES:
return True
if "_" in text and re.search(r"(?:^|[_-])r\d+(?:$|[_-])", lowered):
return True
if re.search(r"\d", text) and re.search(r"(asm|assembly|genome|repr|models)", lowered):
return True
return False
def _species_meta_from_mapping(meta: Any) -> dict[str, str]:
out: dict[str, str] = {}
if isinstance(meta, Mapping):
species_block = meta.get("species") if isinstance(meta.get("species"), Mapping) else {}
rnaseq_block = meta.get("rnaseq") if isinstance(meta.get("rnaseq"), Mapping) else {}
reference_block = meta.get("reference") if isinstance(meta.get("reference"), Mapping) else {}
out["species_code"] = _clean_project_value(
meta.get("speciesCode")
or meta.get("species_code")
or meta.get("organismCode")
or meta.get("organism_code")
or species_block.get("code")
or species_block.get("speciesCode")
or species_block.get("species_code")
or species_block.get("organismCode")
or species_block.get("organism_code")
)
out["species_name"] = _clean_project_value(
meta.get("speciesName")
or meta.get("species_name")
or meta.get("latin_name")
or species_block.get("speciesName")
or species_block.get("species_name")
or species_block.get("latin_name")
or species_block.get("name")
)
out["kegg_organism"] = _clean_project_value(
meta.get("keggOrganism")
or meta.get("kegg_organism")
or meta.get("kegg")
or species_block.get("keggOrganism")
or species_block.get("kegg_organism")
or species_block.get("kegg")
).lower()
out["output_prefix"] = _clean_project_value(
meta.get("rnaseqPrefix")
or meta.get("rnaseq_prefix")
or meta.get("outputPrefix")
or meta.get("output_prefix")
or rnaseq_block.get("outputPrefix")
or rnaseq_block.get("output_prefix")
or rnaseq_block.get("prefix")
)
out["reference_id"] = _clean_project_value(
meta.get("referenceId")
or meta.get("reference_id")
or reference_block.get("id")
or reference_block.get("referenceId")
or reference_block.get("reference_id")
)
elif isinstance(meta, str):
out["species_code"] = _clean_project_value(meta)
return {k: v for k, v in out.items() if v}
def _as_clean_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
parts = re.split(r"[,;,;/]+", value)
elif isinstance(value, (list, tuple, set)):
parts = list(value)
else:
parts = [value]
out: list[str] = []
for item in parts:
text = _clean_project_value(item)
if text and text not in out:
out.append(text)
return out
def _looks_numeric(value: Any) -> bool:
text = _clean_project_value(value)
if not text:
return False
try:
float(text)
return True
except Exception:
return False
def _experiment_meta_from_mapping(meta: Any) -> dict[str, Any]:
"""Extract optional job-level biological context without touching sample groups."""
if not isinstance(meta, Mapping):
return {}
experiment = meta.get("experiment") if isinstance(meta.get("experiment"), Mapping) else {}
rnaseq = meta.get("rnaseq") if isinstance(meta.get("rnaseq"), Mapping) else {}
out: dict[str, Any] = {}
out["treatment"] = _clean_project_value(
meta.get("treatment")
or meta.get("stress")
or experiment.get("treatment")
or experiment.get("stress")
or rnaseq.get("treatment")
)
out["trait"] = _clean_project_value(
meta.get("trait")
or experiment.get("trait")
or rnaseq.get("trait")
)
out["design"] = _clean_project_value(
meta.get("design")
or experiment.get("design")
or rnaseq.get("design")
)
out["primary_axis"] = _clean_project_value(
meta.get("primary_axis")
or experiment.get("primary_axis")
or experiment.get("ordered_axis")
or rnaseq.get("primary_axis")
)
out["control_condition"] = _clean_project_value(
meta.get("control_condition")
or meta.get("control_group")
or experiment.get("control_condition")
or experiment.get("control_group")
or rnaseq.get("control_condition")
)
keywords = []
for key in ("treatment_keywords", "keywords", "context_keywords"):
keywords.extend(_as_clean_list(meta.get(key)))
keywords.extend(_as_clean_list(experiment.get(key)))
keywords.extend(_as_clean_list(rnaseq.get(key)))
if out["treatment"]:
keywords.insert(0, out["treatment"])
out["treatment_keywords"] = list(dict.fromkeys([x for x in keywords if x]))
for key in ("stage_order", "time_order"):
values = _as_clean_list(meta.get(key)) or _as_clean_list(experiment.get(key)) or _as_clean_list(rnaseq.get(key))
if values:
out[key] = values
return {k: v for k, v in out.items() if v not in ("", [], None)}
def _sample_axis_values(config: Mapping[str, Any]) -> dict[str, list[str]]:
axes = {
"condition": [],
"time": [],
"tissue": [],
"organ_part": [],
"stage": [],
"treatment": [],
"genotype": [],
"trait": [],
"cell_type": [],
}
sample_entries = config.get("samples")
if isinstance(sample_entries, Mapping):
for _sample, meta in sample_entries.items():
if not isinstance(meta, Mapping):
continue
aliases = {
"condition": ("condition", "group"),
"time": ("time", "timepoint", "hour"),
"tissue": ("tissue", "organ"),
"organ_part": ("organ_part", "part"),
"stage": ("stage", "development_stage", "maturity"),
"treatment": ("treatment", "stress"),
"genotype": ("genotype", "cultivar", "variety", "accession"),
"trait": ("trait", "phenotype"),
"cell_type": ("cell_type", "cluster"),
}
for axis, keys in aliases.items():
for key in keys:
if key in meta and meta.get(key) not in (None, ""):
text = _clean_project_value(meta.get(key))
if text and text not in axes[axis]:
axes[axis].append(text)
break
return {axis: values for axis, values in axes.items() if values}
def infer_experiment_design(experiment_meta: Mapping[str, Any], axis_values: Mapping[str, list[str]]) -> str:
explicit = _clean_project_value(experiment_meta.get("design")).lower()
if explicit and explicit != "auto":
return explicit
axes = {axis for axis, values in axis_values.items() if values}
has_time = "time" in axes and any(_looks_numeric(v) for v in axis_values.get("time", []))
semantic_axes = axes & {"treatment", "tissue", "organ_part", "stage", "genotype", "trait", "cell_type"}
if experiment_meta.get("treatment"):
semantic_axes.add("treatment")
if experiment_meta.get("trait"):
semantic_axes.add("trait")
if has_time and ("treatment" in semantic_axes or experiment_meta.get("treatment")):
return "time_series_treatment"
if len(semantic_axes) > 1:
return "mixed"
if has_time:
return "time_series"
if "treatment" in semantic_axes:
return "treatment"
if "tissue" in semantic_axes:
return "tissue"
if "organ_part" in semantic_axes:
return "organ_part"
if "stage" in semantic_axes:
return "development"
if "genotype" in semantic_axes:
return "genotype"
if "cell_type" in semantic_axes:
return "cell_type"
return "group_comparison"
def build_experiment_context(
prefix: str,
sample_config: str | Path | None = None,
input_dir: str | Path | None = None,
final_dir: str | Path | None = None,
params: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build optional biological context for recommendation/reporting.
This context never changes DE grouping. It only records user-provided or
file-provided metadata used later for interpretation/RAG.
"""
params = params or {}
input_path = Path(input_dir) if input_dir else (Path(sample_config).parent if sample_config else None)
config = _read_sample_config(sample_config)
project = resolve_rnaseq_project_context(
input_dir=input_path,
final_dir=final_dir,
requested_prefix=prefix,
sample_config=sample_config,
params=params,
)
experiment_meta = _experiment_meta_from_mapping(config)
param_experiment = _experiment_meta_from_mapping(params)
experiment_meta.update({k: v for k, v in param_experiment.items() if v not in ("", [], None)})
axis_values = _sample_axis_values(config)
detected_axes = [axis for axis in ("treatment", "tissue", "organ_part", "stage", "time", "genotype", "trait", "cell_type", "condition") if axis in axis_values]
design = infer_experiment_design(experiment_meta, axis_values)
context_sources = {
"species": project.get("source", ""),
"experiment": "samples_yaml" if experiment_meta else "",
"sample_axes": "samples_yaml" if axis_values else "",
}
explicit_topic = any(experiment_meta.get(key) for key in ("treatment", "trait")) or any(axis in detected_axes for axis in ("tissue", "organ_part", "stage", "genotype", "cell_type"))
recommendation_mode = "context_aware" if explicit_topic else "context_neutral"
confidence = "high" if experiment_meta else ("medium" if detected_axes else "low")
context = {
"rnaseq_experiment_context_version": "1.0",
"rnaseq_experiment_design": design,
"rnaseq_recommendation_mode": recommendation_mode,
"rnaseq_context_confidence": confidence,
"rnaseq_detected_axes": detected_axes,
"rnaseq_axis_values": axis_values,
"rnaseq_treatment": experiment_meta.get("treatment", ""),
"rnaseq_treatment_keywords": experiment_meta.get("treatment_keywords", []),
"rnaseq_trait": experiment_meta.get("trait", ""),
"rnaseq_primary_axis": experiment_meta.get("primary_axis", ""),
"rnaseq_control_condition": experiment_meta.get("control_condition", ""),
"rnaseq_species_name": project.get("species_name", ""),
"rnaseq_kegg_organism": project.get("kegg_organism", ""),
"rnaseq_context_sources": {k: v for k, v in context_sources.items() if v},
}
for key in ("stage_order", "time_order"):
if experiment_meta.get(key):
context[f"rnaseq_{key}"] = experiment_meta[key]
return {k: v for k, v in context.items() if v not in ("", [], {}, None)}
def write_experiment_context(final_dir: str | Path, prefix: str, context: Mapping[str, Any]) -> Path:
path = Path(final_dir) / f"{prefix}.rnaseq_experiment_context.json"
payload = dict(context or {})
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def read_rnaseq_sample_metadata(sample_config: str | Path | None = None, input_dir: str | Path | None = None) -> dict[str, str]:
paths: list[Path] = []
if sample_config:
paths.append(Path(sample_config))
if input_dir:
base = Path(input_dir)
for pattern in ("samples.yaml", "samples.yml", "samples*.yaml", "samples*.yml", "*sample*.yaml", "*sample*.yml"):
paths.extend(sorted(base.glob(pattern)))
seen: set[Path] = set()
for path in paths:
if path in seen or not path.exists() or yaml is None:
continue
seen.add(path)
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception:
continue
if not isinstance(data, Mapping):
continue
merged: dict[str, str] = {}
for meta in (data, data.get("species"), data.get("organism"), data.get("metadata"), data.get("experiment"), data.get("rnaseq")):
for key, value in _species_meta_from_mapping(meta).items():
if value and key not in merged:
merged[key] = value
if merged:
return merged
return {}
def _infer_species_from_config_xlsx(input_dir: Path) -> str:
config_path = input_dir / "config.xlsx"
if not config_path.exists():
return ""
try:
import pandas as pd # type: ignore
sheets = pd.read_excel(config_path, sheet_name=None)
except Exception:
return ""
priority: list[str] = []
fallback: list[str] = []
for _sheet, df in sheets.items():
if "Species_Code" not in df.columns:
continue
for _idx, row in df.fillna("").iterrows():
species = _clean_project_value(row.get("Species_Code"))
if not species:
continue
role = _clean_project_value(row.get("Species_Role")).lower()
scope = _clean_project_value(row.get("File_Scope")).lower()
file_type = _clean_project_value(row.get("File_Type")).lower()
if role in {"target", ""} or scope in {"whole_genome", "genome"} or file_type in {"genome", "gff", "cds", "pep"}:
priority.append(species)
else:
fallback.append(species)
for group in (priority, fallback):
unique: list[str] = []
for item in group:
if item and item not in unique:
unique.append(item)
if unique:
return unique[0]
return ""
def _infer_resource_reference_token(input_dir: Path) -> str:
tokens: list[str] = []
patterns = (
"*.genome.fasta", "*.genome.fa", "*.genome.fna",
"*.gff", "*.gff3", "*.gtf",
"*.cds.fasta", "*.cds.fa", "*.pep.fasta", "*.pep.fa",
"*.fa", "*.fasta", "*.fna",
)
if not input_dir.exists():
return ""
for pattern in patterns:
for file in sorted(input_dir.glob(pattern)):
token = file.name.split(".", 1)[0].strip()
if token and re.match(r"^[A-Za-z][A-Za-z0-9_-]{1,31}$", token):
tokens.append(token)
for child in sorted(input_dir.iterdir()):
if not child.is_dir():
continue
token = child.name.strip()
if token and re.match(r"^[A-Za-z][A-Za-z0-9_-]{1,31}$", token):
for pattern in patterns:
if any(child.glob(pattern)):
tokens.append(token)
break
for token in tokens:
if token.lower() not in GENERIC_RNASEQ_PREFIXES:
return token
return tokens[0] if tokens else ""
def _reference_roots(input_dir: Path, species_code: str = "", reference_id: str = "") -> list[Path]:
"""Return prioritized one-level roots for RNA-seq reference resources."""
roots: list[Path] = []
def add(path: Path) -> None:
if path.exists() and path not in roots:
roots.append(path)
add(input_dir)
for token in (species_code, reference_id):
token = _clean_project_value(token)
if token:
add(input_dir / token)
if input_dir.exists():
for child in sorted(input_dir.iterdir()):
if child.is_dir():
add(child)
return roots
def _existing_first(candidates: list[Path]) -> Path | None:
for candidate in candidates:
if candidate.exists() and candidate.is_file() and candidate.stat().st_size > 0:
return candidate
return None
def find_rnaseq_hisat2_index(input_dir: str | Path, species_code: str = "", reference_id: str = "") -> Path | None:
"""Find a genome HISAT2 index prefix, never a CDS/PEP index."""
base = Path(input_dir)
species = _clean_project_value(species_code)
reference = _clean_project_value(reference_id)
for root in _reference_roots(base, species, reference):
exact: list[Path] = []
if species:
exact.append(root / f"{species}.genome.1.ht2")
marker = _existing_first(exact)
if marker:
return Path(str(marker)[: -len(".1.ht2")])
for root in _reference_roots(base, species, reference):
for marker in sorted(root.glob("*.genome.1.ht2")):
name = marker.name.lower()
if ".cds." in name or ".pep." in name or ".protein." in name:
continue
return Path(str(marker)[: -len(".1.ht2")])
return None
def find_rnaseq_reference_file(
input_dir: str | Path,
kind: str,
species_code: str = "",
reference_id: str = "",
) -> Path | None:
"""Find canonical RNA-seq reference files with species-first priority."""
base = Path(input_dir)
species = _clean_project_value(species_code)
reference = _clean_project_value(reference_id)
kind = str(kind or "").strip().lower()
suffixes = {
"genome": ("genome.fasta", "genome.fa", "genome.fna"),
"annotation": ("gff3", "gff", "gtf"),
"cds": ("cds.fasta", "cds.fa", "cds.fna"),
"pep": ("pep.fasta", "pep.fa", "protein.fasta", "protein.fa"),
"transcriptome": ("cds.fasta", "cds.fa", "transcript.fasta", "transcript.fa"),
}.get(kind, ())
if not suffixes:
return None
for root in _reference_roots(base, species, reference):
exact: list[Path] = []
if species:
if kind == "annotation":
exact.extend([root / f"{species}_final.gff3", root / f"{species}.gff3", root / f"{species}.gff", root / f"{species}.gtf"])
else:
exact.extend(root / f"{species}.{suffix}" for suffix in suffixes)
marker = _existing_first(exact)
if marker:
return marker
for root in _reference_roots(base, species, reference):
patterns = [f"*.{suffix}" for suffix in suffixes]
if kind == "annotation":
patterns.extend(["*_final.gff3", "*all_models.gff3"])
for pattern in patterns:
for file in sorted(root.glob(pattern)):
lowered = file.name.lower()
if kind == "genome" and any(token in lowered for token in (".cds.", ".pep.", ".protein.", "repr.cds")):
continue
if file.is_file() and file.stat().st_size > 0:
return file
return None
def _prefix_from_final_manifest(final_dir: Path) -> str:
if not final_dir.exists():
return ""
preferred = (
"*.rnaseq_wgcna.manifest.json",
"*.rnaseq_fgsea.manifest.json",
"*.rnaseq_candidate_family.manifest.json",
"*.rnaseq_sample_qc.manifest.json",
"*.rnaseq_de.manifest.json",
"*.rnaseq_quant.manifest.json",
"*.rnaseq_align.manifest.json",
"*.rnaseq_qc.manifest.json",
)
for pattern in preferred:
matches = sorted(final_dir.glob(pattern))
if len(matches) == 1:
suffix = pattern[1:]
return matches[0].name[: -len(suffix)]
return ""
def resolve_rnaseq_project_context(
input_dir: str | Path | None = None,
final_dir: str | Path | None = None,
requested_prefix: str = "",
sample_config: str | Path | None = None,
params: Mapping[str, Any] | None = None,
) -> dict[str, str]:
"""Resolve RNA-seq species/output prefix without confusing reference IDs."""
params = params or {}
input_path = Path(input_dir) if input_dir else (Path(sample_config).parent if sample_config else Path("."))
final_path = Path(final_dir) if final_dir else None
sample_meta = read_rnaseq_sample_metadata(sample_config=sample_config, input_dir=input_path)
param_species = _clean_project_value(params.get("speciesCode") or params.get("species_code"))
param_output = _clean_project_value(
params.get("rnaseqPrefix")
or params.get("rnaseq_prefix")
or params.get("rnaseqOutputPrefix")
or params.get("rnaseq_output_prefix")
or params.get("outputPrefix")
or params.get("output_prefix")
)
config_species = _infer_species_from_config_xlsx(input_path)
resource_token = _infer_resource_reference_token(input_path)
final_prefix = _prefix_from_final_manifest(final_path) if final_path else ""
requested = _clean_project_value(requested_prefix)
species_code = param_species or sample_meta.get("species_code") or config_species
reference_id = sample_meta.get("reference_id") or (resource_token if _looks_like_reference_version(resource_token) else "")
explicit_output = param_output or sample_meta.get("output_prefix")
if explicit_output:
output_prefix = explicit_output
source = "explicit_output_prefix"
elif species_code:
output_prefix = species_code
source = "species_metadata" if (param_species or sample_meta.get("species_code")) else "config_xlsx"
elif requested and not _is_gene_family_like_prefix(requested) and requested.lower() not in GENERIC_RNASEQ_PREFIXES and not _looks_like_reference_version(requested):
output_prefix = requested
species_code = species_code or species_from_prefix(requested)
source = "requested_prefix"
elif final_prefix and not _looks_like_reference_version(final_prefix):
output_prefix = final_prefix
species_code = species_code or species_from_prefix(final_prefix)
source = "final_manifest"
elif resource_token and not _looks_like_reference_version(resource_token):
output_prefix = resource_token
species_code = species_code or species_from_prefix(resource_token)
source = "resource_filename"
elif requested and requested.lower() not in GENERIC_RNASEQ_PREFIXES and not _looks_like_reference_version(requested):
output_prefix = requested
species_code = species_code or species_from_prefix(requested)
source = "requested_prefix_fallback"
else:
output_prefix = ""
species_code = species_code or ""
source = "none"
result = {
"output_prefix": output_prefix,
"species_code": species_code,
"species_name": sample_meta.get("species_name", ""),
"kegg_organism": sample_meta.get("kegg_organism", ""),
"reference_id": reference_id,
"resource_token": resource_token,
"final_prefix": final_prefix,
"source": source,
}
return {k: v for k, v in result.items() if v}
def _read_sample_config(path: str | Path | None) -> dict[str, Any]:
if not path or yaml is None:
return {}
p = Path(path)
if not p.exists():
return {}
try:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _sample_names_from_count_matrix(path: str | Path | None) -> list[str]:
if not path:
return []
p = Path(path)
if not p.exists():
return []
try:
header = p.open(encoding="utf-8").readline().rstrip("\n").split("\t")
except Exception:
return []
if len(header) < 2:
return []
samples = header[1:]
featurecounts_meta = {"Chr", "Start", "End", "Strand", "Length"}
if featurecounts_meta.issubset(set(samples)):
samples = [s for s in samples if s not in featurecounts_meta]
samples = [re.sub(r"\.sorted$", "", Path(s).stem) for s in samples]
return samples
def _conditions_from_config(config: Mapping[str, Any], samples: list[str]) -> dict[str, str]:
conditions: dict[str, str] = {}
sample_meta = config.get("sample_info") or config.get("samples_info") or config.get("metadata")
if isinstance(sample_meta, Mapping):
for sample, meta in sample_meta.items():
if isinstance(meta, Mapping):
cond = meta.get("condition") or meta.get("group") or meta.get("treatment") or meta.get("time")
else:
cond = meta
if cond is not None:
conditions[str(sample)] = str(cond)
groups = config.get("groups") or config.get("conditions")
if isinstance(groups, Mapping):
sample_set = set(samples)
if groups and all(str(key) in sample_set for key in groups.keys()):
for sample, cond in groups.items():
if cond is not None:
conditions[str(sample)] = str(cond)
else:
for cond, members in groups.items():
if isinstance(members, (list, tuple)):
for sample in members:
conditions[str(sample)] = str(cond)
sample_entries = config.get("samples")
if isinstance(sample_entries, Mapping):
for sample, meta in sample_entries.items():
if isinstance(meta, Mapping):
cond = meta.get("condition") or meta.get("group") or meta.get("treatment") or meta.get("time")
if cond is not None:
conditions[str(sample)] = str(cond)
return {sample: conditions.get(sample, "") for sample in samples if sample in conditions}
def _condition_signature(conditions: Mapping[str, str], samples: list[str]) -> str:
values = [str(conditions.get(sample) or "") for sample in samples]
unique = []
for value in values:
if value and value not in unique:
unique.append(value)
if unique:
return "_".join(_clean_token(v) for v in unique[:8])
return "nogroups"
ALLOWED_RNASEQ_LIBRARY_STRANDNESS = {"non_stranded", "forward", "reverse"}
RNASEQ_LIBRARY_STRANDNESS_ALIASES = {
"none": "non_stranded",
"no": "non_stranded",
"unstranded": "non_stranded",
"non-stranded": "non_stranded",
"non stranded": "non_stranded",
"non_stranded": "non_stranded",
"0": "non_stranded",
"fr-unstranded": "non_stranded",
"forward": "forward",
"sense": "forward",
"stranded": "forward",
"yes": "forward",
"1": "forward",
"fr-firststrand": "forward",
"reverse": "reverse",
"reverse_stranded": "reverse",
"reverse-stranded": "reverse",
"antisense": "reverse",
"2": "reverse",
"fr-secondstrand": "reverse",
}
class RnaseqLibraryStrandnessError(ValueError):
"""Raised when samples.yaml does not explicitly define library strandness."""
def normalize_rnaseq_library_strandness(value: Any) -> str:
text = _clean_project_value(value).strip().lower().replace("_", "-")
text = text.replace(" ", "-")
if not text:
return ""
normalized = RNASEQ_LIBRARY_STRANDNESS_ALIASES.get(text) or RNASEQ_LIBRARY_STRANDNESS_ALIASES.get(text.replace("-", "_"))
if normalized:
return normalized
if text in ALLOWED_RNASEQ_LIBRARY_STRANDNESS:
return text
raise RnaseqLibraryStrandnessError(
"samples.yaml 中 library_strandness 的取值无效: "
f"{value!r}。允许值: non_stranded, forward, reverse。"
)
def resolve_rnaseq_library_strandness(
sample_config: str | Path | None,
samples: list[str] | None = None,
require_all: bool = False,
) -> dict[str, Any]:
config = _read_sample_config(sample_config)
sample_entries = config.get("samples") if isinstance(config.get("samples"), Mapping) else {}
sample_names = list(samples or [])
if not sample_names and isinstance(sample_entries, Mapping):
sample_names = list(map(str, sample_entries.keys()))
rnaseq_block = config.get("rnaseq") if isinstance(config.get("rnaseq"), Mapping) else {}
project_value = (
rnaseq_block.get("library_strandness")
or config.get("library_strandness")
or config.get("strandness")
or config.get("libraryType")
or config.get("library_type")
)
project_strandness = normalize_rnaseq_library_strandness(project_value) if project_value not in (None, "") else ""
by_sample: dict[str, str] = {}
missing: list[str] = []
for sample in sample_names:
meta = sample_entries.get(sample) if isinstance(sample_entries, Mapping) else {}
sample_value = None
if isinstance(meta, Mapping):
sample_value = (
meta.get("library_strandness")
or meta.get("strandness")
or meta.get("libraryType")
or meta.get("library_type")
)
value = normalize_rnaseq_library_strandness(sample_value) if sample_value not in (None, "") else project_strandness
if value:
by_sample[str(sample)] = value
else:
missing.append(str(sample))
if require_all:
if not sample_config:
raise RnaseqLibraryStrandnessError(
"RNA-seq Align/Quant 必须提供 samples.yaml,并在 rnaseq.library_strandness "
"或每个 samples.<sample>.library_strandness 中明确链特异性。"
)
if missing:
preview = ", ".join(missing[:10])
suffix = f" 等 {len(missing)} 个样本" if len(missing) > 10 else ""
raise RnaseqLibraryStrandnessError(
"samples.yaml 缺少 library_strandness,不能默认按非链特异性处理。"
f"请为样本 {preview}{suffix} 填写 non_stranded / forward / reverse。"
)
values = sorted(set(by_sample.values()))
signature_parts = [f"{sample}:{by_sample.get(sample, '')}" for sample in sorted(by_sample)]
signature = _short_hash(signature_parts) if signature_parts else ""
return {
"project_library_strandness": project_strandness,
"library_strandness_by_sample": by_sample,
"library_strandness_values": values,
"library_strandness_missing_samples": missing,
"library_strandness_mixed": len(values) > 1,
"library_strandness_signature": signature,
}
def require_rnaseq_library_strandness(sample_config: str | Path | None, samples: list[str]) -> dict[str, Any]:
return resolve_rnaseq_library_strandness(sample_config, samples=samples, require_all=True)
def hisat2_rna_strandness_args(strandness: str) -> list[str]:
value = normalize_rnaseq_library_strandness(strandness)
if value == "forward":
return ["--rna-strandness", "FR"]
if value == "reverse":
return ["--rna-strandness", "RF"]
return []
def featurecounts_strandness_value(strandness: str) -> str:
value = normalize_rnaseq_library_strandness(strandness)
return {"non_stranded": "0", "forward": "1", "reverse": "2"}[value]
def _short_hash(parts: list[str]) -> str:
digest = hashlib.sha1("|".join(parts).encode("utf-8", errors="ignore")).hexdigest()
return digest[:8]
def build_rnaseq_context(
prefix: str,
sample_config: str | Path | None = None,
count_matrix: str | Path | None = None,
sample_table: str | Path | None = None,
foreground_mode: str = "global",
family_members_file: str | Path | None = None,
family_scope_id: str = "",
family_label: str = "",
stage: str = "",
) -> dict[str, Any]:
config = _read_sample_config(sample_config)
samples = _sample_names_from_count_matrix(count_matrix)
table_conditions: dict[str, str] = {}
table_axis_values: dict[str, list[str]] = {}
if sample_table:
p = Path(sample_table)
if p.exists():
try:
rows = [row for row in p.read_text(encoding="utf-8").splitlines() if row.strip()]
if len(rows) > 1:
header = [h.strip().lower() for h in rows[0].split("\t")]
sample_idx = header.index("sample") if "sample" in header else 0
condition_idx = header.index("condition") if "condition" in header else (1 if len(header) > 1 else -1)
time_idx = next((header.index(key) for key in ("time", "timepoint", "hour") if key in header), -1)
parsed_samples = []
for row in rows[1:]:
parts = row.split("\t")
if len(parts) <= sample_idx:
continue
sample = parts[sample_idx].strip()
if not sample:
continue
parsed_samples.append(sample)
if condition_idx >= 0 and len(parts) > condition_idx:
table_conditions[sample] = parts[condition_idx].strip()
if time_idx >= 0 and len(parts) > time_idx:
time_value = parts[time_idx].strip()
if time_value:
table_axis_values.setdefault("time", [])
if time_value not in table_axis_values["time"]:
table_axis_values["time"].append(time_value)
if not samples:
samples = parsed_samples
except Exception:
if not samples:
samples = []
if not samples:
sample_entries = config.get("samples")
if isinstance(sample_entries, Mapping):
samples = list(map(str, sample_entries.keys()))
conditions = _conditions_from_config(config, samples)
for sample, condition in table_conditions.items():
if sample in samples and condition:
conditions[sample] = condition
condition_sig = _condition_signature(conditions, samples)
project_context = resolve_rnaseq_project_context(
input_dir=Path(sample_config).parent if sample_config else None,
requested_prefix=prefix,
sample_config=sample_config,
)
species = project_context.get("species_code") or species_from_prefix(prefix)
output_prefix = project_context.get("output_prefix") or prefix
sample_count = len(samples)
hash_parts = [output_prefix, species, condition_sig, str(sample_count), *(samples[:20])]
rnaseq_scope_id = f"{_clean_token(species)}.rnaseq.{condition_sig}.{sample_count}samples.{_short_hash(hash_parts)}"
fg = str(foreground_mode or "global").strip().lower()
if fg not in {"auto", "global", "family", "both"}:
fg = "global"
family_path = str(family_members_file or "")
context = {
"scope_type": "rnaseq_experiment",
"rnaseq_scope_id": rnaseq_scope_id,
"rnaseq_species": species,
"rnaseq_output_prefix": output_prefix,
"rnaseq_reference_id": project_context.get("reference_id", ""),
"rnaseq_context_source": project_context.get("source", ""),
"rnaseq_sample_count": sample_count,
"rnaseq_condition_count": len({v for v in conditions.values() if v}),
"rnaseq_condition_signature": condition_sig,
"rnaseq_stage": stage,
"foreground_mode": fg,
"family_scope_id": str(family_scope_id or ""),
"family_label": str(family_label or ""),
"family_members_file": family_path,
"rnaseq_context_version": "1.0",
}
experiment_context = build_experiment_context(prefix=prefix, sample_config=sample_config, params={})
if table_axis_values:
axis_values = dict(experiment_context.get("rnaseq_axis_values", {}))
for axis, values in table_axis_values.items():
existing = axis_values.setdefault(axis, [])
for value in values:
if value not in existing:
existing.append(value)
experiment_context["rnaseq_axis_values"] = axis_values
detected_axes = [
axis
for axis in ("treatment", "tissue", "organ_part", "stage", "time", "genotype", "trait", "cell_type", "condition")
if axis in axis_values
]
experiment_context["rnaseq_detected_axes"] = detected_axes
if experiment_context.get("rnaseq_experiment_design") in (None, "", "group_comparison"):
experiment_context["rnaseq_experiment_design"] = infer_experiment_design({}, axis_values)
sources = dict(experiment_context.get("rnaseq_context_sources", {}))
sources["sample_axes"] = sources.get("sample_axes") or "sample_table"
experiment_context["rnaseq_context_sources"] = sources
if experiment_context.get("rnaseq_context_confidence") in (None, "", "low"):
experiment_context["rnaseq_context_confidence"] = "medium"
context.update(experiment_context)
strandness = resolve_rnaseq_library_strandness(sample_config, samples=samples, require_all=False)
if strandness.get("library_strandness_by_sample"):
context.update({
"rnaseq_library_strandness_by_sample": strandness["library_strandness_by_sample"],
"rnaseq_library_strandness_values": strandness["library_strandness_values"],
"rnaseq_library_strandness_signature": strandness["library_strandness_signature"],
"rnaseq_library_strandness_mixed": strandness["library_strandness_mixed"],
})
return context
def apply_rnaseq_context(manifest: dict[str, Any], context: Mapping[str, Any]) -> dict[str, Any]:
ctx = dict(context or {})
for key, value in ctx.items():
manifest[key] = value
params = manifest.setdefault("parameters", {})
if isinstance(params, dict):
for key, value in ctx.items():
params.setdefault(key, value)
return manifest
RNASEQ_CONTEXT_KEYS = {
"scope_type",
"rnaseq_scope_id",
"rnaseq_species",
"rnaseq_output_prefix",
"rnaseq_reference_id",
"rnaseq_context_source",
"rnaseq_sample_count",
"rnaseq_condition_count",
"rnaseq_condition_signature",
"rnaseq_stage",
"foreground_mode",
"family_scope_id",
"family_label",
"family_members_file",
"rnaseq_context_version",
"rnaseq_experiment_context_version",
"rnaseq_experiment_design",
"rnaseq_recommendation_mode",
"rnaseq_context_confidence",
"rnaseq_detected_axes",
"rnaseq_axis_values",
"rnaseq_treatment",
"rnaseq_treatment_keywords",
"rnaseq_trait",
"rnaseq_primary_axis",
"rnaseq_control_condition",
"rnaseq_species_name",
"rnaseq_kegg_organism",
"rnaseq_context_sources",
"rnaseq_library_strandness_by_sample",
"rnaseq_library_strandness_values",
"rnaseq_library_strandness_signature",
"rnaseq_library_strandness_mixed",
"rnaseq_stage_order",
"rnaseq_time_order",
"rnaseq_experiment_context_path",
}
def context_from_manifest(manifest: Mapping[str, Any], stage: str = "") -> dict[str, Any]:
"""Extract RNA-seq context fields from an upstream manifest."""
context = {key: manifest.get(key) for key in RNASEQ_CONTEXT_KEYS if manifest.get(key) not in (None, "")}
params = manifest.get("parameters")
if isinstance(params, Mapping):
for key in RNASEQ_CONTEXT_KEYS:
if key not in context and params.get(key) not in (None, ""):
context[key] = params.get(key)
if context:
context["scope_type"] = "rnaseq_experiment"
context["rnaseq_stage"] = stage or str(context.get("rnaseq_stage") or "")
context.setdefault("foreground_mode", "global")
context.setdefault("rnaseq_context_version", "1.0")
return context
def find_upstream_rnaseq_context(
final_dir: str | Path,
prefix: str,
stage: str = "",
upstream_stages: tuple[str, ...] = (
"rnaseq_de",
"expression",
"rnaseq_sample_qc",
"rnaseq_quant",
"rnaseq_align",
"rnaseq_qc",
),
) -> dict[str, Any]:
"""Find the nearest available RNA-seq context in final/ manifests."""
base = Path(final_dir)
candidates: list[Path] = []
clean_prefix = str(prefix or "").strip()
for upstream in upstream_stages:
if clean_prefix:
candidates.extend(base.glob(f"{clean_prefix}.{upstream}.manifest.json"))
candidates.extend(base.glob(f"*.{upstream}.manifest.json"))
seen: set[Path] = set()
for manifest_path in candidates:
if manifest_path in seen or not manifest_path.exists():
continue
seen.add(manifest_path)
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
continue
context = context_from_manifest(manifest, stage=stage)
if context.get("rnaseq_scope_id"):
return context
return {}
def merge_rnaseq_context(*contexts: Mapping[str, Any], stage: str = "") -> dict[str, Any]:
"""Merge context dictionaries, preserving later non-empty values."""
merged: dict[str, Any] = {}
for context in contexts:
for key, value in dict(context or {}).items():
if value not in (None, ""):
merged[key] = value
if merged:
merged["scope_type"] = "rnaseq_experiment"
if stage:
merged["rnaseq_stage"] = stage
merged.setdefault("foreground_mode", "global")
merged.setdefault("rnaseq_context_version", "1.0")
return merged