Files

683 lines
28 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
Combo Plot Stage - Tree + Motif + Structure + Domain 组合图
将多个分析阶段的结果整合到一张图中:
- Panel A: Phylogenetic tree
- Panel B: Subfamily
- Panel C: Motif
- Panel D: Gene structure
- Panel E: Domain
"""
import os
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# 项目根目录
PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute()
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from gfa_core.gene_family_scope import read_scope, sanitize_token, scope_hash
from gfa_core.gene_family_stage_context import build_stage_context, user_output_prefix_for, write_legacy_manifest_copy
# 镜像标准形态: 当前解释器即环境解释器; Rscript 由 PATH 查找。
RSCRIPT_PATH = str(shutil.which("Rscript") or "Rscript")
CONDA_PREFIX = Path(sys.executable).resolve().parent.parent
CONDA_BIN = str(CONDA_PREFIX / "bin")
def find_file_by_glob(pattern: Path, name: str) -> Optional[Path]:
"""递归查找文件"""
matches = list(pattern.rglob(name))
return matches[0] if matches else None
def find_meme_xml(workspace: Path, prefix: str) -> Optional[Path]:
"""查找 MEME XML 文件"""
# 优先在 motif/analysis/meme_out/ 中查找
meme_dir = workspace / "motif" / "analysis" / "meme_out"
if meme_dir.exists():
xml_file = meme_dir / "meme.xml"
if xml_file.exists():
return xml_file
# 备选:递归搜索
return find_file_by_glob(workspace, "meme.xml")
def resolve_meme_xml_from_manifest(workspace: Path, motif_data: Dict, prefix: str, manifest_path: Path) -> Optional[Path]:
artifacts = motif_data.get("artifacts", {}) or {}
meme_xml = artifacts.get("meme_xml") if isinstance(artifacts, dict) else None
candidates = []
if isinstance(meme_xml, dict):
path = manifest_artifact_path(workspace, manifest_path, meme_xml)
if path:
candidates.append(path)
meme_results = artifacts.get("meme_results") if isinstance(artifacts, dict) else None
if isinstance(meme_results, dict):
path = manifest_artifact_path(workspace, manifest_path, meme_results)
if path:
candidates.append(path / "meme.xml")
motif_txt = artifacts.get("motif_prot_txt") if isinstance(artifacts, dict) else None
if isinstance(motif_txt, dict):
path = manifest_artifact_path(workspace, manifest_path, motif_txt)
if path:
candidates.append(path.parent / "meme_out" / "meme.xml")
for candidate in candidates:
if candidate.exists() and candidate.stat().st_size > 0:
return candidate.absolute()
return find_meme_xml(workspace, prefix)
def load_manifest(manifest_path: Path) -> Dict:
"""加载 manifest 文件"""
if not manifest_path.exists():
raise FileNotFoundError(f"Manifest not found: {manifest_path}")
return json.loads(manifest_path.read_text(encoding="utf-8"))
def motif_manifest_failure_message(motif_data: Dict) -> Optional[str]:
status = str((motif_data or {}).get("status") or "").strip().lower()
if status in {"", "success", "partial_success"}:
return None
params = motif_data.get("parameters") if isinstance(motif_data, dict) else {}
params = params if isinstance(params, dict) else {}
detail = (
motif_data.get("error")
or motif_data.get("message")
or params.get("error")
or params.get("message")
or status
)
return f"Motif upstream failed: {detail}"
def manifest_artifact_path(workspace: Path, manifest_path: Path, artifact: Dict) -> Optional[Path]:
if not isinstance(artifact, dict):
return None
input_dir = workspace.parent.parent / "input" / workspace.name
def inside_current_job(path: Path) -> bool:
try:
resolved = path.resolve()
return resolved.is_relative_to(workspace.resolve()) or resolved.is_relative_to(input_dir.resolve())
except OSError:
return False
def rebase_current_job_path(path: Path) -> Optional[Path]:
parts = path.parts
for root_name, current_root in (("workspace", workspace), ("input", input_dir)):
for idx, part in enumerate(parts):
if part != root_name:
continue
if idx + 1 >= len(parts) or parts[idx + 1] != workspace.name:
continue
suffix = Path(*parts[idx + 2 :]) if idx + 2 < len(parts) else Path()
candidate = current_root / suffix
if candidate.exists():
return candidate
return None
for key in ("path", "relpath_or_hint", "file", "full_path"):
raw = str(artifact.get(key) or "").strip()
if not raw:
continue
path = Path(raw)
if path.is_absolute():
if inside_current_job(path):
return path
rebased = rebase_current_job_path(path)
if rebased:
return rebased
continue
candidates = []
if str(path).startswith("final/"):
candidates.append(workspace / path)
candidates.append(manifest_path.parent / path.name)
else:
candidates.append(workspace / path)
candidates.append(manifest_path.parent / path)
candidates.append(manifest_path.parent / path.name)
for candidate in candidates:
if candidate.exists():
return candidate
return None
def materialize_aggregate_subfamily_file(workspace: Path, prefix: str, scope_data: Optional[Dict]) -> Optional[Path]:
"""Build a combined subfamily table from per-target reference-classification manifests."""
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
if not scope_id:
return None
final_dir = workspace / "final"
aggregate_manifest = final_dir / f"{prefix}.{sanitize_token(scope_id)}.reference_classification.manifest.json"
if not aggregate_manifest.exists():
return None
try:
aggregate = load_manifest(aggregate_manifest)
except Exception:
return None
rows = []
for artifact in (aggregate.get("artifacts") or {}).values():
child_manifest = manifest_artifact_path(workspace, aggregate_manifest, artifact)
if not child_manifest or not child_manifest.exists() or not child_manifest.name.endswith(".manifest.json"):
continue
try:
child = load_manifest(child_manifest)
except Exception:
continue
child_artifacts = child.get("artifacts") or {}
sub_artifact = child_artifacts.get("subfamily_file") or child_artifacts.get("geneID_subfamily_compat")
sub_path = manifest_artifact_path(workspace, child_manifest, sub_artifact)
if not sub_path or not sub_path.exists():
continue
for raw in sub_path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.strip()
if not line or line.lower().startswith("gene"):
continue
rows.append(line)
if not rows:
return None
out = final_dir / f"{prefix}.{sanitize_token(scope_id)}.reference_classification.subfamily.tsv"
seen = set()
with out.open("w", encoding="utf-8") as handle:
for line in rows:
key = line.split("\t", 1)[0] if "\t" in line else line.split(None, 1)[0]
if key in seen:
continue
seen.add(key)
handle.write(line + "\n")
print(f"[INFO] Materialized aggregate subfamily file: {out}")
return out
def find_latest_manifest(workspace: Path, stage: str, prefix: str, scope_data: Optional[Dict] = None) -> Optional[Path]:
"""查找最新的 manifest 文件"""
final_dir = workspace / "final"
if not final_dir.exists():
return None
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
if scope_data and scope_data.get("scope_id"):
scoped = final_dir / f"{prefix}.{sanitize_token(scope_id)}.{stage}.manifest.json"
if scoped.exists():
return scoped
compact = final_dir / f"{compact_output_prefix(prefix, scope_data)}.{stage}.manifest.json"
if compact.exists():
return compact
pattern = f"*{stage}*.manifest.json"
candidates = list(final_dir.glob(pattern))
if scope_id:
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
if scope_id in path.name:
return path
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if data.get("stage") == stage and data.get("scope_id") == scope_id:
return path
# 按修改时间排序,返回最新的
if candidates:
return max(candidates, key=lambda p: p.stat().st_mtime)
return None
def compact_output_prefix(prefix: str, scope_data: Optional[Dict]) -> str:
"""Return a bounded user-facing output prefix.
Full scope details stay in scope JSON and manifest. Result filenames should
not enumerate every target/reference/comparative species.
"""
if not scope_data:
return prefix
family = str(scope_data.get("family") or (prefix.split(".")[-1] if "." in prefix else "")).strip()
mode = str(scope_data.get("scope_mode") or "").strip()
targets = [str(x) for x in scope_data.get("target_species") or [] if str(x).strip()]
scope_id = str(scope_data.get("scope_id") or "").strip()
if len(targets) == 1 and family:
return ".".join(x for x in [targets[0], family, mode] if x)
if mode:
base = prefix if prefix else f"Target.{family}".strip(".")
if scope_id:
return f"{base}.{mode}.h{scope_hash(scope_id, 6)}"
return f"{base}.{mode}"
return prefix
def sanitize_output_label(label: str) -> str:
value = str(label or "").strip()
if not value:
return ""
safe = []
for ch in value:
if ch.isalnum() or ch in {"_", "-", "."}:
safe.append(ch)
else:
safe.append("_")
cleaned = "".join(safe).strip("._-")
return cleaned[:80]
def normalize_panels(raw_panels: str) -> List[str]:
allowed = ["motif", "structure", "domain"]
aliases = {
"gene_structure": "structure",
"gene-structure": "structure",
"genestructure": "structure",
"domains": "domain",
"motifs": "motif",
"all": "all",
}
selected: List[str] = []
for token in str(raw_panels or "").replace(";", ",").split(","):
key = token.strip().lower().replace(" ", "_")
if not key:
continue
key = aliases.get(key, key)
if key == "all":
return allowed
if key not in allowed:
raise ValueError(f"Unsupported combo panel: {token}. Allowed: {', '.join(allowed)}")
if key not in selected:
selected.append(key)
selected = selected or allowed
if len(selected) < 2:
raise ValueError("Combo plot requires at least two data panels; single-panel views already exist in motif/structure/domain stages.")
return selected
def run_combo_plot(
workspace: Path,
prefix: str,
output_prefix: str,
tree_file: Path,
subfamily_file: Optional[Path],
meme_xml: Optional[Path],
gff3_file: Optional[Path],
domtblout_file: Optional[Path],
panels: List[str],
output_label: str = "",
evalue_cutoff: float = 1e-5,
coverage_percent: Optional[float] = None,
rscript: Optional[Path] = None,
) -> Dict:
"""
运行 R 脚本绘制组合图
"""
if rscript is None:
rscript = SCRIPTS_DIR / "combo_tree_motif_structure_domain.R"
if not rscript.exists():
return {
"success": False,
"error": f"R script not found: {rscript}"
}
# 输出文件。修订图使用独立 stem,避免覆盖标准 combo 结果。
label = sanitize_output_label(output_label)
output_stem = f"{output_prefix}.{label}" if label else output_prefix
output_pdf = workspace / "final" / f"{output_stem}.combo.pdf"
# 构建命令
cmd = [
str(RSCRIPT_PATH), str(rscript),
str(tree_file),
str(subfamily_file) if subfamily_file else "NONE", # 传递 NONE 表示无亚家族
str(meme_xml) if meme_xml else "NONE",
str(gff3_file) if gff3_file else "NONE",
str(domtblout_file) if domtblout_file else "NONE",
str(evalue_cutoff), # E-value cutoff
]
# 添加 coverage(可选)
if coverage_percent is not None:
cmd.append(str(coverage_percent))
else:
cmd.append("0") # 不启用 coverage 过滤
# 添加输出文件和 panel 选择
cmd.append(str(output_pdf))
cmd.append(",".join(panels))
print(f"[CMD] {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
timeout=300
)
output_png = output_pdf.with_suffix(".png")
success = result.returncode == 0 and output_pdf.exists()
if not success and not result.stderr:
result.stderr = result.stdout
return {
"success": success,
"output_pdf": str(output_pdf) if success else None,
"output_png": str(output_png) if success and output_png.exists() else None,
"returncode": result.returncode,
"stdout": result.stdout[-2000:] if result.stdout else "",
"stderr": result.stderr[-2000:] if result.stderr else "",
}
except Exception as e:
return {"success": False, "error": str(e)}
def promote_scoped_combo_outputs(workspace: Path, prefix: str, scope_id: str, result: Dict, output_label: str = "") -> Dict:
"""Materialize legacy scoped combo copies without changing canonical outputs."""
if not scope_id or not result.get("success"):
return result
scoped_prefix = f"{prefix}.{sanitize_token(scope_id)}"
label = sanitize_output_label(output_label)
scoped_stem = f"{scoped_prefix}.{label}" if label else scoped_prefix
final_dir = workspace / "final"
promoted = dict(result)
for key, suffix in (("output_pdf", ".combo.pdf"), ("output_png", ".combo.png")):
source_text = promoted.get(key)
if not source_text:
continue
source = Path(source_text)
if not source.exists():
continue
target = final_dir / f"{scoped_stem}{suffix}"
if source.resolve() != target.resolve():
shutil.copy2(source, target)
promoted[f"legacy_{key}"] = str(target)
return promoted
def main():
parser = argparse.ArgumentParser(
description="Combo Plot: Tree + Motif + Structure + Domain"
)
parser.add_argument("--prefix", required=True, help="Analysis prefix (e.g., Ft.SPL)")
parser.add_argument("--workspace", required=True, help="Workspace directory")
parser.add_argument("--evalue", type=float, default=1e-5, help="E-value cutoff")
parser.add_argument("--coverage", type=float, default=None, help="Coverage percent (0-100)")
parser.add_argument("--subfamily", type=str, default=None, help="Subfamily file path")
parser.add_argument("--scope-file", type=str, default=None, help="Gene-family scope JSON from resolve_scope.py")
parser.add_argument("--analysis-route", type=str, default="", help="Analysis route identity, e.g. single_species/subfamily_prediction")
parser.add_argument("--member-set-id", type=str, default="", help="Member set identity for scoped outputs")
parser.add_argument("--cpu", type=int, default=8, help="CPU cores (unused, for compatibility)")
parser.add_argument("--run-id", type=str, default=None, help="Run ID")
parser.add_argument("--replot", action="store_true", help="Replot even if exists")
parser.add_argument("--panels", type=str, default="motif,structure,domain", help="Comma-separated panels to show; choose at least two from motif,structure,domain")
parser.add_argument("--output-label", type=str, default="", help="Optional revision label inserted before .combo; keeps standard combo outputs from being overwritten")
args = parser.parse_args()
workspace = Path(args.workspace)
prefix = args.prefix
try:
panels = normalize_panels(args.panels)
except ValueError as exc:
print(f"[ERROR] {exc}")
sys.exit(1)
output_label = sanitize_output_label(args.output_label)
scope_data: Optional[Dict] = None
scope_file: Optional[Path] = None
if args.scope_file:
scope_file = Path(args.scope_file)
if not scope_file.exists():
print(f"[ERROR] Scope file not found: {args.scope_file}")
sys.exit(1)
scope_data = read_scope(scope_file)
print(f"[INFO] Combo plot stage for {prefix}")
print(f"[INFO] Workspace: {workspace}")
print(f"[INFO] Panels: {', '.join(panels)}")
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
output_prefix = user_output_prefix_for(
"combo",
prefix,
scope_data or {},
analysis_route=args.analysis_route,
member_set_id=args.member_set_id,
) if scope_id else prefix
if output_prefix != prefix:
print(f"[INFO] Output prefix: {output_prefix}")
# 1. 查找 tree 文件
phylo_manifest = find_latest_manifest(workspace, "phylogeny", prefix, scope_data)
if not phylo_manifest:
print("[ERROR] Phylogeny manifest not found")
sys.exit(1)
phylo_data = load_manifest(phylo_manifest)
tree_file = manifest_artifact_path(workspace, phylo_manifest, phylo_data.get("artifacts", {}).get("tree", {}))
if not tree_file or not tree_file.exists():
print("[ERROR] Tree file not found")
sys.exit(1)
print(f"[INFO] Tree file: {tree_file}")
# 2. 查找 subfamily 文件
if args.subfamily:
subfamily_file = Path(args.subfamily)
else:
# 自动查找只接受 confirmed_subfamily;prediction/geneID 必须由 --subfamily 显式传入。
confirmed_name = f"{prefix}.confirmed_subfamily.tsv"
scoped_confirmed_name = f"{output_prefix}.confirmed_subfamily.tsv"
search_paths = [
workspace / "final" / scoped_confirmed_name,
workspace / "final" / confirmed_name,
workspace / "input" / confirmed_name,
workspace.parent.parent / "input" / workspace.name / confirmed_name,
]
if scope_data and scope_data.get("scope_mode") in {"target_only", "target_plus_reference"}:
family = str(scope_data.get("family") or (prefix.split(".")[-1] if "." in prefix else "")).strip()
targets = [str(x) for x in scope_data.get("target_species") or [] if str(x).strip()]
refs = [str(x) for x in scope_data.get("reference_species") or [] if str(x).strip()]
if len(targets) == 1 and family:
cross_prefix = f"{targets[0]}.{family}.target_plus_reference"
scoped_prediction = None
if refs:
scoped_prediction = f"Target.{family}.target-{targets[0]}.ref-{'-'.join(refs)}.subfamily_prediction"
extra_paths = [
workspace / "final" / f"{cross_prefix}.confirmed_subfamily.tsv",
]
if scoped_prediction:
extra_paths.extend([
workspace / "final" / f"{scoped_prediction}.confirmed_subfamily.tsv",
])
search_paths[0:0] = extra_paths
subfamily_file = None
for p in search_paths:
if p.exists():
subfamily_file = p
break
# Do not materialize aggregate prediction labels during automatic lookup.
# Unconfirmed prediction tables require an explicit --subfamily path.
if not subfamily_file or not subfamily_file.exists():
print("[INFO] Subfamily file not found, will run without subfamily coloring")
subfamily_file = None
else:
print(f"[INFO] Subfamily file: {subfamily_file}")
# 3. 查找所选 panel 需要的输入文件
meme_xml: Optional[Path] = None
if "motif" in panels:
motif_manifest = find_latest_manifest(workspace, "motif", prefix, scope_data)
if not motif_manifest:
print("[ERROR] Motif manifest not found")
sys.exit(1)
motif_data = load_manifest(motif_manifest)
motif_failure = motif_manifest_failure_message(motif_data)
if motif_failure:
print(f"[ERROR] {motif_failure}")
sys.exit(1)
meme_xml = resolve_meme_xml_from_manifest(workspace, motif_data, prefix, motif_manifest)
if not meme_xml or not meme_xml.exists():
print("[ERROR] MEME XML not found")
sys.exit(1)
print(f"[INFO] MEME XML: {meme_xml}")
gff3_file: Optional[Path] = None
if "structure" in panels:
struct_manifest = find_latest_manifest(workspace, "structure", prefix, scope_data)
if not struct_manifest:
print("[ERROR] Structure manifest not found")
sys.exit(1)
struct_data = load_manifest(struct_manifest)
gff3_file = manifest_artifact_path(workspace, struct_manifest, struct_data.get("artifacts", {}).get("structure_gff3_filtered", {}))
if not gff3_file or not gff3_file.exists():
print("[ERROR] Structure filtered GFF3 not found")
sys.exit(1)
print(f"[INFO] GFF3 file: {gff3_file}")
domtblout_file: Optional[Path] = None
if "domain" in panels:
domain_manifest = find_latest_manifest(workspace, "domain", prefix, scope_data)
if not domain_manifest:
print("[ERROR] Domain manifest not found")
sys.exit(1)
domain_data = load_manifest(domain_manifest)
domtblout_file = manifest_artifact_path(workspace, domain_manifest, domain_data.get("artifacts", {}).get("domtblout", {}))
if not domtblout_file or not domtblout_file.exists():
print("[ERROR] Domain domtblout not found")
sys.exit(1)
print(f"[INFO] domtblout file: {domtblout_file}")
# 6. 运行组合图
result = run_combo_plot(
workspace=workspace,
prefix=prefix,
output_prefix=output_prefix,
tree_file=tree_file,
subfamily_file=subfamily_file,
meme_xml=meme_xml,
gff3_file=gff3_file,
domtblout_file=domtblout_file,
panels=panels,
output_label=output_label,
evalue_cutoff=args.evalue,
coverage_percent=args.coverage,
)
result = promote_scoped_combo_outputs(workspace, prefix, scope_id, result, output_label=output_label)
if result.get("success"):
print(f"[OK] Combo plot generated: {result['output_pdf']}")
# 检查 PNG 是否存在
png_path = result.get("output_png")
png_exists = png_path and Path(png_path).exists() if png_path else False
# 生成 manifest
artifact_base = "combo_replot" if output_label else "combo_plot"
artifacts = {
f"{artifact_base}_pdf": {
"artifact": artifact_base,
"path": str(Path(result["output_pdf"]).relative_to(workspace)),
"full_path": result["output_pdf"],
"type": "image",
"format": "pdf",
"description": f"Tree + {' + '.join(p.title() for p in panels)} combo visualization"
}
}
if png_exists:
artifacts[f"{artifact_base}_png"] = {
"artifact": artifact_base,
"path": str(Path(png_path).relative_to(workspace)),
"full_path": png_path,
"type": "image",
"format": "png",
"description": f"Tree + {' + '.join(p.title() for p in panels)} combo visualization (PNG)"
}
stage_context = build_stage_context(
stage="combo",
prefix=prefix,
scope_data=scope_data or {},
scope_file=str(scope_file or ""),
output_prefix=output_prefix,
subfamily_file=str(subfamily_file or ""),
analysis_route=args.analysis_route or "",
member_set_id=args.member_set_id or "",
).to_dict()
manifest = {
"manifest_version": "1.0",
"job_id": workspace.name,
"stage": "combo",
"run_id": args.run_id or "unknown",
"prefix": prefix,
"output_prefix": output_prefix,
"status": "success",
"analysis_route": stage_context.get("analysis_route", ""),
"result_group": stage_context.get("result_group", ""),
"member_set_id": stage_context.get("member_set_id", ""),
"subfamily_source": stage_context.get("subfamily_source", ""),
"stage_context": stage_context,
"artifacts": artifacts,
"parameters": {
"evalue": args.evalue,
"coverage": args.coverage,
"output_prefix": output_prefix,
"output_label": output_label,
"subfamily_file": str(subfamily_file),
"tree_file": str(tree_file),
"meme_xml": str(meme_xml or ""),
"gff3_file": str(gff3_file or ""),
"domtblout_file": str(domtblout_file or ""),
"panels": panels,
"analysis_route": stage_context.get("analysis_route", ""),
"member_set_id": stage_context.get("member_set_id", ""),
"subfamily_source": stage_context.get("subfamily_source", ""),
}
}
if scope_data:
manifest["scope_id"] = scope_data.get("scope_id", "")
manifest["scope_mode"] = scope_data.get("scope_mode", "")
manifest["scope_file"] = str(scope_file)
manifest["target_species"] = scope_data.get("target_species", [])
manifest["reference_species"] = scope_data.get("reference_species", [])
manifest["comparative_species"] = scope_data.get("comparative_species", [])
manifest["selected_species"] = scope_data.get("selected_species", [])
manifest["scope"] = {
"family": scope_data.get("family", ""),
"prefix": scope_data.get("prefix", ""),
"scope_id": scope_data.get("scope_id", ""),
"scope_mode": scope_data.get("scope_mode", ""),
"target_species": scope_data.get("target_species", []),
"reference_species": scope_data.get("reference_species", []),
"comparative_species": scope_data.get("comparative_species", []),
"selected_species": scope_data.get("selected_species", []),
}
manifest_stem = f"{output_prefix}.{output_label}" if output_label else output_prefix
manifest_path = workspace / "final" / f"{manifest_stem}.combo.manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
print(f"[INFO] Manifest: {manifest_path}")
if scope_data and scope_data.get("scope_id") and not output_label:
scoped_manifest = write_legacy_manifest_copy(manifest_path, prefix, str(scope_data.get("scope_id") or ""), "combo")
if scoped_manifest:
print(f"[INFO] Legacy scoped manifest: {scoped_manifest}")
else:
print(f"[ERROR] Combo plot failed: {result.get('error', result.get('stderr', 'unknown'))}")
if result.get("stdout"):
print(result["stdout"])
sys.exit(1)
if __name__ == "__main__":
main()