python
#!/usr/bin/env python3
"""
Parse a Cadence Innovus-style ASCII timing debug report into normalized JSON,
with optional CSV export.
This is v3 of the parser. Behavior and output schema are intended to be the
same as v2; this file mainly updates documentation/CLI wording.
This version is more robust than the first draft and is designed for reports like:
- metadata header lines starting with '#'
- path-group ID mapping table: `ID Number | Path Group Name`
- timing summary tables: `Setup mode` / `Hold mode`
- multiple named timing sections under the same mode, e.g. corners/views like:
func....setup
shift....setup
- DRV summary table
- scalar tail lines like Density / Routing Overflow
Example usage:
python parse_timing_report_v3.py timing_debug_report.rpt -o timing_debug_report.json
python parse_timing_report_v3.py timing_debug_report.rpt -o timing_debug_report.json --csv-dir out_csv
CSV export files:
- timing_groups.csv : one row per (mode, section, group)
- drvs.csv : one row per drv type
- scalars.csv : simple key/value scalar outputs
Design goals:
1. Parse raw text, not screenshots.
2. Be tolerant to incomplete / slightly malformed lines.
3. Preserve warnings instead of failing hard.
4. Normalize timing data so agents can consume JSON/CSV instead of ASCII art.
"""
from __future__ import annotations
import argparse
import csv
import gzip
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
BORDER_RE = re.compile(r"^[\s+\-|=]+$")
MODE_RE = re.compile(r"\b(setup|hold)\s+mode\b", re.IGNORECASE)
# Header group cells may start with either `1:...` or `[1:...]` (sometimes with a closing `]`).
# We only need the leading numeric ID for mapping lookup; keep display_name unchanged.
GROUP_ID_IN_HEADER_RE = re.compile(r"^\s*\[?\s*(\d+)\s*:")
INT_RE = re.compile(r"^[+-]?\d+$")
FLOAT_RE = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
PAIR_RE = re.compile(r"^\s*([+-]?\d+)\s*\((\s*[+-]?\d+\s*)\)\s*$")
DENSITY_RE = re.compile(r"^\s*Density\s*:\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*%\s*$", re.IGNORECASE)
ROUTING_OVERFLOW_RE = re.compile(
r"^\s*Routing\s+Overflow\s*:\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*%\s*H\s*and\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*%\s*V\s*$",
re.IGNORECASE,
)
CANONICAL_METRIC_KEYS = {
"wns": "wns_ns",
"tns": "tns_ns",
"violating paths": "violating_paths",
"all paths": "all_paths",
}
KNOWN_TIMING_METRICS = ["wns_ns", "tns_ns", "violating_paths", "all_paths"]
# -----------------------------------------------------------------------------
# Generic helpers
# -----------------------------------------------------------------------------
def is_border_line(line: str) -> bool:
stripped = line.strip()
return bool(stripped) and bool(BORDER_RE.fullmatch(stripped))
def split_table_row(line: str) -> List[str]:
"""Split an ASCII table row by `|` and trim cells.
Outer borders create empty first/last cells, which are removed.
Inner empty cells are preserved.
"""
parts = [cell.strip() for cell in line.rstrip("\n").split("|")]
if parts and parts[0] == "":
parts = parts[1:]
if parts and parts[-1] == "":
parts = parts[:-1]
return parts
def normalize_metric_name(raw: str) -> str:
s = raw.strip().rstrip(":")
s = re.sub(r"\s+", " ", s)
s = s.replace("(ns)", "").strip().lower()
return CANONICAL_METRIC_KEYS.get(s, s.replace(" ", "_"))
def parse_scalar(text: str) -> Any:
s = text.strip()
if not s:
return None
upper = s.upper()
if upper in {"N/A", "NA", "--", "NONE"}:
return None
pair_match = PAIR_RE.fullmatch(s)
if pair_match:
return {
"first": int(pair_match.group(1)),
"second": int(pair_match.group(2).strip()),
"raw": s,
}
s_no_commas = s.replace(",", "")
if INT_RE.fullmatch(s_no_commas):
try:
return int(s_no_commas)
except ValueError:
pass
if FLOAT_RE.fullmatch(s_no_commas):
try:
value = float(s_no_commas)
if value.is_integer() and ("." not in s_no_commas) and ("e" not in s_no_commas.lower()):
return int(value)
return value
except ValueError:
pass
return s
def next_nonempty_line(lines: List[str], start_idx: int) -> Tuple[Optional[int], Optional[str]]:
i = start_idx
while i < len(lines):
if lines[i].strip():
return i, lines[i]
i += 1
return None, None
def values_from_cells(cells: List[str], expected_groups: int, assume_labeled: bool) -> List[str]:
"""Normalize row cells into exactly expected_groups value cells.
Tolerant behaviors:
- labeled rows usually look like [metric_label, v1, v2, ...]
- unlabeled rows under corner sections may look like ["", v1, v2, ...]
- if malformed but longer than expected, keep the rightmost expected_groups cells
- if shorter, pad with empty strings
"""
if expected_groups <= 0:
return []
if assume_labeled:
candidate = cells[1:] if len(cells) >= 2 else []
else:
if len(cells) == expected_groups + 1 and cells and cells[0] == "":
candidate = cells[1:]
elif len(cells) == expected_groups:
candidate = cells
elif len(cells) > expected_groups:
candidate = cells[-expected_groups:]
else:
candidate = cells
if len(candidate) < expected_groups:
candidate = candidate + [""] * (expected_groups - len(candidate))
elif len(candidate) > expected_groups:
candidate = candidate[:expected_groups]
return candidate
# -----------------------------------------------------------------------------
# Metadata / scalar parsing
# -----------------------------------------------------------------------------
def parse_metadata(lines: List[str]) -> Dict[str, Any]:
metadata: Dict[str, Any] = {}
banner_lines: List[str] = []
for line in lines:
stripped = line.strip()
if not stripped.startswith("#"):
continue
body = stripped.lstrip("#").strip()
if not body:
continue
if ":" in body:
key, value = body.split(":", 1)
metadata[key.strip().lower().replace(" ", "_")] = value.strip()
else:
banner_lines.append(body)
if banner_lines:
metadata["banner_lines"] = banner_lines
return metadata
def parse_scalar_tail(lines: List[str]) -> Dict[str, Any]:
scalars: Dict[str, Any] = {}
for line in lines:
stripped = line.strip()
if not stripped:
continue
m_density = DENSITY_RE.match(stripped)
if m_density:
scalars["density_pct"] = float(m_density.group(1))
continue
m_overflow = ROUTING_OVERFLOW_RE.match(stripped)
if m_overflow:
scalars["routing_overflow_h_pct"] = float(m_overflow.group(1))
scalars["routing_overflow_v_pct"] = float(m_overflow.group(2))
continue
return scalars
# -----------------------------------------------------------------------------
# Path group ID mapping table
# -----------------------------------------------------------------------------
def parse_id_mapping(lines: List[str], start_idx: int) -> Tuple[Dict[int, str], int]:
mapping: Dict[int, str] = {}
i = start_idx + 1
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
if is_border_line(line):
i += 1
continue
if MODE_RE.search(line):
break
if "|" not in line:
break
cells = split_table_row(line)
if len(cells) < 2:
i += 1
continue
id_cell = cells[0].strip()
name_cell = cells[1].strip()
if INT_RE.fullmatch(id_cell) and name_cell:
mapping[int(id_cell)] = name_cell
i += 1
continue
if mapping:
break
i += 1
return mapping, i
# -----------------------------------------------------------------------------
# Timing table parsing
# -----------------------------------------------------------------------------
def build_group_objects(group_headers: List[str], group_id_map: Dict[int, str]) -> List[Dict[str, Any]]:
groups: List[Dict[str, Any]] = []
for idx, display_name in enumerate(group_headers):
display_name = display_name.strip()
group_id: Optional[int] = None
group_name: Optional[str] = None
m = GROUP_ID_IN_HEADER_RE.match(display_name)
if m:
group_id = int(m.group(1))
group_name = group_id_map.get(group_id)
if display_name.lower() == "all":
group_name = "all"
groups.append(
{
"column_index": idx,
"display_name": display_name,
"group_id": group_id,
"group_name": group_name or display_name,
}
)
return groups
def make_section_object(
section_name: str,
section_kind: str,
metric_rows: List[Dict[str, Any]],
group_headers: List[str],
group_id_map: Dict[int, str],
) -> Dict[str, Any]:
base_groups = build_group_objects(group_headers, group_id_map)
normalized_groups: List[Dict[str, Any]] = []
for group in base_groups:
item = dict(group)
for metric in metric_rows:
values = metric.get("values") or []
idx = item["column_index"]
value = values[idx] if idx < len(values) else None
item[metric["metric_key"]] = value
normalized_groups.append(item)
extra_metric_keys = [m["metric_key"] for m in metric_rows if m["metric_key"] not in KNOWN_TIMING_METRICS]
return {
"section_name": section_name,
"section_kind": section_kind,
"metric_order": [m["metric_key"] for m in metric_rows],
"metric_rows": metric_rows,
"groups": normalized_groups,
"extra_metric_keys": extra_metric_keys,
}
def looks_like_named_timing_section_title(line: str) -> bool:
stripped = line.strip()
if not stripped:
return False
if "|" in stripped:
return False
if stripped.startswith("#"):
return False
if "ID Number" in stripped or "Path Group Name" in stripped:
return False
if stripped.lower().startswith("density:"):
return False
if stripped.lower().startswith("routing overflow"):
return False
if MODE_RE.search(stripped):
return False
return True
def looks_like_drvs_table_start(line: str) -> bool:
return "DRVs" in line and "|" in line
def looks_like_labeled_metric_row(line: str) -> bool:
if "|" not in line:
return False
cells = split_table_row(line)
if len(cells) < 2:
return False
label = normalize_metric_name(cells[0])
return label in KNOWN_TIMING_METRICS or label in {"slack", "nvp", "violations"}
def parse_labeled_metric_rows(
lines: List[str],
start_idx: int,
expected_groups: int,
warnings: List[str],
) -> Tuple[List[Dict[str, Any]], int]:
rows: List[Dict[str, Any]] = []
i = start_idx
while i < len(lines):
line = lines[i]
if not line.strip():
i += 1
continue
if is_border_line(line):
i += 1
continue
if "|" not in line:
break
if not looks_like_labeled_metric_row(line):
break
cells = split_table_row(line)
metric_label = cells[0].strip()
metric_key = normalize_metric_name(metric_label)
raw_values = values_from_cells(cells, expected_groups=expected_groups, assume_labeled=True)
values = [parse_scalar(v) for v in raw_values]
rows.append(
{
"label": metric_label,
"metric_key": metric_key,
"values": values,
}
)
i += 1
if not rows:
warnings.append(f"No labeled metric rows parsed near line {start_idx + 1}")
return rows, i
def parse_unlabeled_metric_rows(
lines: List[str],
start_idx: int,
expected_groups: int,
template_metric_order: List[str],
section_name: str,
warnings: List[str],
) -> Tuple[List[Dict[str, Any]], int]:
rows: List[Dict[str, Any]] = []
i = start_idx
while i < len(lines) and len(rows) < len(template_metric_order):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
if is_border_line(line):
i += 1
continue
if MODE_RE.search(line):
break
if looks_like_drvs_table_start(line):
break
if looks_like_named_timing_section_title(line) and rows:
break
if "|" not in line:
break
cells = split_table_row(line)
raw_values = values_from_cells(cells, expected_groups=expected_groups, assume_labeled=False)
values = [parse_scalar(v) for v in raw_values]
metric_key = template_metric_order[len(rows)]
rows.append(
{
"label": metric_key,
"metric_key": metric_key,
"values": values,
}
)
i += 1
if len(rows) != len(template_metric_order):
warnings.append(
f"Section '{section_name}' parsed {len(rows)} unlabeled metric rows; expected {len(template_metric_order)}"
)
return rows, i
def parse_timing_block(
lines: List[str],
start_idx: int,
group_id_map: Dict[int, str],
warnings: List[str],
) -> Tuple[Optional[Dict[str, Any]], int]:
header_line = lines[start_idx]
header_cells = split_table_row(header_line)
if len(header_cells) < 2:
warnings.append(f"Malformed timing block header at line {start_idx + 1}")
return None, start_idx + 1
mode_label = header_cells[0].strip()
mode_match = MODE_RE.search(mode_label)
if not mode_match:
warnings.append(f"Could not detect mode from timing block header at line {start_idx + 1}")
return None, start_idx + 1
mode = mode_match.group(1).lower()
group_headers = [cell.strip() for cell in header_cells[1:]]
expected_groups = len(group_headers)
block: Dict[str, Any] = {
"mode": mode,
"header_label": mode_label,
"group_headers": group_headers,
"template_metric_order": [],
"sections": [],
}
i = start_idx + 1
base_metric_rows, i = parse_labeled_metric_rows(lines, i, expected_groups=expected_groups, warnings=warnings)
if base_metric_rows:
block["template_metric_order"] = [row["metric_key"] for row in base_metric_rows]
block["sections"].append(
make_section_object(
section_name=f"{mode}_summary",
section_kind="summary",
metric_rows=base_metric_rows,
group_headers=group_headers,
group_id_map=group_id_map,
)
)
else:
warnings.append(f"Timing block '{mode_label}' has no parseable base summary rows")
section_counter = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
if MODE_RE.search(line) and "|" in line:
break
if "ID Number" in line and "Path Group Name" in line:
break
if looks_like_drvs_table_start(line):
break
if stripped.lower().startswith("density:") or stripped.lower().startswith("routing overflow"):
break
if is_border_line(line):
i += 1
continue
if looks_like_named_timing_section_title(line):
title = stripped
peek_idx, peek_line = next_nonempty_line(lines, i + 1)
if peek_idx is None or peek_line is None:
break
if "|" not in peek_line:
warnings.append(f"Named section title at line {i + 1} not followed by pipe rows: {title}")
i += 1
continue
template_metric_order = block["template_metric_order"] or KNOWN_TIMING_METRICS
metric_rows, new_i = parse_unlabeled_metric_rows(
lines,
peek_idx,
expected_groups=expected_groups,
template_metric_order=template_metric_order,
section_name=title,
warnings=warnings,
)
if metric_rows:
block["sections"].append(
make_section_object(
section_name=title,
section_kind="named_view",
metric_rows=metric_rows,
group_headers=group_headers,
group_id_map=group_id_map,
)
)
i = new_i
continue
warnings.append(f"Failed to parse named timing section '{title}' near line {i + 1}")
i += 1
continue
# Tolerate unexpected extra labeled rows by turning them into an anonymous section.
if "|" in line and looks_like_labeled_metric_row(line):
section_counter += 1
anon_name = f"anonymous_section_{section_counter}"
metric_rows, new_i = parse_labeled_metric_rows(lines, i, expected_groups=expected_groups, warnings=warnings)
if metric_rows:
block["sections"].append(
make_section_object(
section_name=anon_name,
section_kind="anonymous_labeled",
metric_rows=metric_rows,
group_headers=group_headers,
group_id_map=group_id_map,
)
)
i = new_i
continue
# Any other line means the timing block likely ended.
break
return block, i
# -----------------------------------------------------------------------------
# DRV table parsing
# -----------------------------------------------------------------------------
def collect_pipe_table_block(lines: List[str], start_idx: int) -> Tuple[List[str], int]:
block: List[str] = []
i = start_idx
saw_pipe = False
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
if saw_pipe:
break
i += 1
continue
if "|" in line or is_border_line(line):
block.append(line)
saw_pipe = saw_pipe or ("|" in line)
i += 1
continue
if saw_pipe:
break
i += 1
return block, i
def parse_drvs_table(lines: List[str], start_idx: int, warnings: List[str]) -> Tuple[Optional[Dict[str, Any]], int]:
block, next_i = collect_pipe_table_block(lines, start_idx)
data_rows = [split_table_row(line) for line in block if ("|" in line and not is_border_line(line))]
if not data_rows:
warnings.append(f"DRV table near line {start_idx + 1} is empty")
return None, next_i
rows: List[Dict[str, Any]] = []
for cells in data_rows:
if not cells:
continue
first = cells[0].strip().lower()
if first in {"drvs", "", "real", "total"}:
continue
if first.startswith("nr nets") or first.startswith("worst vio"):
continue
if first.startswith("max_") or first in {"transition", "capacitance", "fanout", "length"}:
padded = cells + [""] * max(0, 4 - len(cells))
row = {
"drv_type": padded[0].strip(),
"real_nr_nets_terms": parse_scalar(padded[1]),
"real_worst_vio": parse_scalar(padded[2]),
"total_nr_nets_terms": parse_scalar(padded[3]),
}
rows.append(row)
if not rows:
warnings.append(f"No DRV data rows parsed near line {start_idx + 1}")
return None, next_i
return {
"report_type": "drvs_summary",
"rows": rows,
}, next_i
# -----------------------------------------------------------------------------
# High-level report parser
# -----------------------------------------------------------------------------
def parse_timing_report_text(text: str) -> Dict[str, Any]:
lines = text.splitlines()
warnings: List[str] = []
metadata = parse_metadata(lines)
group_id_map: Dict[int, str] = {}
timing_tables: List[Dict[str, Any]] = []
drvs: List[Dict[str, Any]] = []
scalars = parse_scalar_tail(lines)
i = 0
while i < len(lines):
line = lines[i]
if "ID Number" in line and "Path Group Name" in line:
parsed_map, next_i = parse_id_mapping(lines, i)
if parsed_map:
group_id_map.update(parsed_map)
else:
warnings.append(f"ID mapping table near line {i + 1} produced no entries")
i = max(next_i, i + 1)
continue
if MODE_RE.search(line) and "|" in line:
block, next_i = parse_timing_block(lines, i, group_id_map, warnings)
if block:
timing_tables.append(block)
i = max(next_i, i + 1)
continue
if looks_like_drvs_table_start(line):
drv_block, next_i = parse_drvs_table(lines, i, warnings)
if drv_block:
drvs.append(drv_block)
i = max(next_i, i + 1)
continue
i += 1
return {
"report_type": "innovus_timing_debug_report_ascii",
"metadata": metadata,
"group_id_map": {str(k): v for k, v in sorted(group_id_map.items())},
"timing_tables": timing_tables,
"drvs": drvs,
"scalars": scalars,
"warnings": warnings,
}
def parse_timing_report_file(path: Path) -> Dict[str, Any]:
# Support gzipped reports while preserving utf-8 decoding behavior.
if str(path).lower().endswith(".gz"):
with gzip.open(path, "rt", encoding="utf-8", errors="replace") as f:
text = f.read()
else:
text = path.read_text(encoding="utf-8", errors="replace")
result = parse_timing_report_text(text)
result["source_file"] = str(path)
return result
# -----------------------------------------------------------------------------
# CSV export
# -----------------------------------------------------------------------------
def export_timing_groups_csv(report: Dict[str, Any], csv_dir: Path) -> Path:
csv_dir.mkdir(parents=True, exist_ok=True)
out_path = csv_dir / "timing_groups.csv"
fieldnames = [
"source_file",
"mode",
"header_label",
"section_name",
"section_kind",
"group_display_name",
"group_id",
"group_name",
"wns_ns",
"tns_ns",
"violating_paths",
"all_paths",
]
extra_fields: List[str] = []
for table in report.get("timing_tables", []):
for section in table.get("sections", []):
for metric_key in section.get("extra_metric_keys", []):
if metric_key not in extra_fields:
extra_fields.append(metric_key)
with out_path.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames + extra_fields)
writer.writeheader()
for table in report.get("timing_tables", []):
for section in table.get("sections", []):
for group in section.get("groups", []):
row = {
"source_file": report.get("source_file"),
"mode": table.get("mode"),
"header_label": table.get("header_label"),
"section_name": section.get("section_name"),
"section_kind": section.get("section_kind"),
"group_display_name": group.get("display_name"),
"group_id": group.get("group_id"),
"group_name": group.get("group_name"),
"wns_ns": group.get("wns_ns"),
"tns_ns": group.get("tns_ns"),
"violating_paths": group.get("violating_paths"),
"all_paths": group.get("all_paths"),
}
for extra_key in extra_fields:
row[extra_key] = group.get(extra_key)
writer.writerow(row)
return out_path
def export_drvs_csv(report: Dict[str, Any], csv_dir: Path) -> Optional[Path]:
rows: List[Dict[str, Any]] = []
for block in report.get("drvs", []):
for row in block.get("rows", []):
rows.append(row)
if not rows:
return None
csv_dir.mkdir(parents=True, exist_ok=True)
out_path = csv_dir / "drvs.csv"
fieldnames = ["drv_type", "real_nr_nets_terms", "real_worst_vio", "total_nr_nets_terms"]
with out_path.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow({k: json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v for k, v in row.items()})
return out_path
def export_scalars_csv(report: Dict[str, Any], csv_dir: Path) -> Optional[Path]:
scalars = report.get("scalars") or {}
if not scalars:
return None
csv_dir.mkdir(parents=True, exist_ok=True)
out_path = csv_dir / "scalars.csv"
with out_path.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["key", "value"])
writer.writeheader()
for key, value in scalars.items():
writer.writerow({"key": key, "value": value})
return out_path
def export_csv_bundle(report: Dict[str, Any], csv_dir: Path) -> Dict[str, Optional[str]]:
timing_csv = export_timing_groups_csv(report, csv_dir)
drvs_csv = export_drvs_csv(report, csv_dir)
scalars_csv = export_scalars_csv(report, csv_dir)
return {
"timing_groups_csv": str(timing_csv) if timing_csv else None,
"drvs_csv": str(drvs_csv) if drvs_csv else None,
"scalars_csv": str(scalars_csv) if scalars_csv else None,
}
# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Parse ASCII Innovus timing debug report into JSON/CSV (v3).")
parser.add_argument("report", type=Path, help="Path to the timing report text file")
parser.add_argument("-o", "--output", type=Path, help="Output JSON file path")
parser.add_argument("--indent", type=int, default=2, help="JSON indentation (default: 2)")
parser.add_argument("--csv-dir", type=Path, help="Directory to export CSV files")
args = parser.parse_args()
result = parse_timing_report_file(args.report)
if args.csv_dir:
result["csv_exports"] = export_csv_bundle(result, args.csv_dir)
output_text = json.dumps(result, ensure_ascii=False, indent=args.indent)
if args.output:
args.output.write_text(output_text + "\n", encoding="utf-8")
else:
print(output_text)
if __name__ == "__main__":
main()
用法:该脚本.py xxxx.summary.gz -o result.json