File size: 6,650 Bytes
ecbb458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
#!/usr/bin/env python3
"""iHARP risk visualization entry point (enhanced script).

Reads a batch_annotate.py summary CSV and renders a publication-style horizontal
bar chart of the top-N compounds by an RQmodified column (risk ranking + visualization).

This is an ENHANCED script: it depends on matplotlib + pandas (not part of the
standard library). The core calculation scripts (er_rqmodified / ahr_rqmodified /
batch_annotate / query_toxcast / xlsx_reader) remain pure standard-library and do
not require these dependencies.

Usage:
    python risk_visualize.py --input ER_CS_summary.csv --column RQmodified_ag --column RQmodified_ant
    python risk_visualize.py --input ER_CS_summary.csv --column RQmodified_ag --top 20

Default --column: RQmodified_ag, RQmodified_ant (ER). AhR summaries only contain
RQmodified_ag; missing columns are skipped with a warning.

Output: one PNG per requested column, named <input_stem>_<column>.png under
--output-dir (default current directory). PNG only.
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

try:
    import matplotlib

    matplotlib.use("Agg")  # headless environment (CLI / WSL): no display available
    import matplotlib.pyplot as plt
    import pandas as pd
except ImportError as exc:  # enhanced-script dependencies missing -> report clearly
    matplotlib = plt = pd = None  # type: ignore[assignment]
    _IMPORT_ERROR = exc
else:
    _IMPORT_ERROR = None

RULE_VERSION = "iharp_skill_v1"

DEFAULT_COLUMNS = ["RQmodified_ag", "RQmodified_ant"]


def render_hbar(
    df: pd.DataFrame,
    column: str,
    compound_col: str,
    top: int,
    out_path: Path,
) -> tuple[int, str | None]:
    """Render a horizontal bar chart of top-N compounds by `column`.

    Returns (plotted_count, error_or_None).
    """
    sub = df[[compound_col, column]].copy()
    sub[column] = pd.to_numeric(sub[column], errors="coerce")
    sub = sub.dropna(subset=[compound_col, column])
    sub = sub[sub[compound_col].astype(str).str.strip() != ""]
    if sub.empty:
        return 0, f"no valid numeric values in column {column!r}"

    # risk ranking: descending by value, keep top-N, then ascending so the highest sits on top
    top_df = (
        sub.sort_values(by=column, ascending=False)
        .head(top)
        .sort_values(by=column, ascending=True)
    )

    values = top_df[column].astype(float)
    vmin = float(values.min())
    vmax = float(values.max())
    if vmax - vmin < 1e-12:  # guard against a zero range (single value / all equal)
        vmin = 0.0 if vmax > 0 else vmax - 1.0
        vmax = vmax if vmax > 0 else 0.0
    norm = plt.Normalize(vmin, vmax)
    colors = plt.cm.YlOrRd(norm(values))

    plt.rcParams["font.sans-serif"] = ["Arial", "DejaVu Sans"]
    plt.rcParams["axes.unicode_minus"] = False
    fig, ax = plt.subplots(figsize=(7, 6.5), dpi=300)

    bars = ax.barh(
        top_df[compound_col].astype(str),
        values,
        color=colors,
        height=0.68,
        edgecolor="#333333",
        linewidth=0.5,
    )

    # annotate each bar end with its value
    for bar in bars:
        width = bar.get_width()
        ax.text(
            width + (vmax * 0.01),
            bar.get_y() + bar.get_height() / 2,
            f"{width:.3f}",
            va="center",
            ha="left",
            fontsize=14,
            color="#333333",
        )

    ax.set_xlabel(column, fontsize=10, fontweight="bold")
    ax.set_ylabel("Top Candidate Compounds", fontsize=10, fontweight="bold")
    ax.set_xlim(0, vmax * 1.15)
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.tick_params(axis="both", which="major", labelsize=8.5)

    plt.tight_layout()
    plt.savefig(out_path, dpi=300)
    plt.close(fig)
    return int(len(top_df)), None


def main() -> int:
    parser = argparse.ArgumentParser(
        description="iHARP risk visualization (top-N RQmodified horizontal bar chart)"
    )
    parser.add_argument("--input", required=True, help="summary CSV from batch_annotate.py")
    parser.add_argument(
        "--column",
        action="append",
        help="RQmodified column to plot; may be repeated (default: RQmodified_ag, RQmodified_ant)",
    )
    parser.add_argument("--top", type=int, default=20, help="number of top compounds (default 20)")
    parser.add_argument(
        "--compound-col", default="CompoundName", help="compound name column (default CompoundName)"
    )
    parser.add_argument("--output-dir", default=".", help="output directory (default current directory)")
    args = parser.parse_args()

    if _IMPORT_ERROR is not None:
        print(json.dumps({
            "ok": False,
            "error": "missing required dependencies (matplotlib, pandas); install with: pip install matplotlib pandas",
            "detail": str(_IMPORT_ERROR),
        }, ensure_ascii=False))
        return 3

    input_path = Path(args.input)
    if not input_path.is_file():
        print(json.dumps({"ok": False, "error": f"input file not found: {input_path}"}, ensure_ascii=False))
        return 2

    columns = args.column or DEFAULT_COLUMNS
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    try:
        df = pd.read_csv(input_path)
    except Exception as exc:
        print(json.dumps({"ok": False, "error": f"failed to read CSV: {exc}"}, ensure_ascii=False))
        return 2

    if args.compound_col not in df.columns:
        print(
            json.dumps(
                {
                    "ok": False,
                    "error": f"compound column {args.compound_col!r} not found; actual columns: {list(df.columns)}",
                },
                ensure_ascii=False,
            )
        )
        return 2

    input_stem = input_path.stem
    files: dict[str, str] = {}
    skipped: list[str] = []
    for col in columns:
        if col not in df.columns:
            skipped.append(f"{col} (column not found)")
            continue
        out_path = output_dir / f"{input_stem}_{col}.png"
        count, err = render_hbar(df, col, args.compound_col, args.top, out_path)
        if err:
            skipped.append(f"{col}: {err}")
            continue
        files[col] = str(out_path)

    result = {
        "ok": True,
        "rule_version": RULE_VERSION,
        "input": str(input_path),
        "top": args.top,
        "compound_col": args.compound_col,
        "files": files,
        "skipped": skipped,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0


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