diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..0dc2f1a2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: Unit tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Cancel superseded runs on the same branch. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Unit tests (gdstk) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + # setup-python hashes this to key the cache; the repo has no + # requirements.txt or pyproject.toml, so point it at setup.py. + cache-dependency-path: setup.py + + # Deliberately NOT `pip install -e .`. install_requires pins + # gdsfactory<=7.7.0 and numpy<=1.24.0, which forces CPython 3.10 and + # pulls the whole layout stack; none of it is needed here. conftest.py + # sets GLAYOUT_BACKEND=gdstk precisely so tests that import glayout + # directly do not require gdsfactory, and these files build on gdstk + # only. Keeping the environment small is what makes this job fast + # enough to run on every push. + - name: Install test dependencies + run: pip install pytest gdstk numpy pandas pydantic docopt + + # The heavier DRC/LVS workflows run the gdsfactory backend (Python + # 3.10, gdsfactory 7.7), so gdstk currently has no CI coverage at all. + # Everything under tests/ except test_cells_layout.py, which is left + # out on purpose: it builds every cell in both backends, takes ~85 s, + # and carries five documented xfails, so a green result there would + # mean "nothing changed" rather than "everything works". Excluding + # rather than listing files means a test added later is picked up + # without having to remember to edit this workflow. + - name: Run tests + env: + PYTHONPATH: src + GLAYOUT_BACKEND: gdstk + PDK_ROOT: /tmp + run: pytest -q tests --ignore=tests/test_cells_layout.py diff --git a/tests/lvs/klayout_gf180.py b/tests/lvs/klayout_gf180.py index 94653017..bb5bbebd 100644 --- a/tests/lvs/klayout_gf180.py +++ b/tests/lvs/klayout_gf180.py @@ -22,7 +22,9 @@ import subprocess import tempfile from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Tuple + +import gdstk try: from lvsdb_report import analyze, render @@ -59,6 +61,24 @@ def _resolve_deck_dir(pdk_root: str) -> Path: return deck +def _top_level_ports(spice_path: Path, top_cell: str) -> List[str]: + """Port names on the reference netlist's top `.subckt`, in order. + + This is the schematic's own statement of what the cell's pins are, so it + is what decides which layout labels are pins -- see _filter_pin_labels. + """ + try: + text = spice_path.read_text(errors="ignore") + except OSError: + return [] + pat = re.compile(r"^\.subckt\s+" + re.escape(top_cell) + r"\s+(.+)$", + re.MULTILINE | re.IGNORECASE) + m = pat.search(text) + if not m: + return [] + return [tok for tok in m.group(1).split() if "=" not in tok] + + 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. @@ -70,15 +90,9 @@ def _detect_substrate_name(spice_path: Path, top_cell: str) -> str: pick B). Falls back to the last positional port, then to the deck default. """ - try: - text = spice_path.read_text(errors="ignore") - except OSError: + tokens = _top_level_ports(spice_path, top_cell) + if not tokens: 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] upper = {t.upper(): t for t in tokens} for cand in ("B", "VBULK", "VSUB", "GND", "VSS"): if cand in upper: @@ -126,6 +140,46 @@ def _rewrite_x_to_m_for_primitives(cdl_text: str) -> str: return cap_pat.sub(r"C\1\2", cdl_text) +def _filter_pin_labels(gds_path: Path, ports: List[str]) -> Tuple[List[str], bool]: + """Drop layout labels the reference netlist does not declare as pins. + + A pin label is not a property of a cell, it is a property of how the cell + is used: a diff_pair's VTAIL is a top-level pin standalone and an internal + net inside a composite. Elementary cells emit labels so they can be LVS'd + on their own, and a parent that flattens them inherits those names -- + klayout extracts them as extra top-level pins and LVS fails. + + Deciding this in the generator means every composite has to suppress its + children's labels, at every level, and one that forgets fails silently. + Deciding it here needs no cooperation from any cell: the reference netlist + already states which names are pins, and that statement is honoured. + + Only the staged copy used for extraction is filtered, so the GDS a cell + ships keeps its labels and LEF/macro flows are unaffected. + + Matching is case-insensitive: SPICE is case-insensitive and generators do + not always agree with the schematic on capitalisation (`vdd` vs `Vdd`). + + Returns the dropped texts, and whether every label was dropped -- that is + not label inheritance but a naming mismatch, worth reporting. + """ + if not ports: + return [], False + keep = {port.upper() for port in ports} + lib = gdstk.read_gds(str(gds_path)) + dropped: List[str] = [] + total = 0 + for cell in lib.cells: + for label in list(cell.labels): + total += 1 + if label.text.upper() not in keep: + cell.remove(label) + dropped.append(label.text) + if dropped: + lib.write_gds(str(gds_path)) + return dropped, bool(total) and len(dropped) == total + + 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`): @@ -138,6 +192,8 @@ def _stage_inputs(workdir: Path, cell: str, gds_src: Path, netlist_src: Path) -> 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. + * Drop labels the reference netlist does not declare as pins, so a + composite does not inherit its children's standalone pin names. """ layout_dst = workdir / f"{cell}.gds" cdl_dst = workdir / f"{cell}.cdl" @@ -163,6 +219,17 @@ def _stage_inputs(workdir: Path, cell: str, gds_src: Path, netlist_src: Path) -> parts.append(f".include {_REF_SPICE}\n") parts.append(cdl_text) spice_dst.write_text("".join(parts)) + + ports = _top_level_ports(spice_dst, cell) + dropped, all_gone = _filter_pin_labels(layout_dst, ports) + if all_gone: + print(f"[{cell}] WARNING: no layout label matches a port of " + f".subckt {cell} ({' '.join(ports)}); dropped {dropped}. " + f"Layout pin names and reference netlist pin names do not " + f"agree, so LVS will see the layout as having no pins.") + elif dropped: + print(f"[{cell}] dropped {len(dropped)} inherited label(s) not " + f"declared as pins: {' '.join(sorted(set(dropped)))}") return spice_dst @@ -220,6 +287,7 @@ def run_lvs_klayout_gf180( netlist: str, output_file_path: str, pdk_root: Optional[str] = None, + mim_option: Optional[str] = None, ) -> Dict[str, Any]: """Run gf180mcu klayout LVS for one cell. @@ -228,6 +296,12 @@ def run_lvs_klayout_gf180( 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. + + ``mim_option`` selects the MIM stack the deck extracts: "A" (met2 / + FuseTop / met3) or "B" (met4 / FuseTop / met5). The two are mutually + exclusive at process level, so the wrong one extracts no capacitor at all + and every MIM shows up as missing from the layout. Defaults to + ``$GF180_MIM_OPTION``, then to "A". """ layout_path = Path(layout) netlist_path = Path(netlist) @@ -236,6 +310,9 @@ def run_lvs_klayout_gf180( rpt_dir.mkdir(parents=True, exist_ok=True) pdk_root = pdk_root or os.environ.get("PDK_ROOT", "/foss/pdks") + mim_option = (mim_option or os.environ.get("GF180_MIM_OPTION") or "A").upper() + if mim_option not in ("A", "B"): + raise ValueError(f"mim_option must be 'A' or 'B', got {mim_option!r}") deck_dir = _resolve_deck_dir(pdk_root) run_lvs = deck_dir / "run_lvs.py" @@ -245,24 +322,26 @@ def run_lvs_klayout_gf180( sub_name = _detect_substrate_name(spice_staged, design_name) # 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. + # four presets are all wrong here: a cell can draw its MIM on option A + # (met2 / FuseTop / met3) and still route 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. + # patches its own copy. Only option A needs the patch; the deck's + # option B branch already connects all three plates. + deck_src = run_lvs.parent / "gf180mcu.lvs" lvs_deck = Path(os.environ.get("GF180_LVS_DECK") - or _deck_with_option_a_fixed(run_lvs.parent / "gf180mcu.lvs", - tmpdir / "deck")) + or (_deck_with_option_a_fixed(deck_src, tmpdir / "deck") + if mim_option == "A" else deck_src)) 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", + "mim_option": mim_option, "metal_level": "5LM", "metal_top": "11K", "poly_res": "1k", diff --git a/tests/lvs/run_cell_lvs.py b/tests/lvs/run_cell_lvs.py index 05f47ed4..238cad83 100644 --- a/tests/lvs/run_cell_lvs.py +++ b/tests/lvs/run_cell_lvs.py @@ -155,6 +155,7 @@ def _run_one_lvs(item: dict) -> dict: design_name=name, netlist=str(netlist_path), output_file_path=str(rpt_dir), + mim_option=item.get("mim_option"), ) else: pdk = _resolve_pdk(pdk_name) @@ -220,6 +221,16 @@ def main() -> int: "reporting anything useful." ), ) + parser.add_argument( + "--mim-option", choices=["A", "B"], default=None, + help=( + "gf180 MIM stack the deck should extract: A (met2/FuseTop/met3) " + "or B (met4/FuseTop/met5). They are mutually exclusive at process " + "level, so picking the wrong one extracts no capacitor and every " + "MIM reports as missing from the layout. Default: $GF180_MIM_OPTION, " + "else A." + ), + ) parser.add_argument( "--jobs", "-j", type=int, default=max(1, (os.cpu_count() or 2) - 1), help="Worker processes for parallel LVS (default: cpu_count-1).", @@ -258,6 +269,7 @@ def main() -> int: "netlist_path": str(inputs_dir / "netlists" / f"{name}.spice"), "out_dir": str(out_dir), "rpt_dir": str(rpt_dir), + "mim_option": args.mim_option, } for name in cells ] diff --git a/tests/test_lvs_pin_filter.py b/tests/test_lvs_pin_filter.py new file mode 100644 index 00000000..9d349669 --- /dev/null +++ b/tests/test_lvs_pin_filter.py @@ -0,0 +1,101 @@ +"""The reference netlist decides which layout labels are pins. + +Covers the staging step that stops a composite from inheriting its children's +standalone pin names. Pure Python: no PDK, no klayout, no GDS toolchain, so it +runs on every PR -- unlike the LVS workflow, which is triggered by `workflow_run` +and therefore only runs on the default branch, after a merge. +""" +import importlib.util +import tempfile +import unittest +from pathlib import Path + +_MODULE = Path(__file__).resolve().parent / "lvs" / "klayout_gf180.py" + + +def _load(): + """tests/lvs is not a package, so load the runner by path.""" + spec = importlib.util.spec_from_file_location("klayout_gf180", _MODULE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class PinFilterTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.mod = _load() + + def _gds(self, workdir, labels): + import gdstk + lib = gdstk.Library() + cell = lib.new_cell("cell_under_test") + for text in labels: + cell.add(gdstk.Label(text, (0, 0))) + path = Path(workdir) / "cell_under_test.gds" + lib.write_gds(str(path)) + return path + + def _labels(self, path): + import gdstk + return sorted(l.text for c in gdstk.read_gds(str(path)).cells for l in c.labels) + + def test_ports_come_from_the_top_subckt(self): + with tempfile.TemporaryDirectory() as d: + spice = Path(d) / "c.spice" + spice.write_text( + ".subckt other A B\n.ends\n" + ".subckt cell_under_test Vdd Vss Iin spike\n.ends\n" + ) + self.assertEqual( + self.mod._top_level_ports(spice, "cell_under_test"), + ["Vdd", "Vss", "Iin", "spike"], + ) + + def test_inherited_label_is_dropped_and_pins_are_kept(self): + # VTAIL is a diff_pair pin standalone and an internal net in a parent. + with tempfile.TemporaryDirectory() as d: + gds = self._gds(d, ["Vdd", "Vss", "VTAIL", "spike"]) + dropped, all_gone = self.mod._filter_pin_labels(gds, ["Vdd", "Vss", "spike"]) + self.assertEqual(dropped, ["VTAIL"]) + self.assertFalse(all_gone) + self.assertEqual(self._labels(gds), ["Vdd", "Vss", "spike"]) + + def test_matching_ignores_case(self): + # Generators and schematics disagree in practice: `vdd` vs `Vdd`. + with tempfile.TemporaryDirectory() as d: + gds = self._gds(d, ["vdd", "vss"]) + dropped, all_gone = self.mod._filter_pin_labels(gds, ["Vdd", "Vss"]) + self.assertEqual(dropped, []) + self.assertFalse(all_gone) + self.assertEqual(self._labels(gds), ["vdd", "vss"]) + + def test_dropping_every_label_is_flagged(self): + # Not inheritance but a naming mismatch: the caller warns instead of + # leaving the layout silently pinless. + with tempfile.TemporaryDirectory() as d: + gds = self._gds(d, ["n1", "n2"]) + dropped, all_gone = self.mod._filter_pin_labels(gds, ["Vdd", "Vss"]) + self.assertEqual(sorted(dropped), ["n1", "n2"]) + self.assertTrue(all_gone) + + def test_a_layout_without_labels_is_not_flagged(self): + with tempfile.TemporaryDirectory() as d: + gds = self._gds(d, []) + dropped, all_gone = self.mod._filter_pin_labels(gds, ["Vdd", "Vss"]) + self.assertEqual(dropped, []) + self.assertFalse(all_gone) + + def test_unreadable_ports_drop_nothing(self): + # Safety net: without a port list, keep every label rather than + # stripping the layout of all its pins. + with tempfile.TemporaryDirectory() as d: + gds = self._gds(d, ["Vdd", "VTAIL"]) + dropped, all_gone = self.mod._filter_pin_labels(gds, []) + self.assertEqual(dropped, []) + self.assertFalse(all_gone) + self.assertEqual(self._labels(gds), ["VTAIL", "Vdd"]) + + +if __name__ == "__main__": + unittest.main()