diff --git a/tests/drc/run_cell_drc.py b/tests/drc/run_cell_drc.py index 65fb5c9b..6bcbcdaf 100644 --- a/tests/drc/run_cell_drc.py +++ b/tests/drc/run_cell_drc.py @@ -10,6 +10,21 @@ * parses the resulting ``lyrdb`` to count violations, * emits a JSON summary, a JUnit report, and exits non-zero if any cell has DRC errors or fails to build. + +gf180 notes +----------- +The bundled ``gf180mcu.drc`` reads its configuration from global Ruby +variables ($metal_top, $metal_level, $mim_option, $run_mode, $thr, $offgrid). +Every one of them has an internal fallback, so the deck *runs* without them -- +but the fallbacks are 9K / 6LM / "Nan" MIM, which do not match this PDK's +5-metal layer map and silently skip ALL MIM capacitor checks. We therefore +pass them explicitly via ``-rd`` for variant D (5LM / 11K / MIM-B), matching +the ``--variant=D`` used by the klayout LVS deck in tests/lvs/klayout_gf180.py. + +Do NOT point this runner at ``gf180mcu_drc_wrapper.drc``: that wrapper exists +for ``MappedPDK.drc`` (which uses in_gds/report_file naming) and presets the +variant-A metal stack (30K / 3LM / MIM-A), which would drop every metal4 and +metal5 BEOL rule. """ from __future__ import annotations @@ -17,6 +32,7 @@ import csv import json import os +import re import subprocess import sys import traceback @@ -33,6 +49,15 @@ } DEFAULT_PARAM_DIR = REPO_ROOT / "tests" / "parameters" +# gf180mcu metal-stack variants: variant -> (metal_top, metal_level, mim_option). +# Mirrors the mapping in the PDK's own run_drc_main klayout macro. +GF180_VARIANTS = { + "A": ("30K", "3LM", "A"), + "B": ("11K", "4LM", "B"), + "C": ("9K", "5LM", "B"), + "D": ("11K", "5LM", "B"), +} + @dataclass class CellSpec: @@ -140,33 +165,83 @@ def _drc_deck_for(pdk_name: str, override: Optional[str] = None) -> Path: return BUNDLED_DECKS[pdk_name] -# Rules that are not functional defects — fab/density-style; safe to ignore in CI. +# --------------------------------------------------------------------------- +# Rules that are not functional defects for a standalone cell. # Match by category name OR description (case-insensitive). -import re as _re +# --------------------------------------------------------------------------- _IGNORE_PATTERNS = [ - _re.compile(r"density", _re.IGNORECASE), - _re.compile(r"min[._\s-]*\w*\s*area", _re.IGNORECASE), - _re.compile(r"^m\d+\.4$", _re.IGNORECASE), # sky130 metal min-area rules: m1.4, m2.4, m3.4, m4.4 - # gf180 DF.14: max distance from a substrate tap (pcomp outside nwell) - # to the nearest nfet (ncomp outside nwell). This is a chip-level - # latch-up constraint; a pmos-only cell can't satisfy it in isolation. - _re.compile(r"^DF\.14", _re.IGNORECASE), + re.compile(r"density", re.IGNORECASE), + + # Minimum-area rules. The phrasing differs per engine/PDK: + # magic (sky130): "Metal1 minimum area < 0.083um^2" + # klayout sky130: m1.4 / m2.4 / ... (matched by name below) + # klayout gf180: "Minimum Metal1 area : 0.1444um^2" <- M1.3 .. M5.3 + # "Min. COMP area (um2)" <- DF.9 + # A single pattern anchored on min...area (with anything in between up to + # the ':' separator) covers all of them. The earlier + # r"min[._\s-]*\w*\s*area" form did NOT match "Minimum Metal1 area", + # because \w* consumed "imum" and then "area" had to match "Metal1". + re.compile(r"\bmin(?:imum)?\b[^:]*\barea\b", re.IGNORECASE), + + # sky130 metal min-area rules, matched by rule name. + re.compile(r"^m\d+\.4$", re.IGNORECASE), + + # gf180 DF.13 / DF.14: max distance from a well tap / substrate tap to the + # nearest transistor of the opposite type. Both are chip-level latch-up + # constraints -- a single cell containing only NMOS (or only PMOS) cannot + # satisfy them in isolation, and glayout cells are placed into a larger + # design that provides the taps. + re.compile(r"^DF\.13", re.IGNORECASE), + re.compile(r"^DF\.14", re.IGNORECASE), ] +# Rules the gf180 deck emits as explicit *recommendations* / guidelines rather +# than hard defects (their own descriptions say "It is recommended" / +# "Guideline"). They fire on isolated cells that have no surrounding guard +# ring. Enable with --ignore-guidelines once you have confirmed they are the +# only remaining failures; left off by default so nothing is hidden silently. +_GUIDELINE_PATTERNS = [ + re.compile(r"^MDN\.17", re.IGNORECASE), + re.compile(r"^MDP\.3$", re.IGNORECASE), + re.compile(r"^MDP\.17a", re.IGNORECASE), + re.compile(r"recommend", re.IGNORECASE), + re.compile(r"guideline", re.IGNORECASE), +] -def _is_ignored_rule(name: str, desc: str) -> bool: - text = f"{name} {desc}" - return any(p.search(text) for p in _IGNORE_PATTERNS) + +def _is_ignored_rule(name: str, desc: str, extra_patterns: Optional[List[Any]] = None) -> bool: + """True if this rule is not a functional defect for a standalone cell. + + Patterns are tested against the rule NAME on its own and against + " ". Testing the name separately matters for anchored + patterns: r"^m\\d+\\.4$" can never match the concatenated string, so in the + original implementation the sky130 metal min-area names were silently + never ignored. + """ + name = (name or "").strip() + text = f"{name} {desc or ''}" + patterns = list(_IGNORE_PATTERNS) + if extra_patterns: + patterns.extend(extra_patterns) + return any(p.search(name) or p.search(text) for p in patterns) -def _count_lyrdb_violations(report: Path) -> dict: +def _count_lyrdb_violations(report: Path, extra_ignores: Optional[List[Any]] = None) -> dict: """Count DRC violations in a klayout lyrdb. Returns a dict with: total, effective (excluding density/min-area), ignored, by_rule, ignored_by_rule. - On failure to read the report returns {'total': -1, ...}. + On failure to read or parse the report returns {'total': -1, ...} plus a + 'parse_error' key, so a truncated/garbled lyrdb reads as an error rather + than as a clean pass. """ + empty = {"total": -1, "effective": -1, "ignored": 0, "by_rule": {}, "ignored_by_rule": {}} if not report.exists(): - return {"total": -1, "effective": -1, "ignored": 0, "by_rule": {}, "ignored_by_rule": {}} - tree = ET.parse(report) + return dict(empty) + try: + tree = ET.parse(report) + except ET.ParseError as exc: + out = dict(empty) + out["parse_error"] = f"{exc}" + return out root = tree.getroot() cats: dict[str, str] = {} items = None @@ -195,7 +270,7 @@ def _count_lyrdb_violations(report: Path) -> dict: cat = (sub.text or "").strip().strip("'") break desc = cats.get(cat, "") - if _is_ignored_rule(cat, desc): + if _is_ignored_rule(cat, desc, extra_ignores): ignored_by_rule[cat] = ignored_by_rule.get(cat, 0) + 1 else: by_rule[cat] = by_rule.get(cat, 0) + 1 @@ -209,7 +284,28 @@ def _count_lyrdb_violations(report: Path) -> dict: } -def _run_klayout(deck: Path, gds: Path, report: Path) -> subprocess.CompletedProcess: +def _run_klayout( + deck: Path, + gds: Path, + report: Path, + pdk_name: str = "sky130", + variant: str = "D", + topcell: Optional[str] = None, + threads: int = 2, + offgrid: bool = True, + timeout: int = 1800, +) -> subprocess.CompletedProcess: + """Invoke the klayout DRC deck in batch mode. + + For gf180 we must supply the metal-stack globals explicitly: the deck's + own fallbacks are 9K / 6LM / MIM "Nan", which skip every MIM capacitor + rule and select a via5/metaltop stack this PDK's layer map does not use. + + ``threads`` defaults to 2 rather than the deck's internal 16: the caller + already runs cells in parallel via ProcessPoolExecutor, and 16 threads per + cell oversubscribes a 2-4 core CI runner badly enough to trip the OOM + killer (which surfaces as "report file not produced"). + """ cmd = [ "klayout", "-b", @@ -217,14 +313,28 @@ def _run_klayout(deck: Path, gds: Path, report: Path) -> subprocess.CompletedPro "-rd", f"input={gds}", "-rd", f"report={report}", ] - return subprocess.run(cmd, capture_output=True, text=True, timeout=900) - - -_MAGIC_RULE_RE = _re.compile(r"^[A-Za-z]") -_MAGIC_COORD_RE = _re.compile(r"^[0-9-]") - - -def _count_magic_violations(report: Path) -> dict: + if pdk_name == "gf180": + metal_top, metal_level, mim_option = GF180_VARIANTS[variant] + cmd += [ + "-rd", f"metal_top={metal_top}", + "-rd", f"metal_level={metal_level}", + "-rd", f"mim_option={mim_option}", + "-rd", "run_mode=flat", + "-rd", f"thr={threads}", + "-rd", f"offgrid={'true' if offgrid else 'false'}", + ] + if topcell: + # Without this the deck calls source($input) and lets klayout guess + # the top cell. + cmd += ["-rd", f"topcell={topcell}"] + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + + +_MAGIC_RULE_RE = re.compile(r"^[A-Za-z]") +_MAGIC_COORD_RE = re.compile(r"^[0-9-]") + + +def _count_magic_violations(report: Path, extra_ignores: Optional[List[Any]] = None) -> dict: """Parse a magic DRC report (the format ``custom_drc_save_report`` writes in ``pdk.drc_magic``). Returns the same shape as ``_count_lyrdb_violations`` so JUnit/summary code can stay agnostic. @@ -246,7 +356,7 @@ def _count_magic_violations(report: Path) -> dict: current_rule = s continue if _MAGIC_COORD_RE.match(s) and current_rule: - if _is_ignored_rule(current_rule, current_rule): + if _is_ignored_rule(current_rule, current_rule, extra_ignores): ignored_by_rule[current_rule] = ignored_by_rule.get(current_rule, 0) + 1 else: by_rule[current_rule] = by_rule.get(current_rule, 0) + 1 @@ -266,6 +376,7 @@ def _run_magic_drc(item: dict, pdk, comp_name: str, gds_path: Path, magic_dir: P name = item["name"] pdk_name = item["pdk"] out_dir = Path(item["out_dir"]) + extra_ignores = _GUIDELINE_PATTERNS if item.get("ignore_guidelines") else None res: Dict[str, Any] = {"cell": name, "pdk": pdk_name, "engine": "magic", "status": "skip"} rpt_dir = magic_dir / "drc" / comp_name rpt_path = rpt_dir / f"{comp_name}.rpt" @@ -280,7 +391,7 @@ def _run_magic_drc(item: dict, pdk, comp_name: str, gds_path: Path, magic_dir: P res.update({"status": "error", "message": f"magic drc failed: {exc}", "trace": traceback.format_exc()}) print(f"[ERROR] {name}: magic drc failed: {exc}", flush=True) return res - viols = _count_magic_violations(rpt_path) + viols = _count_magic_violations(rpt_path, extra_ignores) effective = viols["effective"] res.update({ "violations": viols, @@ -307,7 +418,8 @@ def _run_one_cell(item: dict) -> dict: runs on its own core. item keys: name, pdk, deck, kwargs, gds_path, rpt_path, netlist_path, - out_dir, engines (list[str]), magic_dir (str|None) + out_dir, engines (list[str]), magic_dir (str|None), + variant, threads, offgrid, ignore_guidelines """ name = item["name"] pdk_name = item["pdk"] @@ -316,6 +428,7 @@ def _run_one_cell(item: dict) -> dict: gds_path = Path(item["gds_path"]) rpt_path = Path(item["rpt_path"]) netlist_path = Path(item["netlist_path"]) + extra_ignores = _GUIDELINE_PATTERNS if item.get("ignore_guidelines") else None result: Dict[str, Any] = {"cell": name, "pdk": pdk_name, "status": "skip"} try: print(f"[BUILD] {name}", flush=True) @@ -350,8 +463,15 @@ def _run_one_cell(item: dict) -> dict: if "klayout" in engines: try: print(f"[DRC] {name}", flush=True) - proc = _run_klayout(deck, gds_path, rpt_path) - viols = _count_lyrdb_violations(rpt_path) + proc = _run_klayout( + deck, gds_path, rpt_path, + pdk_name=pdk_name, + variant=item.get("variant", "D"), + topcell=name, + threads=item.get("threads", 2), + offgrid=item.get("offgrid", True), + ) + viols = _count_lyrdb_violations(rpt_path, extra_ignores) effective = viols["effective"] klayout_res: Dict[str, Any] = { "engine": "klayout", @@ -359,17 +479,24 @@ def _run_one_cell(item: dict) -> dict: "report": str(rpt_path.relative_to(out_dir)), "klayout_returncode": proc.returncode, "klayout_stderr_tail": (proc.stderr or "")[-400:], + # The gf180 deck logs its resolved switches (METAL_TOP, + # METAL_STACK, MIM Option, Offgrid) on stdout -- keep the tail + # so a config mismatch is visible in the artifact. + "klayout_stdout_tail": (proc.stdout or "")[-800:], } if proc.returncode != 0: klayout_res["status"] = "error" klayout_res["message"] = f"klayout exited {proc.returncode}" + elif viols.get("parse_error"): + klayout_res["status"] = "error" + klayout_res["message"] = f"lyrdb parse error: {viols['parse_error']}" elif effective < 0: klayout_res["status"] = "error" klayout_res["message"] = "report file not produced" elif effective == 0: klayout_res["status"] = "pass" if viols["ignored"]: - klayout_res["message"] = f"clean (ignored {viols['ignored']} density/area)" + klayout_res["message"] = f"clean (ignored {viols['ignored']} density/area/latch-up)" else: klayout_res["status"] = "fail" top = ", ".join(f"{r}:{n}" for r, n in sorted(viols["by_rule"].items(), key=lambda kv: -kv[1])[:3]) @@ -460,6 +587,27 @@ def main() -> int: default="klayout", help="DRC engine(s) to run per cell. 'both' runs klayout and magic in sequence per worker.", ) + parser.add_argument( + "--variant", default="D", choices=sorted(GF180_VARIANTS), + help="gf180mcu metal-stack variant (ignored for sky130). D = 5LM/11K/MIM-B, " + "matching --variant=D in tests/lvs/klayout_gf180.py.", + ) + parser.add_argument( + "--threads", type=int, default=2, + help="Threads per klayout process for gf180 ($thr). Kept low because " + "cells already run in parallel; the deck's own default is 16.", + ) + parser.add_argument( + "--no-offgrid", action="store_true", + help="Disable the gf180 deck's OFFGRID/ACUTE geometry section ($offgrid=false). " + "Useful while triaging; leave enabled in CI.", + ) + parser.add_argument( + "--ignore-guidelines", action="store_true", + help="Also ignore rules the deck flags as recommendations/guidelines " + "(MDN.17, MDP.3, MDP.17a, ...) which cannot be satisfied by a " + "standalone cell with no surrounding guard ring.", + ) args = parser.parse_args() engines = ["klayout", "magic"] if args.engine == "both" else [args.engine] @@ -489,6 +637,11 @@ def main() -> int: print(f"warning: cells not in CSV: {sorted(missing)}", file=sys.stderr) specs = {n: s for n, s in specs.items() if n in wanted} + if args.pdk == "gf180": + mt, ml, mim = GF180_VARIANTS[args.variant] + print(f"gf180 variant {args.variant}: metal_top={mt} metal_level={ml} " + f"mim_option={mim} thr={args.threads} offgrid={not args.no_offgrid}") + # Hand cell work to a process pool so build+klayout for different cells # run on different cores. Each worker imports glayout fresh; we pass the # cell name + kwargs over the wire and resolve the builder by name in the @@ -507,6 +660,10 @@ def main() -> int: "out_dir": str(out_dir), "engines": engines, "magic_dir": str(magic_dir) if magic_dir else None, + "variant": args.variant, + "threads": args.threads, + "offgrid": not args.no_offgrid, + "ignore_guidelines": args.ignore_guidelines, } for name, spec in specs.items() ] @@ -527,6 +684,7 @@ def main() -> int: summary = { "pdk": args.pdk, + "variant": args.variant if args.pdk == "gf180" else None, "total": len(results), "pass": sum(1 for r in results if r["status"] == "pass"), "fail": sum(1 for r in results if r["status"] == "fail"), @@ -538,6 +696,19 @@ def main() -> int: _write_junit(results, args.pdk, out_dir / "junit.xml") print(json.dumps({k: v for k, v in summary.items() if k != "results"}, indent=2)) + + # Aggregate rule histogram: the single most useful thing when triaging a + # red run, and cheap to print. + agg: Dict[str, int] = {} + for r in results: + for er in (r.get("engines") or {}).values(): + for rule, n in (er.get("violations", {}).get("by_rule") or {}).items(): + agg[rule] = agg.get(rule, 0) + n + if agg: + print("\nviolations by rule:") + for rule, n in sorted(agg.items(), key=lambda kv: -kv[1]): + print(f" {rule:<24} {n}") + return 0 if summary["fail"] == 0 and summary["error"] == 0 else 1 diff --git a/tests/lvs/klayout_gf180.py b/tests/lvs/klayout_gf180.py index 416f57b3..89b7b984 100644 --- a/tests/lvs/klayout_gf180.py +++ b/tests/lvs/klayout_gf180.py @@ -17,20 +17,46 @@ import os import re +import shlex import shutil import subprocess import tempfile from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Set -# Reference SPICE bundled with gf180_mapped — included in the staged netlist -# so klayout can resolve any standard-cell sub-circuits referenced in tests. +# Std-cell reference SPICE bundled with gf180_mapped. Included ONLY when the +# cell netlist actually references something defined in it -- see +# _needs_ref_spice for why an unconditional include is harmful. _REF_SPICE = ( Path(__file__).resolve().parents[2] / "src" / "glayout" / "pdk" / "gf180_mapped" / "gf180mcu_osu_sc_9T.spice" ) +# Device models klayout's gf180mcu deck classifies by SPICE prefix rather than +# by subckt lookup. Keep in sync with gf180_mapped_pdk.models. +_GF180_PRIMITIVE_FETS = ("nfet_03v3", "pfet_03v3") +_GF180_PRIMITIVE_CAPS = ("mimcap_1p0fF",) + +# Numeric literal including scientific notation: 1, 1.5, .5, 1.5e-06. +_NUM = r"[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?" + +# Subckts defined in _REF_SPICE. If the cell netlist references none of them, +# including the file is pure risk rather than help: +# * SLC defines X0 and X4 twice each -- malformed SPICE; klayout's reader +# may abort, which takes the whole schematic netlist with it (0 devices on +# the schematic side, every layout device unmatched). +# * Every device in it is nmos_3p3 / pmos_3p3, which exist neither in this +# PDK (nfet_03v3 / pfet_03v3) nor as subckts in the file, so it +# contributes hundreds of unresolvable references. +# * `.option scale=0.05u` appears ~40 times and is file-global in SPICE. If +# klayout honours it, our own w=/l= values get rescaled by 20x and every +# device fails on properties. +_REF_SPICE_SUBCKTS = re.compile(r"\b(gf180mcu_osu_sc_9T_\w+|dinv1|HEADER|SLC)\b") + +# Instance-line prefixes worth scanning for a model token. +_INSTANCE_PREFIXES = "XxMmCcRrDdQqJjZz" + def _resolve_deck_dir(pdk_root: str) -> Path: """Resolve the gf180mcu klayout LVS deck directory from $PDK_ROOT. @@ -52,117 +78,225 @@ def _resolve_deck_dir(pdk_root: str) -> Path: return deck -def _detect_substrate_name(spice_path: Path, top_cell: str) -> str: - """Pick the schematic's bulk port name to pass as klayout's --lvs_sub. +def _deck_python() -> str: + """Interpreter to run the gf180mcu deck's run_lvs.py with. - klayout's gf180mcu deck names the implicit substrate "gf180mcu_gnd" by - default. The schematic's bulk port (B / VBULK / VSUB / GND / VSS) needs - to use the SAME name or LVS reports every net as unmatched. We pick the - first port matching common bulk conventions; VSS comes last because it - is usually the source rail (e.g. CMIRROR's `VREF VOUT VSS B` should - pick B). Falls back to the last positional port, then to the deck - default. + The caller runs inside glayout's CPython 3.10 venv, which has neither + `docopt` nor the klayout Python bindings -- both imported at module scope + in run_lvs.py, so a plain `python3` aborts before any LVS work happens. + Prefer an explicit override, then the container interpreter. """ - try: - text = spice_path.read_text(errors="ignore") - except OSError: - return "gf180mcu_gnd" - pat = re.compile(r"^\.subckt\s+" + re.escape(top_cell) + r"\s+(.+)$", re.MULTILINE | re.IGNORECASE) - m = pat.search(text) - if not m: - return "gf180mcu_gnd" - tokens = [t for t in m.group(1).split() if "=" not in t] - for cand in ("B", "VBULK", "VSUB", "GND", "VSS"): - if cand in tokens: + for cand in (os.environ.get("LVS_DECK_PYTHON"), "/usr/bin/python3"): + if cand and Path(cand).is_file(): return cand - return tokens[-1] if tokens else "gf180mcu_gnd" + return "python3" -_GF180_PRIMITIVE_FETS = ("nfet_03v3", "pfet_03v3") +def _deck_env() -> Dict[str, str]: + """Environment for the deck subprocess. + + Drops VIRTUAL_ENV and the venv's bin from PATH so the image's own + site-packages resolve. CI blanks PYTHONPATH (to keep the image's 3.12 + packages out of the 3.10 venv); IMAGE_PYTHONPATH carries the original + value for exactly this purpose. + """ + env = os.environ.copy() + env.pop("VIRTUAL_ENV", None) + env["PYTHONPATH"] = os.environ.get("IMAGE_PYTHONPATH", "") + venv_bin = str(Path(os.environ.get("GITHUB_WORKSPACE", "")) / ".venv" / "bin") + env["PATH"] = ":".join(p for p in env.get("PATH", "").split(":") if p and p != venv_bin) + return env + + +def _defined_subckts(text: str) -> Set[str]: + return set(re.findall(r"^\.subckt\s+(\S+)", text, re.MULTILINE | re.IGNORECASE)) + + +def _referenced_subckts(text: str, defined: Set[str]) -> Set[str]: + """Names from `defined` that appear as a model token on an instance line.""" + referenced: Set[str] = set() + for line in text.splitlines(): + s = line.strip() + if not s or s[0] not in _INSTANCE_PREFIXES: + continue + for tok in s.split()[1:]: + if tok in defined: + referenced.add(tok) + return referenced + + +def _rename_top_subckt(cdl_text: str, cell: str) -> str: + """Rename the schematic's top subckt to match the layout cell name. + + The top is the subckt that is never referenced as an instance model. Do + NOT assume it is the last one defined: that silently renames a *leaf* if a + generator ever emits top-down, after which --topcell= resolves to + the wrong circuit and every net comes back unmatched. + """ + defined = _defined_subckts(cdl_text) + if not defined: + return cdl_text + referenced = _referenced_subckts(cdl_text, defined) + tops = sorted(defined - referenced) + if not tops: + tops = sorted(defined) + print(f"[LVS] {cell}: every subckt is referenced; falling back to {tops[-1]}", flush=True) + elif len(tops) > 1: + print(f"[LVS] {cell}: ambiguous top subckt {tops}; using {tops[-1]}", flush=True) + sch_top = tops[-1] + if sch_top != cell: + cdl_text = re.sub(rf"\b{re.escape(sch_top)}\b", cell, cdl_text) + return cdl_text + +def _tag_geometry_units(cdl_text: str) -> str: + """Append `u` to bare w=/l= values (the gf180mcu deck rejects unitless + geometry parameters). + + Case-insensitive (glayout and the bundled reference spice disagree on + case) and tolerant of scientific notation. Idempotent: the lookahead fails + when a unit suffix is already present, so `w=1u` is left alone. + """ + for key in ("w", "l"): + cdl_text = re.sub( + rf"(\b{key}=)({_NUM})(?=[\s,)]|$)", + r"\1\2u", + cdl_text, + flags=re.MULTILINE | re.IGNORECASE, + ) + return cdl_text -def _rewrite_x_to_m_for_primitives(cdl_text: str) -> str: - """Rewrite X-prefix instances of gf180 primitive MOSFETs to M-prefix. + +def _rewrite_prefix_for_primitives(cdl_text: str) -> str: + """Rewrite X-prefix instances of gf180 primitives to the prefix klayout's + deck classifies on: M for the 4-terminal FETs, C for 2-terminal MIM caps. glayout's netlist generators emit X-prefix everywhere (sky130's magic+netgen tech setup expects X-instances of `sky130_fd_pr__nfet_01v8` and matches them via the netgen tech file). klayout's gf180mcu deck - classifies primitive MOSFETs by SPICE prefix instead — only M-prefix - instances of `nfet_03v3`/`pfet_03v3` get auto-promoted to MOS4 device - classes; X-prefix instances are treated as unknown subckts (no - `.subckt` body anywhere) and the schematic side ends up with 0 - transistors, every layout fet then becomes an unmatched device. - - Match instance lines whose model token (everything after the four - terminal nets) is one of the primitive fet models, and rewrite the - leading ``X`` to ``M``. Lines that hit subckt wrappers (NMOS, PMOS, - DIFF_PAIR, ...) are left as X — those are real subckt references. + instead auto-promotes only M-prefix instances of nfet_03v3 / pfet_03v3 to + MOS4 device classes; X-prefix instances have no `.subckt` body anywhere, + so they are treated as unknown subckts, the schematic side ends up with 0 + transistors, and every layout fet becomes an unmatched device. + + `\\S*` before the model name tolerates a fully-qualified spelling + (gf180mcu_fd_pr__nfet_03v3) as well as the bare one this PDK emits. + Instances of real subckt wrappers (NMOS, PMOS, DIFF_PAIR, ...) are left as + X -- those do have bodies. + + NOTE: the C-prefix rewrite for mimcap_1p0fF is inferred from the FET case. + Verify it against the device-classification section of the PDK's .lvs + files; if MIM caps are declared there as a subckt instead, set + _GF180_PRIMITIVE_CAPS = () and make sure the cap body resolves some other + way. """ fet_alt = "|".join(re.escape(m) for m in _GF180_PRIMITIVE_FETS) - pat = re.compile( - rf"^X(\S+)(\s+\S+\s+\S+\s+\S+\s+\S+\s+(?:{fet_alt})\b)", - re.MULTILINE, + cdl_text = re.sub( + rf"^X(\S+)((?:\s+\S+){{4}}\s+\S*(?:{fet_alt})\b)", + r"M\1\2", + cdl_text, + flags=re.MULTILINE | re.IGNORECASE, ) - return pat.sub(r"M\1\2", cdl_text) + cap_alt = "|".join(re.escape(m) for m in _GF180_PRIMITIVE_CAPS) + if cap_alt: + cdl_text = re.sub( + rf"^X(\S+)((?:\s+\S+){{2}}\s+\S*(?:{cap_alt})\b)", + r"C\1\2", + cdl_text, + flags=re.MULTILINE | re.IGNORECASE, + ) + return cdl_text + +def _needs_ref_spice(cdl_text: str) -> bool: + return _REF_SPICE_SUBCKTS.search(cdl_text) is not None -def _stage_inputs(workdir: Path, cell: str, gds_src: Path, netlist_src: Path) -> Path: - """Copy GDS + reference netlist into the temp dir, normalize, and return - the staged spice path. Normalizations (mirror `.run_ci_lvs_v2.sh`): - * Rename the schematic's top subckt to match the layout cell name. - * Add explicit `u` unit suffix to bare `w=`/`l=` numeric values - (gf180mcu deck rejects unitless geometry params). - * Rewrite X-prefix instances of primitive `nfet_03v3`/`pfet_03v3` - to M-prefix so klayout's deck classifies them as MOS4. The - generator code stays PDK-agnostic and emits X-prefix everywhere. - * Prepend `.include` of the bundled reference spice so any std-cell - subckt the test netlist references can be resolved. +def _stage_inputs(workdir: Path, cell: str, netlist_src: Path) -> Path: + """Normalize the reference netlist into `workdir`; return the staged path. + + Normalizations: + * rename the schematic's top subckt to the layout cell name, + * tag bare w=/l= values with a `u` unit suffix, + * rewrite X-prefix primitive instances to M (fets) / C (mim caps), + * prepend `.include` of the bundled std-cell spice ONLY if referenced. """ - layout_dst = workdir / f"{cell}.gds" cdl_dst = workdir / f"{cell}.cdl" spice_dst = workdir / f"{cell}.spice" - shutil.copy(gds_src, layout_dst) shutil.copy(netlist_src, cdl_dst) cdl_text = cdl_dst.read_text() - sch_top_match = re.findall(r"^\.subckt\s+(\S+)", cdl_text, re.MULTILINE) - if sch_top_match and sch_top_match[-1] != cell: - sch_top = sch_top_match[-1] - cdl_text = re.sub(rf"\b{re.escape(sch_top)}\b", cell, cdl_text) - - # Tag bare w=/l= values with `u` so klayout's parser accepts them. - cdl_text = re.sub(r"(\bw=)([0-9.]+)(?=\s|$)", r"\1\2u", cdl_text, flags=re.MULTILINE) - cdl_text = re.sub(r"(\bl=)([0-9.]+)(?=\s|$)", r"\1\2u", cdl_text, flags=re.MULTILINE) - - # Rewrite X-prefix primitive fet instances to M-prefix. - cdl_text = _rewrite_x_to_m_for_primitives(cdl_text) - - parts = [] - if _REF_SPICE.is_file(): - parts.append(f".include {_REF_SPICE}\n") + cdl_text = _rename_top_subckt(cdl_text, cell) + cdl_text = _tag_geometry_units(cdl_text) + cdl_text = _rewrite_prefix_for_primitives(cdl_text) + + parts: List[str] = [] + if _needs_ref_spice(cdl_text): + if _REF_SPICE.is_file(): + parts.append(f".include {_REF_SPICE}\n") + else: + print(f"[LVS] {cell}: std-cell refs present but {_REF_SPICE} missing", flush=True) + else: + print(f"[LVS] {cell}: no std-cell refs; skipping {_REF_SPICE.name}", flush=True) parts.append(cdl_text) spice_dst.write_text("".join(parts)) return spice_dst +def _detect_substrate_name(spice_path: Path, top_cell: str) -> str: + """Pick the schematic's bulk port name to pass as klayout's --lvs_sub. + + klayout's gf180mcu deck names the implicit substrate "gf180mcu_gnd" by + default; the schematic's bulk port must use the SAME name or LVS reports + every net as unmatched. We look for the usual bulk conventions, with VSS + last because it is normally the source rail (CMIRROR's + `VREF VOUT VSS B` should pick B). + + When nothing bulk-like is present we return the deck default rather than + guessing the last positional port: passing e.g. IBIAS as the substrate net + unmatches the entire design and looks like a parser bug. + """ + try: + text = spice_path.read_text(errors="ignore") + except OSError: + return "gf180mcu_gnd" + pat = re.compile( + r"^\.subckt\s+" + re.escape(top_cell) + r"\s+(.+)$", + re.MULTILINE | re.IGNORECASE, + ) + m = pat.search(text) + if not m: + print(f"[LVS] {top_cell}: no .subckt line found; using deck default", flush=True) + return "gf180mcu_gnd" + tokens = [t for t in m.group(1).split() if "=" not in t] + by_upper = {t.upper(): t for t in tokens} + for cand in ("B", "VBULK", "VSUB", "GND", "VSS"): + if cand in by_upper: + return by_upper[cand] + print(f"[LVS] {top_cell}: no bulk-like port in {tokens}; using deck default", flush=True) + return "gf180mcu_gnd" + + def _classify_log(log: str) -> Dict[str, Any]: - """Map the klayout deck's stdout banner to a netgen-style summary so the - existing ``_parse_lvs_report`` happily reports pass/fail.""" - # Surface the most common environment failure modes explicitly so the - # report file makes the root cause obvious instead of getting binned as - # generic "LVS inconclusive". `docopt` is imported at the top of the - # gf180mcu deck's `run_lvs.py`; if it's missing the whole script aborts - # before any LVS work happens and the report would otherwise be silent. + """Map the klayout deck's stdout banner to a netgen-style summary. + + Environment failures are surfaced explicitly so the report reads as a root + cause instead of the catch-all "LVS inconclusive": run_lvs.py imports + docopt and klayout.db before doing any work, and a missing module leaves + nothing in the log but a Python traceback. + """ if "ModuleNotFoundError: No module named 'docopt'" in log: - return {"is_pass": False, "conclusion": "missing dep: docopt (pip install docopt in the LVS venv)"} + return {"is_pass": False, "conclusion": "missing dep: docopt (install in the deck interpreter)"} if "ModuleNotFoundError: No module named 'klayout'" in log: - return {"is_pass": False, "conclusion": "missing dep: klayout (pip install klayout in the LVS venv)"} + return {"is_pass": False, "conclusion": "missing dep: klayout (install in the deck interpreter)"} if "klayout: command not found" in log or "klayout: not found" in log: return {"is_pass": False, "conclusion": "klayout binary not on PATH"} + if "klayout LVS deck timed out" in log: + return {"is_pass": False, "conclusion": "deck timed out"} if re.search(r"Congratulations!\s*Netlists\s*match", log) or "INFO : Congratulations" in log: return {"is_pass": True, "conclusion": "Netlists match"} - if re.search(r"ERROR\s*:\s*Netlists\s*don.t\s*match", log) or "Netlists do not match" in log: + # Covers "don't", "don\u2019t" and "do not", with or without an ERROR prefix. + if re.search(r"Netlists\s+do\s*n.?t\s+match", log, re.IGNORECASE): return {"is_pass": False, "conclusion": "Netlists do not match"} return {"is_pass": False, "conclusion": "LVS inconclusive"} @@ -173,35 +307,39 @@ def run_lvs_klayout_gf180( netlist: str, output_file_path: str, pdk_root: Optional[str] = None, + variant: str = "D", + timeout: int = 1800, ) -> Dict[str, Any]: """Run gf180mcu klayout LVS for one cell. Mirrors `MappedPDK.lvs_netgen`'s signature: writes its primary report to ``/lvs//_lvs.rpt`` (klayout log dumped - verbatim — `_parse_lvs_report` recognises the "Netlists match" / - "Netlists do not match" lines), and stashes the extracted .cir, .lvsdb, - and lvs_run_*.log alongside it for inspection. + verbatim), and stashes the staged netlist, the extracted .cir, the .lvsdb + and any lvs_run_*.log alongside it for inspection. """ layout_path = Path(layout) netlist_path = Path(netlist) - out_root = Path(output_file_path) - rpt_dir = out_root / "lvs" / design_name + rpt_dir = Path(output_file_path) / "lvs" / design_name rpt_dir.mkdir(parents=True, exist_ok=True) pdk_root = pdk_root or os.environ.get("PDK_ROOT", "/foss/pdks") - deck_dir = _resolve_deck_dir(pdk_root) - run_lvs = deck_dir / "run_lvs.py" + run_lvs = _resolve_deck_dir(pdk_root) / "run_lvs.py" with tempfile.TemporaryDirectory(prefix=f"klvs_{design_name}_") as tmp: tmpdir = Path(tmp) - spice_staged = _stage_inputs(tmpdir, design_name, layout_path, netlist_path) + spice_staged = _stage_inputs(tmpdir, design_name, netlist_path) sub_name = _detect_substrate_name(spice_staged, design_name) + # Copy the staged netlist out BEFORE the tempdir is torn down. It is + # the actual deck input, so a misfiring rewrite is only diagnosable + # from this file. + shutil.copy(spice_staged, rpt_dir / f"{design_name}_staged.spice") + cmd = [ - "python3", str(run_lvs), + _deck_python(), str(run_lvs), f"--layout={layout_path}", f"--netlist={spice_staged}", - "--variant=D", + f"--variant={variant}", f"--topcell={design_name}", "--run_mode=flat", "--combine", @@ -210,25 +348,43 @@ def run_lvs_klayout_gf180( f"--lvs_sub={sub_name}", f"--run_dir={tmpdir}", ] - proc = subprocess.run(cmd, cwd=tmpdir, capture_output=True, text=True) - - # Even on klayout-exit-nonzero we want the log preserved for triage. - log_text = (proc.stdout or "") + (proc.stderr or "") + print(f"[LVS] {design_name}: lvs_sub={sub_name}", flush=True) + print(f"[LVS] {design_name}: {shlex.join(cmd)}", flush=True) + try: + proc = subprocess.run( + cmd, cwd=tmpdir, capture_output=True, text=True, + env=_deck_env(), timeout=timeout, + ) + log_text = (proc.stdout or "") + (proc.stderr or "") + rc = proc.returncode + except subprocess.TimeoutExpired as exc: + partial = (exc.stdout or b"") if isinstance(exc.stdout, bytes) else (exc.stdout or "") + partial_err = (exc.stderr or b"") if isinstance(exc.stderr, bytes) else (exc.stderr or "") + if isinstance(partial, bytes): + partial = partial.decode(errors="replace") + if isinstance(partial_err, bytes): + partial_err = partial_err.decode(errors="replace") + log_text = f"{partial}{partial_err}\nERROR: klayout LVS deck timed out after {timeout}s\n" + rc = -1 + + # Even on a non-zero exit we want the log preserved for triage. rpt_file = rpt_dir / f"{design_name}_lvs.rpt" rpt_file.write_text(log_text) - # Stash the extracted netlist + lvsdb + per-run log if produced. for fname in (f"{design_name}.cir", f"{design_name}.lvsdb"): src = tmpdir / fname if src.is_file(): shutil.copy(src, rpt_dir / fname) - for src in tmpdir.glob("lvs_run_*.log"): + # rglob, not glob: the deck writes its per-run log into a subdirectory + # of run_dir, so a non-recursive glob silently matches nothing. + for src in tmpdir.rglob("lvs_run_*.log"): shutil.copy(src, rpt_dir / src.name) summary = _classify_log(log_text) return { - "subproc_code": proc.returncode, + "subproc_code": rc, "report_path": str(rpt_file), + "staged_netlist": str(rpt_dir / f"{design_name}_staged.spice"), "is_pass": summary["is_pass"], "conclusion": summary["conclusion"], }