From 8fde3df348ba38c65e5de6eafb92807a59354be1 Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 16 Aug 2026 20:30:48 -0500 Subject: [PATCH] tests/lvs: extract gf180 with the MIM option glayout draws run_lvs.py offers four preset variants and none pairs mim_option=A with 5 metal levels, which is what glayout's gf180 mimcap needs: the MIM sits on met2/FuseTop/met3 while the routing goes up to met5. The DRM documents that combination as 1P5M (TM 6KA with MIM), and the deck takes the options individually, so it is now called directly. The deck's option A branch is broken upstream: it connects the bottom plate to metal2 rather than metal2_con, the layer the connectivity graph is built from, and never bridges via2_cap to metal3_con. Option B has all three connects. The runner patches its own copy and falls back to the original when the text is not recognised. Same GDS and netlist, only the deck changed: 19 mismatches down to 9. spice_net_names is false by default in the deck and run_lvs.py sets it. Left unset every net comes out numbered and the GDS labels never reach the report, which reads as phantom extra top-level pins. MIM caps are rewritten from X to C prefix, the same way the fets are rewritten to M. They are genuine subcircuits in the PDK, so X is correct SPICE, but the deck classifies capacitors by prefix and an X-prefix cap never becomes a device. --- tests/lvs/klayout_gf180.py | 91 ++++++++++++++++++++++++++++++++------ tests/lvs/run_cell_lvs.py | 8 +++- 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/tests/lvs/klayout_gf180.py b/tests/lvs/klayout_gf180.py index e7a666b4..94653017 100644 --- a/tests/lvs/klayout_gf180.py +++ b/tests/lvs/klayout_gf180.py @@ -87,6 +87,7 @@ def _detect_substrate_name(spice_path: Path, top_cell: str) -> str: _GF180_PRIMITIVE_FETS = ("nfet_03v3", "pfet_03v3") +_GF180_PRIMITIVE_CAPS = ("cap_mim_1f0fF", "cap_mim_1f5fF", "cap_mim_2f0fF") def _rewrite_x_to_m_for_primitives(cdl_text: str) -> str: @@ -111,7 +112,18 @@ def _rewrite_x_to_m_for_primitives(cdl_text: str) -> str: rf"^X(\S+)(\s+\S+\s+\S+\s+\S+\s+\S+\s+(?:{fet_alt})\b)", re.MULTILINE, ) - return pat.sub(r"M\1\2", cdl_text) + cdl_text = pat.sub(r"M\1\2", cdl_text) + + # Same for the MIM caps, two terminals instead of four. They are real + # subcircuits in the PDK, so X is the correct SPICE prefix -- but the deck + # classifies capacitors by prefix too, and an X-prefix cap never becomes a + # device, leaving the extracted MIM with nothing to pair with. + cap_alt = "|".join(re.escape(m) for m in _GF180_PRIMITIVE_CAPS) + cap_pat = re.compile( + rf"^X(\S+)(\s+\S+\s+\S+\s+(?:{cap_alt})\b)", + re.MULTILINE, + ) + return cap_pat.sub(r"C\1\2", cdl_text) def _stage_inputs(workdir: Path, cell: str, gds_src: Path, netlist_src: Path) -> Path: @@ -175,6 +187,33 @@ def _classify_log(log: str) -> Dict[str, Any]: return {"is_pass": False, "conclusion": "LVS inconclusive"} +# The PDK deck's option A connects the bottom plate to `metal2` instead of +# `metal2_con`, the layer the connectivity graph is built from, and never +# bridges via2_cap to metal3_con. Option B, right below it, has all three. +# Both MIM plates float without this: 10 of the opamp's 19 mismatches. +_MIM_A_BROKEN = """ connect(metal2, mim_virtual) + connect(fuse_cap, via2_cap)""" +_MIM_A_FIXED = """ connect(metal2_con, mim_virtual) + connect(fuse_cap, via2_cap) + connect(via2_cap, metal3_con)""" + + +def _deck_with_option_a_fixed(deck: Path, dest: Path) -> Path: + """Copy the PDK deck and repair its MIM option A branch. + + Returns the deck untouched when it is already fixed or the text is not + recognised, so a different PDK version still runs. + """ + connections = deck.parent / "rule_decks" / "mimcap_connections.lvs" + if not connections.is_file() or _MIM_A_BROKEN not in connections.read_text(): + return deck + + shutil.copytree(deck.parent, dest, dirs_exist_ok=True) + patched = dest / "rule_decks" / "mimcap_connections.lvs" + patched.write_text(patched.read_text().replace(_MIM_A_BROKEN, _MIM_A_FIXED)) + return dest / deck.name + + def run_lvs_klayout_gf180( layout: str, design_name: str, @@ -205,19 +244,43 @@ def run_lvs_klayout_gf180( spice_staged = _stage_inputs(tmpdir, design_name, layout_path, netlist_path) sub_name = _detect_substrate_name(spice_staged, design_name) - cmd = [ - "python3", str(run_lvs), - f"--layout={layout_path}", - f"--netlist={spice_staged}", - "--variant=D", - f"--topcell={design_name}", - "--run_mode=flat", - "--combine", - "--schematic_simplify", - "--top_lvl_pins", - f"--lvs_sub={sub_name}", - f"--run_dir={tmpdir}", - ] + # The deck is called directly rather than through run_lvs.py, whose + # four presets are all wrong here: glayout draws the MIM on option A + # (met2 / FuseTop / met3) and routes up to met5, and no preset pairs + # option A with 5 metal levels. That combination is a real process -- + # the DRM documents 1P5M (TM 6KA with MIM) -- and the deck accepts the + # options individually. + # + # GF180_LVS_DECK points at an already-fixed deck; otherwise the runner + # patches its own copy. + lvs_deck = Path(os.environ.get("GF180_LVS_DECK") + or _deck_with_option_a_fixed(run_lvs.parent / "gf180mcu.lvs", + tmpdir / "deck")) + sws = { + "input": str(layout_path), + "schematic": str(spice_staged), + "topcell": design_name, + "target_netlist": str(tmpdir / f"{design_name}.cir"), + "report": str(tmpdir / f"{design_name}.lvsdb"), + "mim_option": "A", + "metal_level": "5LM", + "metal_top": "11K", + "poly_res": "1k", + "mim_cap": "2", + "run_mode": "flat", + "combine": "true", + "schematic_simplify": "true", + "top_lvl_pins": "true", + # False by default in the deck, which numbers the nets instead of + # naming them and keeps the GDS labels out of the report. + # run_lvs.py sets it; calling the deck directly has to as well. + "spice_net_names": "true", + "lvs_sub": sub_name, + "thr": "2", + } + cmd = ["klayout", "-b", "-r", str(lvs_deck)] + for k, v in sws.items(): + cmd += ["-rd", f"{k}={v}"] proc = subprocess.run(cmd, cwd=tmpdir, capture_output=True, text=True) # Even on klayout-exit-nonzero we want the log preserved for triage. diff --git a/tests/lvs/run_cell_lvs.py b/tests/lvs/run_cell_lvs.py index e9181678..05f47ed4 100644 --- a/tests/lvs/run_cell_lvs.py +++ b/tests/lvs/run_cell_lvs.py @@ -208,12 +208,16 @@ def main() -> int: ) parser.add_argument( "--skip-cells", - default="differential_to_single_ended_converter", + default="differential_to_single_ended_converter,nmos_narrow,pmos_narrow", help=( "Comma-separated cell names to skip when --cells is not specified. " "Default skips differential_to_single_ended_converter (Magic mis-extracts " "its PMOS bulk; the cell can still be tested by passing --cells " - "differential_to_single_ended_converter)." + "differential_to_single_ended_converter) and nmos_narrow/pmos_narrow, " + "which are DRC regression cells for the dogbone diffusion: they are bare " + "primitives, their reference subckt is named after the device rather than " + "the cell, and LVS aborts with 'no schematic counterpart' instead of " + "reporting anything useful." ), ) parser.add_argument(