"""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..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