← Files Life Sciences NGS AnalysisARCHIVED FILE

scripts/run_bcl_to_fastq.py

30.1 KB · Sep 30, 2026 · 22:50 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate Illumina BCL run folders and run local BCL-to-FASTQ conversion when available."""

from __future__ import annotations

import argparse
import csv
import shlex
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any

from ngs_run_utils import (
    build_artifact_index,
    command_path,
    run_cmd,
    slug_timestamp,
    software_versions,
    tool_preflight,
    write_json,
    write_standard_manifest,
    write_text,
)

WORKSPACE_ROOT = Path.cwd()
DEFAULT_RUN_ROOT = WORKSPACE_ROOT / "ngs_runs" / "bcl_to_fastq"
DOCKER_MOUNT_ROOTS = (Path("/Users"), Path("/private"), Path("/Volumes"))
DEMUX_QC_THRESHOLDS = {
    "warn_undetermined_fraction": 0.20,
    "fail_undetermined_fraction": 0.50,
    "warn_top_unknown_fraction": 0.01,
}


def parse_runinfo(path: Path) -> dict[str, Any]:
    result: dict[str, Any] = {
        "path": str(path),
        "exists": path.exists(),
        "ok": False,
        "reads": [],
        "errors": [],
    }
    if not path.exists():
        result["errors"].append("RunInfo.xml is missing")
        return result
    try:
        root = ET.parse(path).getroot()
    except ET.ParseError as exc:
        result["errors"].append(f"RunInfo.xml parse failed: {exc}")
        return result

    run = root.find(".//Run")
    if run is not None:
        result["run_id"] = run.attrib.get("Id")
        result["flowcell"] = run.attrib.get("Flowcell")
        result["instrument"] = run.attrib.get("Instrument")

    reads = []
    for read in root.findall(".//Reads/Read"):
        read_info = {
            "number": int(read.attrib.get("Number", "0") or 0),
            "num_cycles": int(read.attrib.get("NumCycles", "0") or 0),
            "is_indexed_read": read.attrib.get("IsIndexedRead", "").lower() == "y",
        }
        reads.append(read_info)
    result["reads"] = sorted(reads, key=lambda item: item["number"])
    result["indexed_reads"] = [item for item in result["reads"] if item["is_indexed_read"]]
    result["sequencing_reads"] = [item for item in result["reads"] if not item["is_indexed_read"]]
    if not result["reads"]:
        result["errors"].append("RunInfo.xml does not contain Reads/Read entries")
    result["ok"] = not result["errors"]
    return result


def parse_runparameters(path: Path) -> dict[str, Any]:
    result: dict[str, Any] = {
        "path": str(path),
        "exists": path.exists(),
        "ok": True,
        "values": {},
        "errors": [],
    }
    if not path.exists():
        result["ok"] = False
        result["errors"].append("RunParameters.xml is missing")
        return result
    try:
        root = ET.parse(path).getroot()
    except ET.ParseError as exc:
        result["ok"] = False
        result["errors"].append(f"RunParameters.xml parse failed: {exc}")
        return result

    wanted = [
        "ApplicationName",
        "ApplicationVersion",
        "InstrumentType",
        "RunID",
        "ExperimentName",
        "FlowCellType",
    ]
    values = {}
    for tag in wanted:
        node = root.find(f".//{tag}")
        if node is not None and node.text:
            values[tag] = node.text.strip()
    result["values"] = values
    return result


def normalize_header(value: str) -> str:
    return value.strip().lstrip("\ufeff")


def parse_sample_sheet(path: Path) -> dict[str, Any]:
    result: dict[str, Any] = {
        "path": str(path),
        "exists": path.exists(),
        "ok": False,
        "sections": [],
        "data_columns": [],
        "data_rows": [],
        "reads": [],
        "settings": {},
        "errors": [],
        "warnings": [],
    }
    if not path.exists():
        result["errors"].append("sample sheet is missing")
        return result

    with path.open(newline="", encoding="utf-8-sig") as handle:
        parsed = list(csv.reader(handle))

    current_section: str | None = None
    data_header: list[str] | None = None
    in_data = False
    for raw_row in parsed:
        row = [item.strip() for item in raw_row]
        if not row or not any(row):
            continue
        first = normalize_header(row[0])
        if first.startswith("[") and first.endswith("]"):
            current_section = first.strip("[]")
            result["sections"].append(current_section)
            in_data = current_section.lower().endswith("data") or current_section.lower() == "data"
            data_header = None
            continue
        if current_section is None:
            if data_header is None:
                data_header = [normalize_header(item) for item in row]
                result["data_columns"] = data_header
                in_data = True
            else:
                values = row + [""] * (len(data_header) - len(row))
                result["data_rows"].append(dict(zip(data_header, values)))
            continue
        section_key = current_section.lower()
        if section_key == "reads":
            try:
                result["reads"].append(int(row[0]))
            except ValueError:
                result["warnings"].append(f"could not parse [Reads] row: {','.join(row)}")
            continue
        if section_key in {"settings", "header"} and len(row) >= 2:
            result["settings"][row[0]] = row[1]
            continue
        if in_data:
            if data_header is None:
                data_header = [normalize_header(item) for item in row]
                result["data_columns"] = data_header
            else:
                values = row + [""] * (len(data_header) - len(row))
                result["data_rows"].append(dict(zip(data_header, values)))

    data_rows = result["data_rows"]
    if not data_rows:
        result["errors"].append("sample sheet does not contain data rows")
        return result

    duplicate_keys: set[tuple[str, str, str]] = set()
    for index, row in enumerate(data_rows, start=1):
        sample = (
            row.get("Sample_ID")
            or row.get("SampleID")
            or row.get("Sample_Name")
            or row.get("sample")
            or ""
        )
        i7 = row.get("index") or row.get("Index") or row.get("I7_Index_ID") or ""
        i5 = row.get("index2") or row.get("Index2") or row.get("I5_Index_ID") or ""
        lane = row.get("Lane") or row.get("lane") or "all"
        if not sample:
            result["errors"].append(f"data row {index}: sample identifier is missing")
        if not i7 and not i5:
            result["warnings"].append(f"data row {index}: no index sequence columns were found")
        key = (lane, i7, i5)
        if key in duplicate_keys:
            result["errors"].append(
                f"data row {index}: duplicate lane/index/index2 combination {key}"
            )
        duplicate_keys.add(key)

    result["sample_count"] = len(
        {
            row.get("Sample_ID")
            or row.get("SampleID")
            or row.get("Sample_Name")
            or row.get("sample")
            or f"row_{i}"
            for i, row in enumerate(data_rows, start=1)
        }
    )
    result["index_lengths"] = sorted(
        {
            len(row.get("index") or row.get("Index") or "")
            for row in data_rows
            if row.get("index") or row.get("Index")
        }
    )
    result["index2_lengths"] = sorted(
        {
            len(row.get("index2") or row.get("Index2") or "")
            for row in data_rows
            if row.get("index2") or row.get("Index2")
        }
    )
    result["ok"] = not result["errors"]
    return result


def validate_index_lengths(runinfo: dict[str, Any], sample_sheet: dict[str, Any]) -> list[str]:
    warnings = []
    indexed_cycles = [item.get("num_cycles", 0) for item in runinfo.get("indexed_reads", [])]
    sequencing_cycles = [item.get("num_cycles", 0) for item in runinfo.get("sequencing_reads", [])]
    sample_sheet_reads = sample_sheet.get("reads") or []
    if (
        sample_sheet_reads
        and sequencing_cycles
        and sample_sheet_reads != sequencing_cycles[: len(sample_sheet_reads)]
    ):
        warnings.append(
            f"sample sheet [Reads] values do not match RunInfo sequencing reads: sample sheet {sample_sheet_reads}, RunInfo {sequencing_cycles}"
        )
    if not indexed_cycles:
        return warnings
    index_lengths = sample_sheet.get("index_lengths") or []
    index2_lengths = sample_sheet.get("index2_lengths") or []
    if (
        index_lengths
        and indexed_cycles
        and any(length > indexed_cycles[0] for length in index_lengths)
    ):
        warnings.append(
            f"i7 index length exceeds RunInfo index read cycles: sample sheet {index_lengths}, RunInfo {indexed_cycles[0]}"
        )
    if (
        index2_lengths
        and len(indexed_cycles) >= 2
        and any(length > indexed_cycles[1] for length in index2_lengths)
    ):
        warnings.append(
            f"i5 index length exceeds RunInfo index read cycles: sample sheet {index2_lengths}, RunInfo {indexed_cycles[1]}"
        )
    if index2_lengths and len(indexed_cycles) < 2:
        warnings.append(
            "sample sheet has index2 values but RunInfo.xml has fewer than two indexed reads"
        )
    return warnings


def validate_inputs(args: argparse.Namespace) -> dict[str, Any]:
    run_folder = args.run_folder.expanduser().resolve()
    sample_sheet = args.sample_sheet.expanduser().resolve()
    runinfo = parse_runinfo(run_folder / "RunInfo.xml")
    runparameters = parse_runparameters(run_folder / "RunParameters.xml")
    sheet = parse_sample_sheet(sample_sheet)
    basecalls = run_folder / "Data" / "Intensities" / "BaseCalls"
    errors = []
    warnings = []
    if not run_folder.exists():
        errors.append(f"run folder does not exist: {run_folder}")
    if not basecalls.exists():
        errors.append(f"BaseCalls directory does not exist: {basecalls}")
    errors.extend(runinfo.get("errors", []))
    errors.extend(sheet.get("errors", []))
    warnings.extend(runparameters.get("errors", []))
    warnings.extend(sheet.get("warnings", []))
    warnings.extend(validate_index_lengths(runinfo, sheet))

    validation = {
        "ok": not errors,
        "run_folder": str(run_folder),
        "sample_sheet": str(sample_sheet),
        "output_directory": str(args.output_directory.expanduser().resolve()),
        "runinfo": runinfo,
        "runparameters": runparameters,
        "sample_sheet_summary": sheet,
        "basecalls_directory": str(basecalls),
        "basecalls_directory_exists": basecalls.exists(),
        "errors": errors,
        "warnings": warnings,
    }
    return validation


def select_converter(args: argparse.Namespace) -> str | None:
    if args.converter:
        return args.converter if command_path(args.converter) else None
    if command_path("bcl-convert"):
        return "bcl-convert"
    if command_path("bcl2fastq"):
        return "bcl2fastq"
    return None


def conversion_command(converter: str, args: argparse.Namespace) -> list[str]:
    run_folder = str(args.run_folder.expanduser().resolve())
    sample_sheet = str(args.sample_sheet.expanduser().resolve())
    output_directory = str(args.output_directory.expanduser().resolve())
    if Path(converter).name == "bcl2fastq" or converter == "bcl2fastq":
        return [
            converter,
            "--runfolder-dir",
            run_folder,
            "--output-dir",
            output_directory,
            "--sample-sheet",
            sample_sheet,
        ]
    return [
        converter,
        "--bcl-input-directory",
        run_folder,
        "--output-directory",
        output_directory,
        "--sample-sheet",
        sample_sheet,
    ]


def read_csv_rows(path: Path) -> list[dict[str, str]]:
    if not path.exists():
        return []
    with path.open(newline="", encoding="utf-8-sig") as handle:
        return list(csv.DictReader(handle))


def parse_int(value: str | None) -> int | None:
    if not value:
        return None
    try:
        return int(value.replace(",", ""))
    except ValueError:
        return None


def parse_float(value: str | None) -> float | None:
    if value is None or value == "":
        return None
    try:
        return float(value)
    except ValueError:
        return None


def path_within_roots(path: Path, roots: tuple[Path, ...]) -> bool:
    return any(path == root or root in path.parents for root in roots)


def converter_runtime_preflight(converter: str | None, args: argparse.Namespace) -> dict[str, Any]:
    result: dict[str, Any] = {
        "converter": converter,
        "converter_path": command_path(converter) if converter else None,
        "uses_docker_wrapper": False,
        "docker_daemon_ok": None,
        "errors": [],
        "warnings": [],
        "mount_roots": [str(item) for item in DOCKER_MOUNT_ROOTS],
    }
    if not converter:
        return result
    converter_path = Path(result["converter_path"] or converter)
    if converter_path.exists():
        try:
            head = converter_path.read_text(encoding="utf-8", errors="ignore")[:2000]
            result["uses_docker_wrapper"] = "docker run" in head
        except OSError:
            pass
    if result["uses_docker_wrapper"]:
        probe = run_cmd(["docker", "info"], WORKSPACE_ROOT, timeout=30)
        result["docker_daemon_ok"] = probe.get("ok", False)
        if not probe.get("ok", False):
            result["warnings"].append(
                "docker daemon is not ready for the Docker-backed bcl-convert wrapper"
            )
        for label, candidate in {
            "run_folder": args.run_folder.expanduser().resolve(),
            "sample_sheet": args.sample_sheet.expanduser().resolve(),
            "output_directory": args.output_directory.expanduser().resolve(),
        }.items():
            if not path_within_roots(candidate, DOCKER_MOUNT_ROOTS):
                result["errors"].append(
                    f"{label} is outside the Docker wrapper mount roots {', '.join(str(item) for item in DOCKER_MOUNT_ROOTS)}"
                )
    return result


def docker_daemon_error(result: dict[str, Any]) -> bool:
    output = str(result.get("stdout_tail", "") or "")
    return "Cannot connect to the Docker daemon" in output


def wait_for_docker_daemon(timeout_seconds: int = 60, poll_seconds: int = 5) -> dict[str, Any]:
    attempts = []
    deadline = time.time() + timeout_seconds
    while time.time() <= deadline:
        probe = run_cmd(["docker", "info"], WORKSPACE_ROOT, timeout=30)
        attempts.append(
            {
                "ok": probe.get("ok", False),
                "finished_at": probe.get("finished_at"),
                "stdout_tail": probe.get("stdout_tail", ""),
            }
        )
        if probe.get("ok", False):
            return {"ok": True, "attempts": attempts}
        if time.time() + poll_seconds > deadline:
            break
        time.sleep(poll_seconds)
    return {"ok": False, "attempts": attempts}


def normalize_report_path(path_value: str, output_directory: Path) -> Path:
    normalized = (
        path_value.replace("/host", "", 1) if path_value.startswith("/host/") else path_value
    )
    candidate = Path(normalized)
    if candidate.exists():
        return candidate
    return output_directory / Path(path_value).name


def summarize_fastq_outputs(
    output_directory: Path, demux_rows: list[dict[str, str]]
) -> list[dict[str, Any]]:
    by_sample = {
        row.get("SampleID", ""): parse_int(row.get("# Reads"))
        for row in demux_rows
        if row.get("SampleID")
    }
    rows = []
    reports_dir = output_directory / "Reports"
    for entry in read_csv_rows(reports_dir / "fastq_list.csv"):
        for read_key in ("Read1File", "Read2File"):
            path_value = entry.get(read_key)
            if not path_value:
                continue
            path = normalize_report_path(path_value, output_directory)
            rows.append(
                {
                    "sample": entry.get("RGSM"),
                    "lane": entry.get("Lane"),
                    "read": "R1" if read_key == "Read1File" else "R2",
                    "path": str(path),
                    "bytes": path.stat().st_size if path.exists() else None,
                    "read_pairs": by_sample.get(entry.get("RGSM", "")),
                }
            )
    undetermined_reads = by_sample.get("Undetermined")
    for path in sorted(output_directory.glob("Undetermined*.fastq.gz")):
        rows.append(
            {
                "sample": "Undetermined",
                "lane": "1",
                "read": "R1" if "_R1_" in path.name else "R2",
                "path": str(path),
                "bytes": path.stat().st_size,
                "read_pairs": undetermined_reads,
            }
        )
    return rows


def parse_report_bundle(output_directory: Path) -> dict[str, Any] | None:
    reports_dir = output_directory / "Reports"
    if not reports_dir.exists():
        return None
    demux_rows = read_csv_rows(reports_dir / "Demultiplex_Stats.csv")
    quality_rows = read_csv_rows(reports_dir / "Quality_Metrics.csv")
    unknown_rows = read_csv_rows(reports_dir / "Top_Unknown_Barcodes.csv")
    assigned_reads = sum(
        parse_int(row.get("# Reads")) or 0
        for row in demux_rows
        if row.get("SampleID") != "Undetermined"
    )
    undetermined_reads = sum(
        parse_int(row.get("# Reads")) or 0
        for row in demux_rows
        if row.get("SampleID") == "Undetermined"
    )
    total_reads = assigned_reads + undetermined_reads
    assigned_fraction = (assigned_reads / total_reads) if total_reads else None
    undetermined_fraction = (undetermined_reads / total_reads) if total_reads else None
    top_unknown = [
        {
            "index": row.get("index"),
            "index2": row.get("index2"),
            "reads": parse_int(row.get("# Reads")),
            "fraction_of_all_reads": parse_float(row.get("% of All Reads")),
        }
        for row in unknown_rows[:5]
    ]
    quality_by_sample = {}
    for row in quality_rows:
        sample = row.get("SampleID", "unknown")
        quality_by_sample.setdefault(sample, {})[f"read_{row.get('ReadNumber')}"] = {
            "yield": parse_int(row.get("Yield")),
            "q30_fraction": parse_float(row.get("% Q30")),
            "mean_quality_pf": parse_float(row.get("Mean Quality Score (PF)")),
        }
    issues = []
    assessment = "pass"
    if (
        undetermined_fraction is not None
        and undetermined_fraction >= DEMUX_QC_THRESHOLDS["fail_undetermined_fraction"]
    ):
        assessment = "fail"
        issues.append(
            f"undetermined reads are {undetermined_fraction:.2%}, above the fail threshold of {DEMUX_QC_THRESHOLDS['fail_undetermined_fraction']:.0%}"
        )
    elif (
        undetermined_fraction is not None
        and undetermined_fraction >= DEMUX_QC_THRESHOLDS["warn_undetermined_fraction"]
    ):
        assessment = "warning"
        issues.append(
            f"undetermined reads are {undetermined_fraction:.2%}, above the warning threshold of {DEMUX_QC_THRESHOLDS['warn_undetermined_fraction']:.0%}"
        )
    top_unknown_fraction = max(
        (
            item["fraction_of_all_reads"]
            for item in top_unknown
            if item["fraction_of_all_reads"] is not None
        ),
        default=None,
    )
    if (
        top_unknown_fraction is not None
        and top_unknown_fraction >= DEMUX_QC_THRESHOLDS["warn_top_unknown_fraction"]
    ):
        assessment = "warning" if assessment == "pass" else assessment
        issues.append(
            f"top unknown barcode accounts for {top_unknown_fraction:.2%} of all reads, at or above the warning threshold of {DEMUX_QC_THRESHOLDS['warn_top_unknown_fraction']:.0%}"
        )
    return {
        "output_directory": str(output_directory),
        "report_directory": str(reports_dir),
        "assigned_reads": assigned_reads,
        "undetermined_reads": undetermined_reads,
        "total_reads": total_reads,
        "assigned_fraction": assigned_fraction,
        "undetermined_fraction": undetermined_fraction,
        "assessment": assessment,
        "issues": issues,
        "quality_by_sample": quality_by_sample,
        "top_unknown_barcodes": top_unknown,
        "fastq_outputs": summarize_fastq_outputs(output_directory, demux_rows),
    }


def write_commands(run_dir: Path, args: argparse.Namespace, converter: str | None) -> None:
    lines = ["#!/usr/bin/env bash", "set -euo pipefail"]
    if converter:
        lines.append(shlex.join(conversion_command(converter, args)))
    else:
        lines.append("# bcl-convert or bcl2fastq is required before execution.")
        lines.append(
            "# "
            + shlex.join(
                [
                    "bcl-convert",
                    "--bcl-input-directory",
                    str(args.run_folder.expanduser().resolve()),
                    "--output-directory",
                    str(args.output_directory.expanduser().resolve()),
                    "--sample-sheet",
                    str(args.sample_sheet.expanduser().resolve()),
                ]
            )
        )
    write_text(run_dir / "commands.sh", "\n".join(lines) + "\n")


def execute_conversion(
    run_dir: Path, args: argparse.Namespace, converter: str, runtime_preflight: dict[str, Any]
) -> dict[str, Any]:
    output_directory = args.output_directory.expanduser().resolve()
    output_directory.parent.mkdir(parents=True, exist_ok=True)
    command = conversion_command(converter, args)
    attempts = []
    result = run_cmd(command, run_dir, timeout=args.timeout_seconds)
    attempts.append(result)
    if (
        runtime_preflight.get("uses_docker_wrapper")
        and not result.get("ok")
        and docker_daemon_error(result)
    ):
        daemon_wait = wait_for_docker_daemon()
        if daemon_wait.get("ok"):
            retry = run_cmd(command, run_dir, timeout=args.timeout_seconds)
            retry["retry_reason"] = "docker_daemon_ready_after_wait"
            attempts.append(retry)
            result = retry
    payload = {
        "ok": result.get("ok"),
        "converter": converter,
        "output_directory": str(output_directory),
        "command": result.get("cmd"),
        "attempts": attempts,
    }
    write_json(run_dir / "logs" / "bcl_conversion.json", payload)
    write_text(run_dir / "logs" / "bcl_conversion.log", result.get("stdout_tail", ""))
    return payload


def write_summary(
    run_dir: Path,
    status: str,
    validation: dict[str, Any],
    converter: str | None,
    runtime_preflight: dict[str, Any],
    report_bundle: dict[str, Any] | None,
) -> None:
    sheet = validation.get("sample_sheet_summary", {})
    runinfo = validation.get("runinfo", {})
    lines = [
        "# BCL To FASTQ Run Summary",
        "",
        f"Status: `{status}`",
        f"Converter: `{converter or 'not installed'}`",
        f"Run folder: `{validation.get('run_folder')}`",
        f"Samples parsed: `{sheet.get('sample_count', 0)}`",
        f"Read structure: `{runinfo.get('reads', [])}`",
    ]
    if runtime_preflight.get("uses_docker_wrapper"):
        lines.extend(
            [
                f"Docker-backed wrapper: `{runtime_preflight.get('converter_path')}`",
                f"Docker daemon ready: `{runtime_preflight.get('docker_daemon_ok')}`",
            ]
        )
    if report_bundle:
        lines.extend(
            [
                "",
                "## Demux QC",
                "",
                f"Assessment: `{report_bundle['assessment']}`",
                f"Assigned reads: `{report_bundle['assigned_reads']}` (`{report_bundle['assigned_fraction']:.2%}`)",
                f"Undetermined reads: `{report_bundle['undetermined_reads']}` (`{report_bundle['undetermined_fraction']:.2%}`)",
            ]
        )
        if report_bundle["issues"]:
            lines.extend(f"- {issue}" for issue in report_bundle["issues"])
        top_unknown = report_bundle["top_unknown_barcodes"][:3]
        if top_unknown:
            lines.extend(["", "Top unknown barcodes:"])
            lines.extend(
                f"- {item['index']}-{item['index2']}: {item['reads']} reads ({item['fraction_of_all_reads']:.2%} of all reads)"
                for item in top_unknown
                if item["reads"] is not None and item["fraction_of_all_reads"] is not None
            )
        lines.extend(["", "FASTQ outputs:"])
        lines.extend(
            f"- `{Path(item['path']).name}`: {item['read_pairs']} read pairs, {item['bytes']} bytes"
            for item in report_bundle["fastq_outputs"]
        )
    lines.extend(
        [
            "",
            "## Key Artifacts",
            "",
            "- `validation/runinfo.json`",
            "- `validation/samplesheet_summary.json`",
            "- `qc/demux_qc_summary.json` when conversion succeeds",
            "- `logs/bcl_conversion.log` when conversion executes",
            "- `run_manifest.json` and `artifact_index.json`",
            "",
        ]
    )
    if validation.get("errors"):
        lines.extend(["## Blockers", ""])
        lines.extend(f"- {error}" for error in validation["errors"])
    combined_warnings = [*validation.get("warnings", []), *runtime_preflight.get("warnings", [])]
    if combined_warnings:
        lines.extend(["", "## Warnings", ""])
        lines.extend(f"- {warning}" for warning in combined_warnings)
    write_text(run_dir / "summary.md", "\n".join(lines) + "\n")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--run-folder", type=Path, required=True)
    parser.add_argument("--sample-sheet", type=Path, required=True)
    parser.add_argument("--output-directory", type=Path, required=True)
    parser.add_argument("--outdir", type=Path)
    parser.add_argument("--run-id", default=slug_timestamp("bcl-to-fastq"))
    parser.add_argument("--converter", choices=["bcl-convert", "bcl2fastq"])
    parser.add_argument("--execute", action="store_true")
    parser.add_argument("--timeout-seconds", type=int, default=86400)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    run_dir = (args.outdir or (DEFAULT_RUN_ROOT / args.run_id)).expanduser().resolve()
    if run_dir.exists():
        raise FileExistsError(f"run directory already exists: {run_dir}")
    run_dir.mkdir(parents=True)
    (run_dir / "logs").mkdir(parents=True, exist_ok=True)

    validation = validate_inputs(args)
    converter = select_converter(args)
    runtime_preflight = converter_runtime_preflight(converter, args)
    tool_status = tool_preflight([], optional=["bcl-convert", "bcl2fastq"])
    if args.execute and converter is None:
        tool_status["ok"] = False
        tool_status["missing_required"] = ["bcl-convert or bcl2fastq"]
    tool_status["runtime"] = runtime_preflight
    if runtime_preflight.get("errors"):
        tool_status["ok"] = False
        tool_status["runtime_errors"] = runtime_preflight["errors"]

    write_json(
        run_dir / "config.json",
        {
            "run_folder": str(args.run_folder.expanduser().resolve()),
            "sample_sheet": str(args.sample_sheet.expanduser().resolve()),
            "output_directory": str(args.output_directory.expanduser().resolve()),
        },
    )
    write_json(
        run_dir / "validation" / "input_summary.json",
        {
            "run_folder": validation["run_folder"],
            "sample_sheet": validation["sample_sheet"],
            "output_directory": validation["output_directory"],
        },
    )
    write_json(run_dir / "validation" / "validation_summary.json", validation)
    write_json(run_dir / "validation" / "runtime_preflight.json", runtime_preflight)
    write_json(run_dir / "validation" / "runinfo.json", validation["runinfo"])
    write_json(run_dir / "validation" / "runparameters.json", validation["runparameters"])
    write_json(
        run_dir / "validation" / "samplesheet_summary.json", validation["sample_sheet_summary"]
    )
    write_json(run_dir / "validation" / "tool_preflight.json", tool_status)
    write_commands(run_dir, args, converter)
    write_json(
        run_dir / "versions" / "software_versions.json",
        software_versions(
            {"bcl-convert": ["bcl-convert", "--version"], "bcl2fastq": ["bcl2fastq", "--version"]}
        ),
    )

    dry_run = {
        "ok": validation["ok"] and (converter is not None or not args.execute),
        "detail": "run folder and sample sheet validation completed",
    }
    write_json(run_dir / "logs" / "validation_dry_run.json", dry_run)
    execution = None
    report_bundle = None
    status = "blocked" if not dry_run["ok"] else "validated"
    if args.execute and validation["ok"] and converter:
        execution = execute_conversion(run_dir, args, converter, runtime_preflight)
        status = "completed" if execution.get("ok") else "failed"
        if execution.get("ok"):
            report_bundle = parse_report_bundle(args.output_directory.expanduser().resolve())
            if report_bundle:
                write_json(run_dir / "qc" / "demux_qc_summary.json", report_bundle)
    elif args.execute and not converter:
        execution = {"ok": False, "reason": "bcl-convert or bcl2fastq is not installed"}
        status = "blocked"

    write_standard_manifest(
        run_dir,
        run_id=args.run_id,
        lane="bcl_to_fastq",
        workflow="local_bcl_convert_or_bcl2fastq",
        status=status,
        execute_requested=args.execute,
        validation=validation,
        tool_preflight_result=tool_status,
        dry_run=dry_run,
        execution=execution,
        inputs={"run_folder": validation["run_folder"], "sample_sheet": validation["sample_sheet"]},
        outputs={
            "output_directory": validation["output_directory"],
            "conversion_logs": "logs/bcl_conversion.log",
        },
        method={
            "converter": converter,
            "converter_selection": "bcl-convert preferred, bcl2fastq fallback",
        },
        review_bundle={"demux_qc": report_bundle} if report_bundle else {},
    )
    write_summary(run_dir, status, validation, converter, runtime_preflight, report_bundle)
    extra_roots = (
        {"output_directory": args.output_directory.expanduser().resolve()}
        if report_bundle
        else None
    )
    write_json(
        run_dir / "artifact_index.json", build_artifact_index(run_dir, extra_roots=extra_roots)
    )
    print(run_dir)
    return 1 if status in {"blocked", "failed"} else 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: fbfcdfd27b5c70d8908d4b678af63a93567118b8f175f8273c74ce1298fbda1d