Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
115 changes: 97 additions & 18 deletions tests/lvs/klayout_gf180.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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`):
Expand All @@ -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"
Expand All @@ -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


Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -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"

Expand All @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions tests/lvs/run_cell_lvs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).",
Expand Down Expand Up @@ -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
]
Expand Down
101 changes: 101 additions & 0 deletions tests/test_lvs_pin_filter.py
Original file line number Diff line number Diff line change
@@ -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()
Loading