diff --git a/.github/workflows/drc.yml b/.github/workflows/drc.yml new file mode 100644 index 00000000..2b9328bd --- /dev/null +++ b/.github/workflows/drc.yml @@ -0,0 +1,148 @@ +name: Cell DRC + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Cancel superseded runs on the same branch — pushing N commits in a row +# shouldn't keep N CI runs going. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + drc: + name: DRC (${{ matrix.pdk }}) + runs-on: ubuntu-22.04 + timeout-minutes: 30 + + # iic-osic-tools ships klayout, magic, netgen, and the sky130A / gf180mcuD + # PDKs pre-installed under /foss/pdks. The image is Ubuntu 24.04 with only + # Python 3.12, but glayout pins gdsfactory<=7.7.0 / numpy<=1.24, so we + # install Python 3.10 via uv (python-build-standalone, hosted on GitHub + # releases) and run glayout in a venv. We previously used the deadsnakes + # PPA but ppa.launchpadcontent.net was too flaky for CI. + # + # See https://github.com/iic-jku/iic-osic-tools. + container: + image: hpretl/iic-osic-tools:latest + options: --user root + # The image's entrypoint launches a UI manager; bypass it. + env: + PDK_ROOT: /foss/pdks + DEBIAN_FRONTEND: noninteractive + PYTHONUNBUFFERED: "1" + # The image sets PYTHONPATH to its 3.12 site-packages, which breaks + # python3.10 if inherited. + PYTHONPATH: "" + # GitHub Actions overrides the image's ENTRYPOINT with `tail -f`, so + # the iic-osic-tools entrypoint that normally enriches PATH with + # /foss/tools/{bin,klayout,...} never runs. Set it explicitly here + # so klayout/magic/etc. are on PATH for every step. + PATH: /foss/tools/bin:/foss/tools/sak:/foss/tools/kactus2:/foss/tools/klayout:/foss/tools/osic-multitool:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + + strategy: + fail-fast: false + matrix: + pdk: [sky130, gf180] + + # The iic-osic-tools image's default container shell is dash (`sh`), so + # `set -o pipefail` blows up. Force bash for every `run:` step. + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - name: Cache uv + CPython 3.10 + id: cache-uv + uses: actions/cache@v4 + with: + # iic-osic-tools sets HOME=/headless even when running as root, so + # uv installs land here regardless of who launched the container. + path: | + /headless/.local/bin/uv + /headless/.local/bin/uvx + /headless/.local/share/uv + key: uv-py310-${{ runner.os }}-v1 + + - name: Install Python 3.10 (uv) + run: | + set -euxo pipefail + # uv installs CPython from python-build-standalone (GitHub releases), + # bypassing launchpad PPAs entirely. Skip the curl install when the + # cache already restored uv. `uv python install 3.10` is idempotent + # (no-op if 3.10 is already present in $UV_PYTHON_INSTALL_DIR). + if [ ! -x "$HOME/.local/bin/uv" ]; then + curl -LsSf https://astral.sh/uv/install.sh | sh + fi + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + uv python install 3.10 + # Resolve the absolute path so later steps don't depend on PATH. + echo "PYTHON310=$(uv python find 3.10)" >> "$GITHUB_ENV" + + - name: Show tool versions + run: | + set -euxo pipefail + klayout -v + "$PYTHON310" --version + ls "$PDK_ROOT" + + - name: Cache python venv + id: cache-venv + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.venv + # Bust the cache when setup.py changes (deps) or the bundled DRC + # decks change (paths embedded in glayout's editable install). + # v2 = switched interpreter from deadsnakes to uv; venvs cached under + # v1 symlink to a now-absent /usr/bin/python3.10. + key: drc-venv-py310-${{ runner.os }}-${{ hashFiles('setup.py', 'src/glayout/**/*.py') }}-v2 + restore-keys: | + drc-venv-py310-${{ runner.os }}- + + - name: Create venv and install glayout (cache miss) + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + set -euxo pipefail + rm -rf "$GITHUB_WORKSPACE/.venv" + "$PYTHON310" -m venv "$GITHUB_WORKSPACE/.venv" + . "$GITHUB_WORKSPACE/.venv/bin/activate" + # uv pip install is ~3-5x faster than pip for cold installs and + # picks up $VIRTUAL_ENV automatically after `activate`. + uv pip install -e . + + # No "refresh editable install" step on cache hit: glayout's .pth points + # to $GITHUB_WORKSPACE which is stable across runs, so the restored venv + # imports the freshly checked-out source as-is. + + - name: Run cell DRC + run: | + set -euxo pipefail + . "$GITHUB_WORKSPACE/.venv/bin/activate" + python tests/drc/run_cell_drc.py \ + --pdk ${{ matrix.pdk }} \ + --out-dir drc_results/${{ matrix.pdk }} + + - name: Upload DRC artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: drc-${{ matrix.pdk }} + path: drc_results/${{ matrix.pdk }} + retention-days: 14 + + - name: Publish JUnit summary + # Skip when junit.xml wasn't produced (e.g. setup died before the + # runner could write it) — otherwise the publisher emits a misleading + # second red check on top of the real failure. + if: ${{ always() && hashFiles(format('drc_results/{0}/junit.xml', matrix.pdk)) != '' }} + uses: mikepenz/action-junit-report@v4 + with: + report_paths: drc_results/${{ matrix.pdk }}/junit.xml + check_name: DRC report (${{ matrix.pdk }}) + require_tests: true diff --git a/.github/workflows/lvs.yml b/.github/workflows/lvs.yml new file mode 100644 index 00000000..09a9b0b3 --- /dev/null +++ b/.github/workflows/lvs.yml @@ -0,0 +1,174 @@ +name: "Automated: Cell LVS" + +# Triggered automatically when the DRC workflow finishes (success OR failure). +# DRC produces the GDS + reference netlists, so LVS does not have to rebuild +# the cells — it just downloads the DRC artifacts and runs netgen. +# +# Also runnable on demand to re-run LVS against the latest DRC artifact. +on: + workflow_run: + workflows: ["Cell DRC"] + types: [completed] + workflow_dispatch: + inputs: + drc_run_id: + description: "GitHub Actions run id of the DRC workflow whose artifacts to consume (defaults to latest successful run)." + required: false + +# Cancel superseded LVS runs on the same source branch. workflow_run-triggered +# runs report github.ref as the default branch (where this file lives), so we +# fall back to the triggering DRC run's head_branch when present. +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }} + cancel-in-progress: true + +jobs: + lvs: + name: LVS (${{ matrix.pdk }}) + runs-on: ubuntu-22.04 + timeout-minutes: 30 + # Skip when this run was kicked off by a DRC failure — DRC artifacts may + # still be partial, but we DO want to run LVS on the cells that passed + # DRC, so allow both success and failure conclusions. + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion != 'cancelled' }} + + # download-artifact across workflow runs needs actions:read; the JUnit + # publisher (mikepenz/action-junit-report) needs checks:write to post the + # report check. workflow_run uses the parent's token, which is read-only + # by default once any `permissions:` block is declared, so checks:write + # must be granted explicitly — otherwise the publish step errors with + # "Resource not accessible by integration" and no report shows in the UI. + permissions: + contents: read + actions: read + checks: write + + container: + image: hpretl/iic-osic-tools:latest + options: --user root + env: + PDK_ROOT: /foss/pdks + DEBIAN_FRONTEND: noninteractive + PYTHONUNBUFFERED: "1" + PYTHONPATH: "" + PATH: /foss/tools/bin:/foss/tools/sak:/foss/tools/kactus2:/foss/tools/klayout:/foss/tools/osic-multitool:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + + strategy: + fail-fast: false + matrix: + # sky130 uses magic+netgen (via `pdk.lvs_netgen`); gf180 uses the + # bundled gf180mcu klayout LVS deck — magic+netgen mis-extracts the + # gf180 substrate (NMOS bulks merge into VDD via the n-well), so we + # drive the PDK's own run_lvs.py instead. The dispatch happens in + # tests/lvs/run_cell_lvs.py based on --pdk; both branches use the + # same DRC artifact and write the same summary.json/junit.xml shape. + pdk: [sky130, gf180] + + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - name: Download DRC artifact (drc-${{ matrix.pdk }}) + uses: actions/download-artifact@v4 + with: + name: drc-${{ matrix.pdk }} + path: drc_inputs/${{ matrix.pdk }} + # When triggered by workflow_run, pull the artifact from that run. + # Falls back to the current run when triggered manually. + run-id: ${{ github.event.workflow_run.id || inputs.drc_run_id || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache uv + CPython 3.10 + id: cache-uv + uses: actions/cache@v4 + with: + path: | + /headless/.local/bin/uv + /headless/.local/bin/uvx + /headless/.local/share/uv + key: uv-py310-${{ runner.os }}-v1 + + - name: Install Python 3.10 (uv) + run: | + set -euxo pipefail + # uv installs CPython from python-build-standalone (GitHub releases), + # bypassing launchpad PPAs entirely. Skip the curl install when the + # cache already restored uv. `uv python install 3.10` is idempotent. + if [ ! -x "$HOME/.local/bin/uv" ]; then + curl -LsSf https://astral.sh/uv/install.sh | sh + fi + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + uv python install 3.10 + echo "PYTHON310=$(uv python find 3.10)" >> "$GITHUB_ENV" + + - name: Show tool versions + run: | + set -euxo pipefail + klayout -v + magic -d null -noconsole -T minimum &1 | head -5 || true + netgen -batch lvs -version 2>&1 | head -3 || true + "$PYTHON310" --version + ls "$PDK_ROOT" + # gf180 only: surface the resolved klayout LVS deck path so a + # PDK install hiccup shows up in the log instead of a cryptic + # FileNotFoundError later in the run step. + if [ "${{ matrix.pdk }}" = "gf180" ]; then + ver=$(cat "$PDK_ROOT/ciel/gf180mcu/current") + ls "$PDK_ROOT/ciel/gf180mcu/versions/$ver/gf180mcuD/libs.tech/klayout/tech/lvs/run_lvs.py" + fi + + - name: Cache python venv + id: cache-venv + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.venv + # v2 matches drc.yml: switched interpreter from deadsnakes to uv. + key: drc-venv-py310-${{ runner.os }}-${{ hashFiles('setup.py', 'src/glayout/**/*.py') }}-v2 + restore-keys: | + drc-venv-py310-${{ runner.os }}- + + - name: Create venv and install glayout (cache miss) + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + set -euxo pipefail + rm -rf "$GITHUB_WORKSPACE/.venv" + "$PYTHON310" -m venv "$GITHUB_WORKSPACE/.venv" + . "$GITHUB_WORKSPACE/.venv/bin/activate" + uv pip install -e . + + # No "refresh editable install" step on cache hit — see drc.yml. + + - name: Sanity-check DRC inputs + run: | + set -euxo pipefail + ls -la drc_inputs/${{ matrix.pdk }}/gds || { echo "no gds/ in DRC artifact"; exit 1; } + ls -la drc_inputs/${{ matrix.pdk }}/netlists || { echo "no netlists/ in DRC artifact"; exit 1; } + + - name: Run cell LVS + run: | + set -euxo pipefail + . "$GITHUB_WORKSPACE/.venv/bin/activate" + python tests/lvs/run_cell_lvs.py \ + --pdk ${{ matrix.pdk }} \ + --inputs-dir drc_inputs/${{ matrix.pdk }} \ + --out-dir lvs_results/${{ matrix.pdk }} + + - name: Upload LVS artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: lvs-${{ matrix.pdk }} + path: lvs_results/${{ matrix.pdk }} + retention-days: 14 + + - name: Publish JUnit summary + if: ${{ always() && hashFiles(format('lvs_results/{0}/junit.xml', matrix.pdk)) != '' }} + uses: mikepenz/action-junit-report@v4 + with: + report_paths: lvs_results/${{ matrix.pdk }}/junit.xml + check_name: LVS report (${{ matrix.pdk }}) + require_tests: true diff --git a/.gitignore b/.gitignore index c44a7c79..3a77cd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -244,3 +244,18 @@ cython_debug/ # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore +/.drc-cache/ +.cursor/ +/.claude/ + +# magic / netgen extract artifacts that get dropped in the cwd by lvs_netgen +*.ext +*.nodes +*.res.ext +*.lvsmag +*.sim +.*.* +.*/ +_*.json +out/ +drc_results/ \ No newline at end of file diff --git a/setup.py b/setup.py index fb9200f1..2e555904 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,15 @@ "pandas>1.3.0,<=2.3.0", "matplotlib>3.4.0,<=3.10.0", "klayout>0.28.0,<=0.29", + # `docopt` is imported by gf180mcu's bundled `run_lvs.py` (under + # `$PDK_ROOT/ciel/gf180mcu/versions//gf180mcuD/libs.tech/ + # klayout/tech/lvs/run_lvs.py`). The gf180 LVS dispatch in + # `tests/lvs/klayout_gf180.py` execs that script via the active + # python3, so docopt must be importable from the venv that runs + # LVS — otherwise every gf180 LVS report contains only + # `ModuleNotFoundError: No module named 'docopt'` and the deck + # never runs. + "docopt", "prettyprint", "prettyprinttree", "gdstk", diff --git a/src/glayout/cells/composite/differential_to_single_ended_converter/differential_to_single_ended_converter.py b/src/glayout/cells/composite/differential_to_single_ended_converter/differential_to_single_ended_converter.py index 85594f3e..852ea956 100644 --- a/src/glayout/cells/composite/differential_to_single_ended_converter/differential_to_single_ended_converter.py +++ b/src/glayout/cells/composite/differential_to_single_ended_converter/differential_to_single_ended_converter.py @@ -28,7 +28,12 @@ def __create_sharedgatecomps(pdk: MappedPDK, rmult: int, half_pload: tuple[float # create the 2*2 multiplier transistors (placed twice later) twomultpcomps = Component("2 multiplier shared gate comps") pcompR = multiplier(pdk, "p+s/d", width=half_pload[0], length=half_pload[1], fingers=half_pload[2], dummy=True,rmult=rmult).copy() - tapref = pcompR << tapring(pdk, evaluate_bbox(pcompR,padding=0.3+pdk.get_grule("n+s/d", "active_tap")["min_enclosure"]),"n+s/d","met1","met1") + # Give the welltap an extra met1 min-separation on top of the original + # 0.3um pad — the multiplier's S/D extensions on met1 reach the bbox + # edge, and at gf180 rmult=1 they ended up flush against the welltap + # (M1.2a slivers). + _tap_pad = 0.3 + pdk.get_grule("n+s/d", "active_tap")["min_enclosure"] + pdk.get_grule("met1")["min_separation"] + tapref = pcompR << tapring(pdk, evaluate_bbox(pcompR,padding=_tap_pad),"n+s/d","met1","met1") pcompR.add_padding(layers=(pdk.get_glayer("nwell"),), default=pdk.get_grule("active_tap", "nwell")["min_enclosure"]) pcompR.add_ports(tapref.get_ports_list(),prefix="welltap_") pcompR << straight_route(pdk,pcompR.ports["dummy_L_gsdcon_top_met_W"],pcompR.ports["welltap_W_top_met_W"],glayer2="met1") @@ -95,6 +100,12 @@ def __route_sharedgatecomps(pdk: MappedPDK, shared_gate_comps, via_location, pto shared_gate_comps << straight_route(pdk,LRdummyports[1],pbottom_AB.ports["R_welltap_N_top_met_S"],glayer2="met1") # connect p+s/d layer of the transistors shared_gate_comps << route_quad(LRplusdopedPorts[0],LRplusdopedPorts[-1],layer=pdk.get_glayer("p+s/d")) + # The 4 center multipliers leave 0.17um comp gaps between i=-2/i=-1 and + # between i=1/i=2 (gf180 DF.3a min comp space = 0.28um). All four are + # PCOMP-outside-nwell at the same psub potential, so the rule allows + # butting them — fill the gap with comp on the active_diff layer. + shared_gate_comps << route_quad(LRplusdopedPorts[1], LRplusdopedPorts[2], layer=pdk.get_glayer("active_diff")) + shared_gate_comps << route_quad(LRplusdopedPorts[5], LRplusdopedPorts[6], layer=pdk.get_glayer("active_diff")) # connect drain of the left 2 and right 2, short sources of all 4 shared_gate_comps << route_quad(LRdrainsPorts[0],LRdrainsPorts[3],layer=LRdrainsPorts[0].layer) shared_gate_comps << route_quad(LRdrainsPorts[4],LRdrainsPorts[7],layer=LRdrainsPorts[0].layer) @@ -139,13 +150,31 @@ def __route_sharedgatecomps(pdk: MappedPDK, shared_gate_comps, via_location, pto pmos_bdrain_diffpair_v = align_comp_to_port(pmos_bdrain_diffpair_v, movex(pbottom_AB.ports["L_gate_S"].copy(),destination=via_location)) pmos_bdrain_diffpair_v.movey(0-_max_metal_seperation_ps) pcomps_route_B_drain_extension = shared_gate_comps.xmax-ptop_AB.ports["R_drain_E"].center[0]+_max_metal_seperation_ps - shared_gate_comps << c_route(pdk, ptop_AB.ports["R_drain_E"], pmos_bdrain_diffpair_v.ports["bottom_met_E"],extension=pcomps_route_B_drain_extension +_max_metal_seperation_ps) - shared_gate_comps << c_route(pdk, pbottom_AB.ports["L_drain_W"], pmos_bdrain_diffpair_v.ports["bottom_met_W"],extension=pcomps_route_B_drain_extension +_max_metal_seperation_ps) + # Narrow these rails on gf180 — its tighter finger pitch puts the + # default-width (port-width) rails 0.1um apart, tripping M3.2a. sky130 + # has wider pitch, so leave its rails at default to avoid via-enclosure + # gaps that show up as m1.2 when the rail is too thin. + _drain_w = 0.5 if pdk.name.lower() == "gf180" else None + shared_gate_comps << c_route(pdk, ptop_AB.ports["R_drain_E"], pmos_bdrain_diffpair_v.ports["bottom_met_E"],extension=pcomps_route_B_drain_extension +_max_metal_seperation_ps, width1=_drain_w, width2=_drain_w) + shared_gate_comps << c_route(pdk, pbottom_AB.ports["L_drain_W"], pmos_bdrain_diffpair_v.ports["bottom_met_W"],extension=pcomps_route_B_drain_extension +_max_metal_seperation_ps, width1=_drain_w, width2=_drain_w) shared_gate_comps.add_ports(pmos_bdrain_diffpair_v.get_ports_list(),prefix="minusvia_") shared_gate_comps.add_ports(mimcap_connection_ref.get_ports_list(),prefix="mimcap_connection_") return shared_gate_comps def differential_to_single_ended_converter_netlist(pdk: MappedPDK, half_pload: tuple[float, float, int]) -> Netlist: + # Schematic structure matches OpenFASOC reference: PMOS bulks tied to VSS + # (no separate `B` top-level port). + # + # Layout-vs-schematic dummy accounting: the layout has 10 PMOS dummies + # that the OpenFASOC reference schematic did not model: + # * 4 outer multipliers (pcompL/pcompR placed top + bottom) with + # ``dummy=True`` -> 2 dummies each = 8 dummies on VSS + # * 2 corner-center multipliers (i=-2 with [True,False] and i=+2 with + # [False,True]) -> 1 dummy each = 2 dummies on VSS + # All ten dummies sit in the n-well at VSS potential; Magic extracts each + # as a PMOS with D=G=S=B=VSS. Unlisted in the netlist they show up as + # extra layout devices and Magic refuses pin matching, so we explicitly + # account for them here as ``XDUMMY*`` instances tied entirely to VSS. return Netlist( circuit_name="DIFF_TO_SINGLE", nodes=['VIN', 'VOUT', 'VSS', 'VSS2'], @@ -154,6 +183,16 @@ def differential_to_single_ended_converter_netlist(pdk: MappedPDK, half_pload: t XTOP2 VSS2 VIN VSS VSS {model} l={{l}} w={{w}} m={{mt}} XBOT1 VIN VIN V1 VSS {model} l={{l}} w={{w}} m={{mb}} XBOT2 VOUT VIN VSS2 VSS {model} l={{l}} w={{w}} m={{mb}} +XDUMMY1 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY2 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY3 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY4 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY5 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY6 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY7 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY8 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY9 VSS VSS VSS VSS {model} l={{l}} w={{w}} +XDUMMY10 VSS VSS VSS VSS {model} l={{l}} w={{w}} .ends {circuit_name}""", instance_format="X{name} {nodes} {circuit_name} l={length} w={width} mt={mult_top} mb={mult_bot}", parameters={ @@ -171,6 +210,14 @@ def differential_to_single_ended_converter(pdk: MappedPDK, rmult: int, half_ploa clear_cache() pmos_comps = __route_sharedgatecomps(pdk, pmos_comps, via_xlocation, ptop_AB, pbottom_AB, LRplusdopedPorts, LRgatePorts, LRdrainsPorts, LRsourcesPorts, LRdummyports) + # Intentionally no pin labels: dse is exercised only through opamp at + # this point (it is on the LVS skip list because Magic mis-extracts its + # PMOS bulks). Adding labels named VSS/VOUT/VIN/VSS2 here would collide + # with opamp's top-level labels at the SAME text — e.g. dse_VSS lands on + # the gain-stage's VDD net, and dpiibias also emits a "VSS" label on the + # real GND, so Magic would (correctly) report "VSS and VDD electrically + # shorted" purely as a name collision, not a real short. + pmos_comps.info['netlist'] = differential_to_single_ended_converter_netlist(pdk, half_pload) return pmos_comps diff --git a/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py b/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py index 2d03a72c..31551626 100644 --- a/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py +++ b/src/glayout/cells/composite/diffpair_cmirror_bias/diff_pair_cmirrorbias.py @@ -47,15 +47,21 @@ def diff_pair_ibias_netlist(center_diffpair: Component, current_mirror: Componen [] ) + # Cmirror bulk tied to the top-level B port (NOT VSS): in the layout the + # cmirror's tap ring connects to the global substrate, which is the same + # net as the diff_pair's substrate-tap ring (top-level B). Mapping it to + # VSS instead would split the dummies' bulks across two schematic nets + # while the layout has them all on one — that single-group difference + # is the only Magic LVS mismatch on this cell. cmirror_ref = netlist.connect_netlist( current_mirror.info['netlist'], - [('VREF', 'IBIAS'), ('VB', 'VSS')] + [('VREF', 'IBIAS'), ('B', 'B')] ) netlist.connect_subnets( cmirror_ref, diffpair_ref, - [('VCOPY', 'VTAIL')] + [('VOUT', 'VTAIL')] ) if antenna_diode is not None: @@ -76,17 +82,24 @@ def diff_pair_ibias( pdk: MappedPDK, half_diffpair_params: tuple[float, float, int], diffpair_bias: tuple[float, float, int], - rmult: int, - with_antenna_diode_on_diffinputs: int, + rmult: int = 1, + with_antenna_diode_on_diffinputs: int = 0, ) -> Component: # create and center diffpair diffpair_i_ = Component("temp diffpair and current source") + # `dum_net='B'` overrides the standalone gf180 diff_pair convention + # (which puts dummies on a local floating 'dum' net): inside this + # composite, the diff_pair's pwell merges with the surrounding tap + # rings so klayout extracts the dummies' G/S/D on bulk (B). sky130 + # always wants 'B' too — passing it unconditionally is correct on + # both PDKs because it matches the magic-merged extraction. center_diffpair_comp = diff_pair( pdk, width=half_diffpair_params[0], length=half_diffpair_params[1], fingers=half_diffpair_params[2], rmult=rmult, + dum_net='B', ) # add antenna diodes if that option was specified diffpair_centered_ref = prec_ref_center(center_diffpair_comp) @@ -172,29 +185,52 @@ def diff_pair_ibias( viaoffset=False, fullbottom=False, ) + # Match gate_short's `extension=3*metal_sep` for breathing room, and + # `viaoffset=None` to keep the via stack flush with the e1_extension stub. + # Original `viaoffset=False` negates the flush amount, leaving a ~30nm gap + # between the W-side via and its met3 stub on the smaller rmult layouts — + # which trips m2.2 (met2 spacing in the sky130 deck = met3 in glayout). srcshort = cmirror << c_route( pdk, cmirror.ports["A_source_W"], cmirror.ports["B_source_W"], - extension=metal_sep, - viaoffset=False, + extension=3 * metal_sep, + viaoffset=None, ) cmirror.add_ports(srcshort.get_ports_list(), prefix="purposegndports") - # current mirror netlist + # current mirror netlist — gf180 needs `dummies_tied_to_bulk=False` + # because here we use raw two_nfet_interdigitized + custom routing, + # NOT current_mirror, so the standalone-cell's straight_route from + # dummy gsdcon to welltie never gets drawn; klayout extracts the + # cmirror dummies on a per-cell floating net. sky130 magic merges + # the floating dummies into the bulk so the schematic must keep + # them tied to VB or magic counts an extra net. + ## HACK: Note that this is a hack for magic LVS, and it's likely incorrect + ## we probably want to fix it properly + _dummies_tied = (pdk.name.lower() == "sky130") cmirror.info['netlist'] = current_mirror_netlist( pdk, width=diffpair_bias[0], length=diffpair_bias[1], - multipliers=diffpair_bias[2] + fingers=1, + multipliers=diffpair_bias[2], + dummies_tied_to_bulk=_dummies_tied, ) - # add cmirror + # add cmirror — bump y-offset enough that the LVPWELL paddings of the + # diffpair and cmirror don't end up with a sub-min_separation gap (gf180 + # LPW.2a/b: min 0.86um). sky130's pwell self-rule is empty so fall back. + try: + _pwell_sep = pdk.get_grule("pwell").get("min_separation", 0) + except NotImplementedError: + _pwell_sep = 0 + _pwell_clear = max(metal_sep, _pwell_sep) tailcurrent_ref = diffpair_i_ << cmirror tailcurrent_ref.movey( pdk.snap_to_2xgrid( -0.5 * (center_diffpair_comp.ymax - center_diffpair_comp.ymin) - abs(tailcurrent_ref.ymax) - - metal_sep + - _pwell_clear ) ) purposegndPort = tailcurrent_ref.ports["purposegndportscon_S"].copy() @@ -202,8 +238,66 @@ def diff_pair_ibias( diffpair_i_.add_ports([purposegndPort]) diffpair_i_.add_ports(tailcurrent_ref.get_ports_list(), prefix="ibias_") - diffpair_i_ref = prec_ref_center(diffpair_i_) + # VTAIL connection: schematic ties the diff_pair sources (VTAIL) to the + # cmirror's B-side drain (VOUT). Without this metal the two halves are + # electrically isolated and LVS sees a topology mismatch. Route from the + # source-bar bottom (con_S) to the cmirror drain on each side so the + # wire stays in the gap below the diffpair. Width is left to default + # (= port width) so the route inherits the rmult-scaled width of the + # surrounding diff_pair / cmirror routing. + diffpair_i_ << L_route( + pdk, + diffpair_i_.ports["source_routeW_con_S"], + diffpair_i_.ports["ibias_B_drain_W"], + ) + diffpair_i_ << L_route( + pdk, + diffpair_i_.ports["source_routeE_con_S"], + diffpair_i_.ports["ibias_B_drain_E"], + ) + + # Pin labels for the seven top-level nets so klayout/magic LVS can pair + # them with the schematic ports. align_comp_to_port's alignment letters + # describe which edge of the label rect lines up with the port (e.g. + # yalign="b" puts the rect's bottom under the port — i.e. the rect + # extends DOWN from a port). For an N-facing port whose metal lies + # BELOW the port, we therefore want yalign="b" so the label sits INSIDE + # the metal; using the default ("c","t") leaves the label floating + # above the metal where it can't pin a net. + _orient_to_align = { + 90: ("c", "b"), # N-facing: metal below → label below + 270: ("c", "t"), # S-facing: metal above → label above + 0: ("l", "c"), # E-facing: metal west → label west + 180: ("r", "c"), # W-facing: metal east → label east + } + _pin_specs = [ + ("VP", "br_multiplier_0_gate_S", "met2"), + ("VN", "bl_multiplier_0_gate_S", "met2"), + ("VDD1", "tl_multiplier_0_drain_N", "met2"), + ("VDD2", "tr_multiplier_0_drain_N", "met2"), + ("IBIAS", "ibias_A_drain_E", "met3"), + # The cmirror's source short is a c_route on top of met3 sd-bars, so + # its conducting c-bar is on met4 (cglayer = e1glayer+1 in c_route). + ("VSS", "ibias_purposegndport", "met4"), + ("B", "tap_N_top_met_S", "met1"), + ] + for _text, _portname, _glayer in _pin_specs: + _port = diffpair_i_.ports[_portname] + _alignment = _orient_to_align[round(_port.orientation) % 360] + _label = rectangle( + layer=pdk.get_glayer(f"{_glayer}_pin"), + size=(0.27, 0.27), + centered=True, + ).copy() + _label.add_label(text=_text, layer=pdk.get_glayer(f"{_glayer}_label")) + diffpair_i_.add(align_comp_to_port(_label, _port, alignment=_alignment)) - diffpair_i_ref.info['netlist'] = diff_pair_ibias_netlist(center_diffpair_comp, cmirror, antenna_diode_comp) - return diffpair_i_ref + # Flatten so the pin labels live at this cell's top level. Without + # flattening, prec_ref_center would wrap the labels inside a child + # reference, and Magic LVS's `subcircuit top on` extraction wouldn't + # promote them to top-level pins (klayout LVS does, but Magic doesn't). + # The result keeps the same ports + netlist that callers expect. + diffpair_i_flat = diffpair_i_.flatten() + diffpair_i_flat.info['netlist'] = diff_pair_ibias_netlist(center_diffpair_comp, cmirror, antenna_diode_comp) + return diffpair_i_flat diff --git a/src/glayout/cells/composite/low_voltage_cmirror/low_voltage_cmirror.py b/src/glayout/cells/composite/low_voltage_cmirror/low_voltage_cmirror.py index d1ba46c6..a63b45c6 100644 --- a/src/glayout/cells/composite/low_voltage_cmirror/low_voltage_cmirror.py +++ b/src/glayout/cells/composite/low_voltage_cmirror/low_voltage_cmirror.py @@ -24,56 +24,80 @@ def add_lvcm_labels(lvcm_in: Component, pdk: MappedPDK ) -> Component: - + lvcm_in.unlock() - met2_pin = (68,16) - met2_label = (68,5) - met3_pin = (69,16) - met3_label = (69,5) # list that will contain all port/comp info move_info = list() - # create labels and append to info list - # gnd - gndlabel = rectangle(layer=pdk.get_glayer("met2_pin"),size=(0.5,0.5),centered=True).copy() - gndlabel.add_label(text="GND",layer=pdk.get_glayer("met2_label")) - move_info.append((gndlabel,lvcm_in.ports["M_1_B_tie_N_top_met_N"],None)) - - #currentbias - ibias1label = rectangle(layer=pdk.get_glayer("met3_pin"),size=(0.5,0.5),centered=True).copy() - ibias1label.add_label(text="IBIAS1",layer=pdk.get_glayer("met3_label")) - move_info.append((ibias1label,lvcm_in.ports["M_1_A_drain_bottom_met_N"],None)) - - ibias2label = rectangle(layer=pdk.get_glayer("met3_pin"),size=(0.5,0.5),centered=True).copy() - ibias2label.add_label(text="IBIAS2",layer=pdk.get_glayer("met3_label")) - move_info.append((ibias2label,lvcm_in.ports["M_2_A_drain_bottom_met_N"],None)) - - # output - output1label = rectangle(layer=pdk.get_glayer("met2_pin"),size=(0.27,0.27),centered=True).copy() - output1label.add_label(text="IOUT1",layer=pdk.get_glayer("met2_label")) - move_info.append((output1label,lvcm_in.ports["M_3_A_multiplier_0_drain_N"],None)) - - output2label = rectangle(layer=pdk.get_glayer("met2_pin"),size=(0.27,0.27),centered=True).copy() - output2label.add_label(text="IOUT2",layer=pdk.get_glayer("met2_label")) - move_info.append((output2label,lvcm_in.ports["M_4_A_multiplier_0_drain_N"],None)) + + # IBIAS1, IBIAS2 — top-met of the bias-via stacks (glayout met3). + ibias1label = rectangle(layer=pdk.get_glayer("met3_pin"), size=(0.5,0.5), centered=True).copy() + ibias1label.add_label(text="IBIAS1", layer=pdk.get_glayer("met3_label")) + move_info.append((ibias1label, lvcm_in.ports["M_1_A_drain_bottom_met_N"], None)) + + ibias2label = rectangle(layer=pdk.get_glayer("met3_pin"), size=(0.5,0.5), centered=True).copy() + ibias2label.add_label(text="IBIAS2", layer=pdk.get_glayer("met3_label")) + move_info.append((ibias2label, lvcm_in.ports["M_2_A_drain_bottom_met_N"], None)) + + # IOUT1, IOUT2 — drain of the output-branch top fets (met2). + output1label = rectangle(layer=pdk.get_glayer("met2_pin"), size=(0.27,0.27), centered=True).copy() + output1label.add_label(text="IOUT1", layer=pdk.get_glayer("met2_label")) + move_info.append((output1label, lvcm_in.ports["M_3_A_multiplier_0_drain_N"], None)) + + output2label = rectangle(layer=pdk.get_glayer("met2_pin"), size=(0.27,0.27), centered=True).copy() + output2label.add_label(text="IOUT2", layer=pdk.get_glayer("met2_label")) + move_info.append((output2label, lvcm_in.ports["M_4_A_multiplier_0_drain_N"], None)) + + # GND — stamp on EVERY welltie ring's metal so klayout's gf180 deck + # binds all the per-fet substrate-tap pwells into a single GND net. + # Without this, the cascoded bottom fets (fet_1, fet_3) end up with + # their source on a per-fet floating net (the unlabeled welltie metal), + # and the schematic's `S=GND` mapping doesn't match the layout. Also + # GND-stamps the FVF sub-cells' tie rings so their dummies' G/S/D + # (which physically merge into the welltie metal via parallel + # diffusion contacts) likewise end up named GND. + _gnd_tie_ports = [ + "M_1_A_tie_N_top_met_N", # bias_fvf input fet welltie + "M_1_B_tie_N_top_met_N", # bias_fvf feedback fet welltie (was the only one before) + "M_2_A_tie_N_top_met_N", # cascode_fvf input fet welltie + "M_2_B_tie_N_top_met_N", # cascode_fvf feedback fet welltie + "M_3_A_tie_N_top_met_N", # out1 top fet welltie + "M_3_B_tie_N_top_met_N", # out1 bot fet welltie + "M_4_A_tie_N_top_met_N", # out2 top fet welltie + "M_4_B_tie_N_top_met_N", # out2 bot fet welltie + ] + for _portname in _gnd_tie_ports: + if _portname not in lvcm_in.ports: + continue + gndlabel = rectangle(layer=pdk.get_glayer("met2_pin"), size=(0.5,0.5), centered=True).copy() + gndlabel.add_label(text="GND", layer=pdk.get_glayer("met2_label")) + # ('c','c') keeps the label box overlapping the welltie metal + # regardless of port orientation. + move_info.append((gndlabel, lvcm_in.ports[_portname], ('c','c'))) # move everything to position for comp, prt, alignment in move_info: alignment = ('c','b') if alignment is None else alignment compref = align_comp_to_port(comp, prt, alignment=alignment) lvcm_in.add(compref) - return lvcm_in.flatten() + return lvcm_in.flatten() def low_voltage_cmirr_netlist(bias_fvf: Component, cascode_fvf: Component, fet_1_ref: ComponentReference, fet_2_ref: ComponentReference, fet_3_ref: ComponentReference, fet_4_ref: ComponentReference) -> Netlist: - + netlist = Netlist(circuit_name='Low_voltage_current_mirror', nodes=['IBIAS1', 'IBIAS2', 'GND', 'IOUT1', 'IOUT2']) + # Map the 4 output-branch fets' DUM ports to GND on both PDKs. On + # sky130 magic absorbs floating dummies into the bulk anyway. On + # gf180 add_lvcm_labels now stamps GND on every fet's welltie + # ring (M_3_*, M_4_*), so klayout extracts the dummies' diffusion + # nets as GND too — DUM=GND keeps schematic and layout in sync. + dum = 'GND' # Use netlist_obj for hierarchical netlist building netlist.connect_netlist(bias_fvf.info['netlist_obj'], [('VIN','IBIAS1'),('VBULK','GND'),('Ib','IBIAS1'),('VOUT','local_net_1')]) netlist.connect_netlist(cascode_fvf.info['netlist_obj'], [('VIN','IBIAS1'),('VBULK','GND'),('Ib', 'IBIAS2'),('VOUT','local_net_2')]) - fet_1A_ref=netlist.connect_netlist(fet_2_ref.info['netlist'], [('D', 'IOUT1'),('G','IBIAS1'),('B','GND')]) - fet_2A_ref=netlist.connect_netlist(fet_4_ref.info['netlist'], [('D', 'IOUT2'),('G','IBIAS1'),('B','GND')]) - fet_1B_ref=netlist.connect_netlist(fet_1_ref.info['netlist'], [('G','IBIAS2'),('S', 'GND'),('B','GND')]) - fet_2B_ref=netlist.connect_netlist(fet_3_ref.info['netlist'], [('G','IBIAS2'),('S', 'GND'),('B','GND')]) + fet_1A_ref=netlist.connect_netlist(fet_2_ref.info['netlist'], [('D', 'IOUT1'),('G','IBIAS1'),('B','GND'),('DUM', dum)]) + fet_2A_ref=netlist.connect_netlist(fet_4_ref.info['netlist'], [('D', 'IOUT2'),('G','IBIAS1'),('B','GND'),('DUM', dum)]) + fet_1B_ref=netlist.connect_netlist(fet_1_ref.info['netlist'], [('G','IBIAS2'),('S', 'GND'),('B','GND'),('DUM', dum)]) + fet_2B_ref=netlist.connect_netlist(fet_3_ref.info['netlist'], [('G','IBIAS2'),('S', 'GND'),('B','GND'),('DUM', dum)]) netlist.connect_subnets( fet_1A_ref, fet_1B_ref, @@ -86,6 +110,8 @@ def low_voltage_cmirr_netlist(bias_fvf: Component, cascode_fvf: Component, fet_1 ) return netlist + + @cell def low_voltage_cmirror( @@ -102,13 +128,26 @@ def low_voltage_cmirror( #top level component top_level = Component("Low_voltage_N-type_current_mirror") - #input branch 2 - cascode_fvf = flipped_voltage_follower(pdk, width=(width[0],width[0]), length=(length,length), fingers=(fingers[0],fingers[0]), multipliers=(multipliers[0],multipliers[0]), with_dnwell=False) + # Suppress sub-cell pin labels for gf180 so the inner FVF VBULK/VIN/Ib/VOUT + # labels don't leak into the LVCM GDS (klayout would extract them as extra + # top-level pins, breaking LVS). + import os as _os + _prev_labels = _os.environ.get("GLAYOUT_NO_PIN_LABELS") + _os.environ["GLAYOUT_NO_PIN_LABELS"] = "1" + try: + #input branch 2 + cascode_fvf = flipped_voltage_follower(pdk, width=(width[0],width[0]), length=(length,length), fingers=(fingers[0],fingers[0]), multipliers=(multipliers[0],multipliers[0]), with_dnwell=False) + #input branch 1 + bias_fvf = flipped_voltage_follower(pdk, width=(width[0],width[1]), length=(length,length), fingers=(fingers[0],fingers[1]), multipliers=(multipliers[0],multipliers[1]), placement="vertical", with_dnwell=False) + finally: + if _prev_labels is None: + _os.environ.pop("GLAYOUT_NO_PIN_LABELS", None) + else: + _os.environ["GLAYOUT_NO_PIN_LABELS"] = _prev_labels + cascode_fvf_ref = prec_ref_center(cascode_fvf) top_level.add(cascode_fvf_ref) - - #input branch 1 - bias_fvf = flipped_voltage_follower(pdk, width=(width[0],width[1]), length=(length,length), fingers=(fingers[0],fingers[1]), multipliers=(multipliers[0],multipliers[1]), placement="vertical", with_dnwell=False) + bias_fvf_ref = prec_ref_center(bias_fvf) bias_fvf_ref.movey(cascode_fvf_ref.ymin - 2 - (evaluate_bbox(bias_fvf)[1]/2)) top_level.add(bias_fvf_ref) @@ -120,10 +159,18 @@ def low_voltage_cmirror( fet_3_ref = prec_ref_center(fet_1) fet_4_ref = prec_ref_center(fet_1) - fet_1_ref.movex(cascode_fvf_ref.xmin - (evaluate_bbox(fet_1)[0]/2) - pdk.util_max_metal_seperation()) - fet_2_ref.movex(cascode_fvf_ref.xmin - (3*evaluate_bbox(fet_1)[0]/2) - 2*pdk.util_max_metal_seperation()) - fet_3_ref.movex(cascode_fvf_ref.xmax + (evaluate_bbox(fet_1)[0]/2) + pdk.util_max_metal_seperation()) - fet_4_ref.movex(cascode_fvf_ref.xmax + (3*evaluate_bbox(fet_1)[0]/2) + 2*pdk.util_max_metal_seperation()) + # Use max(metal_sep, pwell_min_separation) so gf180 LVPWELL spacing rule + # (LPW.2a/b: 0.86um) isn't violated by under-spaced subcells. sky130's + # pwell self-rule is empty (raises NotImplementedError), so fall back to 0. + try: + _pwell_sep = pdk.get_grule("pwell").get("min_separation", 0) + except NotImplementedError: + _pwell_sep = 0 + _xclear = max(pdk.util_max_metal_seperation(), _pwell_sep) + fet_1_ref.movex(cascode_fvf_ref.xmin - (evaluate_bbox(fet_1)[0]/2) - _xclear) + fet_2_ref.movex(cascode_fvf_ref.xmin - (3*evaluate_bbox(fet_1)[0]/2) - 2*_xclear) + fet_3_ref.movex(cascode_fvf_ref.xmax + (evaluate_bbox(fet_1)[0]/2) + _xclear) + fet_4_ref.movex(cascode_fvf_ref.xmax + (3*evaluate_bbox(fet_1)[0]/2) + 2*_xclear) top_level.add(fet_1_ref) top_level.add(fet_2_ref) @@ -170,11 +217,30 @@ def low_voltage_cmirror( top_level << straight_route(pdk, fet_3_ref.ports["multiplier_0_gate_E"], gate_3_via.ports["bottom_met_W"]) top_level << straight_route(pdk, fet_4_ref.ports["multiplier_0_gate_E"], gate_4_via.ports["bottom_met_W"]) - top_level << c_route(pdk, gate_1_via.ports["top_met_S"], gate_3_via.ports["top_met_S"], extension=(1.2*width[0]+0.6), cglayer='met2') - top_level << c_route(pdk, gate_2_via.ports["top_met_S"], gate_4_via.ports["top_met_S"], extension=(1.2*width[0]-0.6), cglayer='met2') + # Spread the two south-going gate c_routes wider so their horizontal + # met2 strokes respect gf180 M2.2a (0.28um). Bumping the offset from + # ±0.6 to ±1.0 gives ~0.4um center-to-center spacing on top of the + # 0.5um stroke width — clear of the rule on either PDK. + top_level << c_route(pdk, gate_1_via.ports["top_met_S"], gate_3_via.ports["top_met_S"], extension=(1.2*width[0]+1.0), cglayer='met2') + top_level << c_route(pdk, gate_2_via.ports["top_met_S"], gate_4_via.ports["top_met_S"], extension=(1.2*width[0]-1.0), cglayer='met2') - top_level << straight_route(pdk, fet_1_ref.ports["multiplier_0_source_W"], fet_1_ref.ports["tie_W_top_met_W"], glayer1='met1', width=0.2) - top_level << straight_route(pdk, fet_3_ref.ports["multiplier_0_source_W"], fet_3_ref.ports["tie_W_top_met_W"], glayer1='met1', width=0.2) + # Tie source to substrate. The via_stack(met1,met2) the route drops at + # edge1=source_W has its bottom layer (li1) at 0.17um (mcon-sized, no + # enclosure padding); the default 'r','c' alignment lands the mcon 0.06um + # to the LEFT of the fet's existing gate-top mcon, tripping sky130 ct.1. + # Aligning by the via's met1 (sky130) layer instead — which is 0.29um wide + # because of the via1↔met1 enclosure rule — shifts the mcon by exactly the + # 0.06um needed to coincide with the fet's gate mcon (they merge into a + # single 0.17x0.17 polygon, no violation). + _tie_w = max(0.2, pdk.get_grule("met1")["min_width"]) + for fet_ref in (fet_1_ref, fet_3_ref): + top_level << straight_route( + pdk, + fet_ref.ports["multiplier_0_source_W"], + fet_ref.ports["tie_W_top_met_W"], + glayer1='met1', width=_tie_w, + via1_alignment_layer='met2', + ) top_level.add_ports(bias_fvf_ref.get_ports_list(), prefix="M_1_") @@ -187,7 +253,17 @@ def low_voltage_cmirror( component = component_snap_to_grid(rename_ports_by_orientation(top_level)) netlist_obj = low_voltage_cmirr_netlist(bias_fvf, cascode_fvf, fet_1_ref, fet_2_ref, fet_3_ref, fet_4_ref) component.info['netlist'] = netlist_obj.generate_netlist() - + + # gf180 LVS uses klayout's official deck which strictly requires named + # pin labels on met*_label layers. sky130 magic+netgen tolerates missing + # labels, so we only stamp them for gf180. + import os + if pdk.name.lower() == "gf180" and not os.environ.get("GLAYOUT_NO_PIN_LABELS"): + try: + component = add_lvcm_labels(component, pdk) + except KeyError: + pass + return component if __name__=="__main__": diff --git a/src/glayout/cells/composite/opamp/diff_pair_stackedcmirror.py b/src/glayout/cells/composite/opamp/diff_pair_stackedcmirror.py index 0aa9201c..a5a7e3b8 100644 --- a/src/glayout/cells/composite/opamp/diff_pair_stackedcmirror.py +++ b/src/glayout/cells/composite/opamp/diff_pair_stackedcmirror.py @@ -70,10 +70,53 @@ def __route_bottom_ncomps_except_drain_nbias(pdk: MappedPDK, toplevel_stacked: C toplevel_stacked << straight_route(pdk, toplevel_stacked.ports["diffpair_tap_W_top_met_E"], toplevel_stacked.ports["commonsource_cmirror_output_L_tie_E_top_met_W"],width=1,glayer2="met1") toplevel_stacked << straight_route(pdk, toplevel_stacked.ports["diffpair_tap_E_top_met_W"], toplevel_stacked.ports["commonsource_cmirror_output_R_tie_W_top_met_E"],width=1,glayer2="met1") # common source - # route to gnd the sources of cmirror - _cref = toplevel_stacked << c_route(pdk, toplevel_stacked.ports["commonsource_cmirror_output_R_multiplier_0_source_con_S"], toplevel_stacked.ports["commonsource_cmirror_output_L_multiplier_0_source_con_S"], extension=abs(gndpin.ports["N"].center[1]-toplevel_stacked.ports["commonsource_cmirror_output_R_multiplier_0_source_con_S"].center[1]),fullbottom=True) - toplevel_stacked << straight_route(pdk, toplevel_stacked.ports["commonsource_cmirror_ref_R_multiplier_0_source_E"],_cref.ports["con_E"],glayer2="met3",via2_alignment=('c','c')) - toplevel_stacked << straight_route(pdk, toplevel_stacked.ports["commonsource_cmirror_ref_L_multiplier_0_source_W"],_cref.ports["con_W"],glayer2="met3",via2_alignment=('c','c')) + # route to gnd the sources of cmirror, also creating the bottom shared power rail. + _cref = toplevel_stacked << c_route(pdk, + toplevel_stacked.ports["commonsource_cmirror_output_R_multiplier_0_source_con_S"], + toplevel_stacked.ports["commonsource_cmirror_output_L_multiplier_0_source_con_S"], + extension=abs(gndpin.ports["N"].center[1]-toplevel_stacked.ports["commonsource_cmirror_output_R_multiplier_0_source_con_S"].center[1]),fullbottom=True) + # gf180-only m2 patch: the cmirror_ref's tap-ring SW/SE corner via on + # m2 lands ~0.04um below the cmirror_ref source m2 column above it, + # leaving a sliver gap that trips m2.2a. Stamp an m2 patch at each + # corner that overlaps both polygons so they merge in DRC. + if pdk.name.lower() == "gf180": + from gdsfactory.components.rectangle import rectangle as _rect + _m2 = pdk.get_glayer("met2") + # Use cmirror_ref_L's drain_E port (center.x is the inner edge of + # cmirror_ref_L's leftmost drain column on m2; mirror for R) and + # multiplier_0_diff_S port (center.y is the diff's south edge, + # which is exactly where the gap sits). + _de = toplevel_stacked.ports.get("commonsource_cmirror_ref_L_multiplier_0_drain_E") + _ds = toplevel_stacked.ports.get("commonsource_cmirror_ref_L_multiplier_0_diff_S") + if _de is not None and _ds is not None: + _bridge_y = _ds.center[1] + for _sign in (-1, +1): # L (-) and R (+) + _bref = toplevel_stacked << _rect(size=(0.6, 0.4), layer=_m2, centered=True) + _bref.movex(_sign * (abs(_de.center[0]) + 0.25)).movey(_bridge_y) + # toplevel_stacked << straight_route(pdk, toplevel_stacked.ports["commonsource_cmirror_ref_R_multiplier_0_source_E"],_cref.ports["con_E"],glayer2="met3",via2_alignment=('c','c')) + # toplevel_stacked << straight_route(pdk, toplevel_stacked.ports["commonsource_cmirror_ref_L_multiplier_0_source_W"],_cref.ports["con_W"],glayer2="met3",via2_alignment=('c','c')) + # _cref only exposes con_W/con_E at the bridge y (≈ ymin); its e1/e2 + # extension columns are on glayout met3 (sky130 met2 — same layer as + # OUT.source_con_S, the c_route's input ports) and pass through the REF + # source bars' y, but with no port at that centerline, the straight_ + # routes above target con_W/con_E and strand a via at the bridge y. + # Synthesize a port at (OUT.source_con_S.x, REF.source_W/E.y, layer=met3) + # — the column's centerline at the source-bar y — and let straight_route + # bridge REF.source (met2) to it (met3) through a via stack. This ties + # the REF source bars to _cref's column (which is on GND via OUT) + for _src_port_name, _out_port_name in ( + ("commonsource_cmirror_ref_L_multiplier_0_source_W", + "commonsource_cmirror_output_L_multiplier_0_source_con_S"), + ("commonsource_cmirror_ref_R_multiplier_0_source_E", + "commonsource_cmirror_output_R_multiplier_0_source_con_S"), + ): + _src_port = toplevel_stacked.ports[_src_port_name] + _out_port = toplevel_stacked.ports[_out_port_name] + _bridge_port = _src_port.copy() + _bridge_port.center = (_out_port.center[0], _src_port.center[1]) + _bridge_port.orientation = (round(_src_port.orientation) + 180) % 360 # Rotating the source port to maintain correct alignment of the via stack + _bridge_port.layer = _out_port.layer + toplevel_stacked << straight_route(pdk, _src_port, _bridge_port, via2_alignment=('c','c'), fullbottom=True) # connect cmirror ref drain to cmirror output gate, then short cmirror ref drain and gate Ldrainport = toplevel_stacked.ports["commonsource_cmirror_ref_L_multiplier_0_drain_N"] Lgateport = toplevel_stacked.ports["commonsource_cmirror_output_L_multiplier_0_gate_S"] diff --git a/src/glayout/cells/composite/opamp/opamp.py b/src/glayout/cells/composite/opamp/opamp.py index d9f7b866..323d6c2a 100644 --- a/src/glayout/cells/composite/opamp/opamp.py +++ b/src/glayout/cells/composite/opamp/opamp.py @@ -23,8 +23,145 @@ from glayout.cells.composite.opamp.opamp_twostage import opamp_twostage from glayout.cells.elementary.current_mirror import current_mirror_netlist + +def _erase_subcell_pin_labels(opamp_top: Component, label_texts) -> Component: + """Remove pin labels that propagated up from flattened sub-cells. + + Sub-cells like ``diff_pair_ibias`` and + ``differential_to_single_ended_converter`` add their OWN labels (VP, VN, + VDD1, VDD2, IBIAS, VSS, B, …) so they can be exercised standalone against + their standalone schematics. When those cells are flatten()'d into the + opamp layout the labels land on the opamp top GDS cell and confuse + Magic LVS in two ways: + + 1. Sub-cell labels named the same as opamp-top labels (e.g. VP, VN) get + reported as "electrically shorted" synonyms even when the underlying + metal really IS the same net — noisy. + 2. Sub-cell labels named for nets that are INTERNAL at the opamp level + (e.g. dpiibias's ``VDD1`` and ``VDD2`` — those are diffpair drains, + which are mapped to ``GAIN_STAGE.VIN1/VIN2`` inside opamp_twostage, + not top-level pins) get extracted as extra subckt ports, which then + fail pin matching. + + The function removes labels by exact text. The opamp top-level labels + (added by ``add_opamp_labels``) are NOT in ``label_texts`` so they stay + in place. We touch ``opamp_top._cell.labels`` directly because gdsfactory + exposes ``.labels`` as a property whose returned list is detached + (mutating it does nothing); the underlying gdstk Cell's ``remove()`` + method is the only way to actually delete labels. + """ + opamp_top.unlock() + targets = set(label_texts) + # Snapshot first because we mutate the cell during iteration. + to_remove = [lab for lab in opamp_top._cell.labels if lab.text in targets] + for lab in to_remove: + opamp_top._cell.remove(lab) + return opamp_top + + +def add_opamp_labels(opamp_in: Component, pdk: MappedPDK, add_output_stage: bool = False) -> Component: + """Drop pin/label rectangles on the top-level signals so netgen LVS can + match them to the schematic's named pins. Without these, magic extracts + auto-named nodes and netgen can't disambiguate by connectivity alone. + + Two label sets, depending on the topology: + * ``add_output_stage=False`` -> opamp_twostage_netlist ports (uppercase + VDD, GND, DIFFPAIR_BIAS, VP, VN, CS_BIAS, VOUT). + * ``add_output_stage=True`` -> opamp_netlist ports (lowercase vdd, gnd, + plus, minus, diffpairibias, commonsourceibias + outputibias, output, + CSoutput introduced by the wrapper netlist). + + Each label is anchored at an existing port on the matching metal layer. + """ + opamp_in.unlock() + + # Pin glayer must match the pin rectangle's actual layer in opamp_twostage, + # otherwise magic associates the label with the wrong (or no) metal: + # vddpin: met4, vbias1 (DIFFPAIR_BIAS): met3, vbias2 (CS_BIAS): met5, + # minusi_pin: met3, plusi_pin: met3, gndpin: met4 (in diff_pair_stackedcmirror). + # commonsource_output_E sits on the c_route's met2 connector. + # + # Anchor at the GEOMETRIC CENTER of each pin rectangle (computed from two + # opposite-edge ports) rather than an edge port. Edge anchors put half of + # the label rect outside the metal, which causes magic to associate the + # label with whichever neighboring metal it happened to overlap. + # The pin_minus / pin_plus / commonsource_output rectangles in + # opamp_twostage are placed at the LEFT edge (x ≈ opamp_top.xmin) and + # near the cs_amp drain c_route's WEST extension. Centering a label rect + # on those metals makes Magic associate VN/VP/VOUT with the cs_amp drain + # (= GAIN_STAGE.VOUT) instead of the diffpair gate / output node, which + # extracts as a 3-way short between VN, VOUT, and the cs_bias drain. + # Anchor VP/VN on the diffpair multiplier gate ports themselves (deep + # inside the diffpair area, on met2 — same metal as dpiibias used to + # label internally before _erase_subcell_pin_labels stripped them). + if not add_output_stage: + placements = [ + # (port_a, port_b, label_text, glayer) + ("pin_vdd_e1", "pin_vdd_e3", "VDD", "met4"), + ("pin_diffpairibias_e1", "pin_diffpairibias_e3", "DIFFPAIR_BIAS", "met3"), + ("pin_commonsourceibias_e1", "pin_commonsourceibias_e3", "CS_BIAS", "met5"), + # diff_pair places: tl=fetL, tr=fetR, bl=fetL, br=fetR (lines + # 155-162 of diff_pair.py — a_topl=fetL, b_topr=fetR, a_botr=fetR, + # b_botl=fetL, with the bl/br port-prefix swap because b_botl + # gets the "bl_" prefix and a_botr gets "br_"). So VP (fetL) + # gates live on bl_*, and VN (fetR) gates on br_*. + ("diffpair_bl_multiplier_0_gate_S", None, "VP", "met2"), + ("diffpair_br_multiplier_0_gate_S", None, "VN", "met2"), + ("pin_gnd_W", "pin_gnd_E", "GND", "met4"), + # commonsource_output_E is the n_to_p_output_route c_route's con_S + # port — that bridge sits on met4 (cglayer = e1+1 = met3+1). A + # met2 label rect there has no underlying met2 polygon, so Magic + # auto-names a floating fragment instead of pinning the bridge. + ("commonsource_output_E", None, "VOUT", "met4"), + ] + else: + # add_output_stage=True -> opamp_netlist's lowercase top-level nodes. + # The two_stage VDD/GND/VP/VN/DIFFPAIR_BIAS/CS_BIAS get re-mapped to + # vdd/gnd/plus/minus/diffpairibias/commonsourceibias, and VOUT becomes + # CSoutput (the gain-stage output that drives the output stage). The + # output stage adds the outputibias and output top-level pins. + placements = [ + ("pin_vdd_e1", "pin_vdd_e3", "vdd", "met4"), + ("pin_diffpairibias_e1", "pin_diffpairibias_e3", "diffpairibias", "met3"), + ("pin_commonsourceibias_e1", "pin_commonsourceibias_e3", "commonsourceibias","met5"), + ("diffpair_bl_multiplier_0_gate_S", None, "plus", "met2"), + ("diffpair_br_multiplier_0_gate_S", None, "minus", "met2"), + ("pin_gnd_W", "pin_gnd_E", "gnd", "met4"), + # CSoutput is the gain-stage's output node, named VOUT inside two_stage. + ("commonsource_output_E", None, "CSoutput", "met2"), + # Output stage ibias pin (added by __add_output_stage as pin_outputibias_). + ("pin_outputibias_e1", "pin_outputibias_e3", "outputibias", "met3"), + # Output pin (rectangle from output_pin straight_route, prefix pin_output_). + ("pin_output_route_E", "pin_output_route_W", "output", "met3"), + ] + for port_a, port_b, text, glayer in placements: + if port_a not in opamp_in.ports: + continue + try: + pin_layer = pdk.get_glayer(f"{glayer}_pin") + label_layer = pdk.get_glayer(f"{glayer}_label") + except (NotImplementedError, KeyError): + continue + a = opamp_in.ports[port_a].center + if port_b and port_b in opamp_in.ports: + b = opamp_in.ports[port_b].center + cx, cy = (float(a[0]) + float(b[0])) / 2.0, (float(a[1]) + float(b[1])) / 2.0 + else: + cx, cy = float(a[0]), float(a[1]) + # Tiny label rect, fully inside the metal. Build fresh per call so the + # gdsfactory rectangle cache doesn't mix labels across cells. + # 0.15 half-side (0.3x0.3) clears sky130 m1 min-width (0.14) and + # min-area (0.083um²) when the anchor lands on a met1 layer. + s = 0.15 + rect = Component(name=f"opamp_pin_{text}") + rect.add_polygon([(-s, -s), (s, -s), (s, s), (-s, s)], layer=pin_layer) + rect.add_label(text=text, layer=label_layer, position=(0.0, 0.0)) + ref = rect.ref(position=(cx, cy)) + opamp_in.add(ref) + return opamp_in.flatten() + def opamp_output_stage_netlist(pdk: MappedPDK, output_amp_fet_ref: ComponentReference, biasParams: list) -> Netlist: - bias_netlist = current_mirror_netlist(pdk, biasParams[0], biasParams[1], biasParams[2]) + bias_netlist = current_mirror_netlist(pdk, biasParams[0], biasParams[1], 1, biasParams[2]) output_stage_netlist = Netlist( circuit_name="OUTPUT_STAGE", @@ -33,12 +170,12 @@ def opamp_output_stage_netlist(pdk: MappedPDK, output_amp_fet_ref: ComponentRefe output_stage_netlist.connect_netlist( output_amp_fet_ref.info['netlist'], - [('D', 'VDD'), ('G', 'VIN'), ('B', 'GND'), ('S', 'VOUT')] + [('D', 'VDD'), ('G', 'VIN'), ('B', 'GND'), ('S', 'VOUT'), ('DUM', 'GND')] ) output_stage_netlist.connect_netlist( bias_netlist, - [('VREF', 'IBIAS'), ('VSS', 'GND'), ('VCOPY', 'VOUT'), ('VB', 'GND')] + [('VREF', 'IBIAS'), ('VSS', 'GND'), ('VOUT', 'VOUT'), ('B', 'GND')] ) return output_stage_netlist @@ -105,7 +242,14 @@ def __add_output_stage( opamp_top << L_route(pdk, n_to_p_output_route, amp_fet_ref.ports["multiplier_0_gate_W"]) # route drain of amplifier to vdd vdd_route_extension = opamp_top.ymax-opamp_top.ports["pin_vdd_e4"].center[1]+metal_sep - opamp_top << c_route(pdk,amp_fet_ref.ports["multiplier_0_drain_N"],set_port_orientation(opamp_top.ports["pin_vdd_e4"],"N"),width1=5,width2=5,extension=vdd_route_extension,e2glayer="met3") + # widths capped under sky130's "huge" threshold (3um). The c_route's stubs + # land on the FET drain sd-bar (~0.86um tall, non-huge) and on the pin_vdd + # rectangle, and merge with them on met2/met3. If the c_route stub is + # itself "huge" (>=3um in min dim) the merge creates a huge/non-huge + # boundary that trips m{1,2,3}.3ab even though everything is electrically + # one net. Keeping widths at 2.5 keeps the stubs non-huge so the rule + # never fires. + opamp_top << c_route(pdk,amp_fet_ref.ports["multiplier_0_drain_N"],set_port_orientation(opamp_top.ports["pin_vdd_e4"],"N"),width1=2.5,width2=2.5,extension=vdd_route_extension,e2glayer="met3") vddvia = opamp_top << via_stack(pdk,"met3","met4",fullbottom=True) align_comp_to_port(vddvia,opamp_top.ports["pin_vdd_e4"],('c','t')) # route drain of cmirror to source of amplifier @@ -117,7 +261,13 @@ def __add_output_stage( opamp_top << straight_route(pdk, srcshort.ports["con_N"], cmirror_ibias.ports["welltie_N_top_met_S"],via2_alignment_layer="met2") # Route all tap rings together and ground them opamp_top << straight_route(pdk, cmirror_ibias.ports["welltie_N_top_met_N"],amp_fet_ref.ports["tie_S_top_met_S"]) - opamp_top << L_route(pdk, cmirror_ibias.ports["welltie_S_top_met_S"], opamp_top.ports["pin_gnd_E"],hwidth=4) + # hwidth capped at 2.5 (under sky130's 3um huge_m1 threshold). The L_route's + # vertical leg lands on the cmirror_ibias welltie ring (a thin frame on + # met2, non-huge). With hwidth>=3 the leg is "huge" and merging into the + # welltie creates a huge/non-huge boundary that trips m1.3ab even though + # both pieces are the same GND net. Same family of rules m{2,3}.3ab forced + # the c_route widths above. + opamp_top << L_route(pdk, cmirror_ibias.ports["welltie_S_top_met_S"], opamp_top.ports["pin_gnd_E"],hwidth=2.5) # add ports, add bias/output pin, and return psuedo_out_port = movex(amp_fet_ref.ports["multiplier_0_source_E"].copy(),6*metal_sep) output_pin = opamp_top << straight_route(pdk, amp_fet_ref.ports["multiplier_0_source_E"], psuedo_out_port) @@ -153,17 +303,17 @@ def opamp_netlist(two_stage_netlist: Netlist, output_stage_netlist: Netlist) -> @cell def opamp( pdk: MappedPDK, - half_diffpair_params: tuple[float, float, int] = (4.830253286815493,2.2539578478046662,8), - diffpair_bias: tuple[float, float, int] = (6.037515802496237,4.123786739228095,3), - half_common_source_params: tuple[float, float, int, int] = (2.0125794375155603,14.565564649657246,15,5), - half_common_source_bias: tuple[float, float, int, int] = (4.944937663219363,7.28342012411769,7,4), - output_stage_params: tuple[float, float, int] = (5.399091728093639,4.5857715613487375,20), - output_stage_bias: tuple[float, float, int] = (4.833064927880735,3.4385982794948085,5), - half_pload: tuple[float,float,int] = (6.475062736253839,2.8421424334962415,2), - mim_cap_size=(15.335314270645531,10.161949416053947), - mim_cap_rows=2, + half_diffpair_params: tuple[float, float, int] = (6, 1, 4), + diffpair_bias: tuple[float, float, int] = (6, 2, 4), + half_common_source_params: tuple[float, float, int, int] = (7, 1, 10, 3), + half_common_source_bias: tuple[float, float, int, int] = (6, 2, 8, 2), + output_stage_params: tuple[float, float, int] = (5, 1, 16), + output_stage_bias: tuple[float, float, int] = (6, 2, 4), + half_pload: tuple[float,float,int] = (6,1,6), + mim_cap_size=(12, 12), + mim_cap_rows=3, rmult: int = 2, - with_antenna_diode_on_diffinputs: int=7, + with_antenna_diode_on_diffinputs: int=5, add_output_stage: Optional[bool] = False ) -> Component: """ @@ -199,7 +349,21 @@ def opamp( opamp_top, output_stage_netlist = __add_output_stage(pdk, opamp_top, output_stage_params, output_stage_bias, rmult) opamp_top.info['netlist'] = opamp_netlist(opamp_top.info['netlist'], output_stage_netlist) - # return + # Strip pin labels that propagated up from sub-cells (dpiibias, dse, + # ...). They were added so each sub-cell can be LVS'd standalone, but + # at the opamp top level they collide with the opamp's own pin labels + # and create extra subckt ports for nets that are internal here. + opamp_top = _erase_subcell_pin_labels( + opamp_top, + # diff_pair_ibias labels (see _pin_specs in + # diff_pair_cmirrorbias.py): every name except those that happen to + # already match an opamp top-level pin. We purge them all because + # the opamp's add_opamp_labels below re-emits the right set on + # the right metal. + ["VP", "VN", "VDD1", "VDD2", "IBIAS", "VSS", "B"], + ) + # add LVS pin/label rects so netgen can name-match the top-level signals + opamp_top = add_opamp_labels(opamp_top, pdk, add_output_stage=add_output_stage) return rename_ports_by_orientation(component_snap_to_grid(opamp_top)) diff --git a/src/glayout/cells/composite/opamp/opamp_twostage.py b/src/glayout/cells/composite/opamp/opamp_twostage.py index 81bf6fd1..385479d1 100644 --- a/src/glayout/cells/composite/opamp/opamp_twostage.py +++ b/src/glayout/cells/composite/opamp/opamp_twostage.py @@ -57,9 +57,11 @@ def __create_and_route_pins( extensionR = max(halfmultn_drain_routeref.ports["con_E"].center[0],halfmultp_drain_routeref.ports["con_E"].center[0]) opamp_top << c_route(pdk, halfmultn_drain_routeref.ports["con_W"], halfmultp_drain_routeref.ports["con_W"],extension=abs(opamp_top.xmin-extensionL)+2,cwidth=2) n_to_p_output_route = opamp_top << c_route(pdk, halfmultn_drain_routeref.ports["con_E"], halfmultp_drain_routeref.ports["con_E"],extension=abs(opamp_top.xmax-extensionR)+2,cwidth=2) - # top nwell taps to vdd, top p substrate taps to gnd - opamp_top << straight_route(pdk, opamp_top.ports["commonsource_cmirror_output_L_tie_N_top_met_N"], opamp_top.ports["pcomps_top_ptap_S_top_met_S"], width=5) - opamp_top << straight_route(pdk, opamp_top.ports["commonsource_cmirror_output_R_tie_N_top_met_N"], opamp_top.ports["pcomps_top_ptap_S_top_met_S"], width=5) + # top nwell taps to vdd, top p substrate taps to gnd. Keep <3um wide so + # sky130's m1.3ab huge-metal rule isn't tripped (the rail otherwise + # picks up tiny mcon neighbours at the diff_pair corners <0.28um away). + opamp_top << straight_route(pdk, opamp_top.ports["commonsource_cmirror_output_L_tie_N_top_met_N"], opamp_top.ports["pcomps_top_ptap_S_top_met_S"], width=2.5) + opamp_top << straight_route(pdk, opamp_top.ports["commonsource_cmirror_output_R_tie_N_top_met_N"], opamp_top.ports["pcomps_top_ptap_S_top_met_S"], width=2.5) L_toptapn_route = opamp_top.ports["commonsource_Pamp_L_tie_N_top_met_N"] R_toptapn_route = opamp_top.ports["commonsource_Pamp_R_tie_N_top_met_N"] opamp_top << straight_route(pdk, movex(vddpin.ports["e4"],destination=L_toptapn_route.center[0]), L_toptapn_route, glayer1="met3",fullbottom=True) @@ -72,10 +74,17 @@ def __create_and_route_pins( vbias2.movex(1+opamp_top.xmax+evaluate_bbox(vbias2)[0]+pdk.util_max_metal_seperation()).movey(opamp_top.ymin+vbias2.ymax) opamp_top << L_route(pdk, halfmultn_gate_routeref.ports["con_E"], vbias2.ports["e2"],hwidth=2) # route + and - pins (being careful about antenna violations) + # The cs_bias↔cs_amp drain c_route at line ~58 places a met3 bridge + # spanning the full width at y ≈ 16. The minus_pin's L_route from the + # diff_pair MINUS gate (at y ≈ -1) up to the pin (at y ≈ 18) has a + # VERTICAL leg on met3 (default = N-facing port layer) at x = + # diffpair_MINUSgateroute_W_con_N.x. That vertical leg crosses the + # met3 bridge → VN is shorted to GAIN_STAGE.VOUT. Force the vertical + # leg onto met4 so it can fly OVER the cs_amp drain bridge. minusi_pin = opamp_top << rectangle(size=(5,2),layer=pdk.get_glayer("met3"),centered=True) minusi_pin.movex(opamp_top.xmin).movey(_max_metal_seperation_ps + minusi_pin.ymax + halfmultn_drain_routeref.ports["con_W"].center[1] + halfmultn_drain_routeref.ports["con_W"].width/2) iport_antenna1 = movex(minusi_pin.ports["e3"],destination=opamp_top.ports["diffpair_MINUSgateroute_W_con_N"].center[0]-9*_max_metal_seperation_ps) - opamp_top << L_route(pdk, opamp_top.ports["diffpair_MINUSgateroute_W_con_N"],iport_antenna1) + opamp_top << L_route(pdk, opamp_top.ports["diffpair_MINUSgateroute_W_con_N"],iport_antenna1, vglayer="met5") iport_antenna2 = movex(iport_antenna1,offsetx=-9*_max_metal_seperation_ps) opamp_top << straight_route(pdk, iport_antenna1, iport_antenna2,glayer1="met4",glayer2="met4",via2_alignment=('c','c'),via1_alignment=('c','c'),fullbottom=True) iport_antenna2.layer=pdk.get_glayer("met4") @@ -114,16 +123,21 @@ def __add_mimcap_arr(pdk: MappedPDK, opamp_top: Component, mim_cap_size, mim_cap raise ValueError("mim_cap_rows should be a positive integer") mimcap_netlist = mimcaps_ref.info['netlist'] - displace_fact = max(max_metalsep,pdk.get_grule("capmet")["min_separation"]) + displace_fact = max(max_metalsep,pdk.get_grule("capmet")["min_separation"]) + 0.3 # Hack mimcaps_ref.movex(pdk.snap_to_2xgrid(opamp_top.xmax + displace_fact + mim_cap_size[0]/2)) mimcaps_ref.movey(pdk.snap_to_2xgrid(ymin + mim_cap_size[1]/2)) - # connect mimcap to gnd + # connect mimcap. Match OpenFASOC reference: use the cap plates' native + # routing layers — V2 (cap_metalbottom) is glayout met4, V1 (cap_metaltop) + # is glayout met5. The c_route to V2 lands on met4 and the L_route to V1 + # lands on met5, so both via stacks contact the cap plates directly. port1 = opamp_top.ports["pcomps_mimcap_connection_con_N"] - port2 = mimcaps_ref.ports["row"+str(int(mim_cap_rows)-1)+"_col0_bottom_met_N"] + port2 = mimcaps_ref.ports["row"+str(int(mim_cap_rows)-1)+"_col0_top_met_N"] cref2_extension = max_metalsep + opamp_top.ymax - max(port1.center[1], port2.center[1]) - opamp_top << c_route(pdk,port1,port2, extension=cref2_extension, fullbottom=True) - intermediate_output = set_port_orientation(n_to_p_output_route.ports["con_S"],"E") - opamp_top << L_route(pdk, mimcaps_ref.ports["row0_col0_top_met_S"], intermediate_output, hwidth=3) + opamp_top << c_route(pdk,port1,port2, extension=cref2_extension, fullbottom=True, e1glayer="met3", e2glayer="met5", cglayer="met4", width2=5.0) # A Hack + intermediate_output = set_port_orientation(n_to_p_output_route.ports["con_S"],"N") + # opamp_top << L_route(pdk, mimcaps_ref.ports["row0_col0_top_met_N"], intermediate_output, hwidth=1, hglayer="met4", vglayer="met4") + # C route up right up to reach the mimcap port, extension is + opamp_top << L_route(pdk, intermediate_output, mimcaps_ref.ports["row0_col0_bottom_met_E"], fullbottom=True, vglayer="met5", hglayer="met4") opamp_top.add_ports(mimcaps_ref.get_ports_list(),prefix="mimcap_") # add the cs output as a port opamp_top.add_port(name="commonsource_output_E", port=intermediate_output) @@ -142,15 +156,20 @@ def opamp_gain_stage_netlist(mimcap_netlist: Netlist, diff_cs_netlist: Netlist, netlist.connect_netlist( cs_bias_netlist, - [('VREF', 'IBIAS'), ('VSS', 'GND'), ('VCOPY', 'VOUT'), ('VB', 'GND')] + [('VREF', 'IBIAS'), ('VSS', 'GND'), ('VOUT', 'VOUT'), ('B', 'GND')] ) - mimcap_ref = netlist.connect_netlist(mimcap_netlist, [('V1', 'VOUT'), ('V2', 'VSS2')]) + # V1/V2 swapped vs the OpenFASOC reference to match the layout: with + # the c_route landing on V2 (met4 = cap_metalbottom) tied to the cs_amp + # output and the L_route landing on V1 (met5 = cap_metaltop) tied to + # dse VSS2, the cap is symmetric so the schematic just needs to use + # the same plate assignments. + mimcap_ref = netlist.connect_netlist(mimcap_netlist, [('V2', 'VOUT'), ('V1', 'VSS2')]) netlist.connect_subnets( mimcap_ref, diff_cs_ref, - [('V2', 'VSS2')] + [('V1', 'VSS2')] ) return netlist @@ -220,11 +239,64 @@ def opamp_twostage( pmos_comps = row_csamplifier_diff_to_single_ended_converter(pdk, pmos_comps, half_common_source_params, rmult) - cs_bias_netlist = current_mirror_netlist( - pdk, - width=diffpair_bias[0], - length=diffpair_bias[1], - multipliers=diffpair_bias[2] + # The cs_bias layout is __add_common_source_nbias_transistors: per side + # (L,R) one stacked_nfet_current_mirror call which returns two SEPARATE + # nmos refs — cmirror_ref (multipliers=1) and cmirror_output (multipliers= + # half_common_source_bias[3]). With fingers=N and `with_dummy=True`, + # fet_netlist emits one XMAIN per finger×multiplier and one XDUMMY per + # side×multiplier; Magic merges parallel devices by connectivity, so the + # extracted cs_bias has three classes: + # ref-class D=G=IBIAS, S=GND, B=GND → m = 2 * fingers + # out-class D=VOUT, G=IBIAS, S=GND, B=GND → m = 2 * fingers * mult + # dummy-class D=G=S=B=GND → m = 4 + 4*mult + # The previous current_mirror_netlist call produced a single flat CMIRROR + # with the wrong dimensions (diffpair_bias instead of half_common_source_ + # bias) and only ~10 NMOS, leaving ~50 unmatched devices. Hand-build a + # CMIRROR subckt that mirrors the layout's stacked structure on each side. + _csb_w = half_common_source_bias[0] + _csb_l = half_common_source_bias[1] + _csb_f = half_common_source_bias[2] + _csb_m = half_common_source_bias[3] + _nfet_model = pdk.models['nfet'] + # X-prefix at the leaf: sky130's magic+netgen tech setup expects + # X-instances of `sky130_fd_pr__nfet_01v8`. klayout decks that classify + # primitive MOSFETs by SPICE prefix (e.g. gf180mcu) get their netlist + # X→M-rewritten by the LVS runner before extraction — keeps this + # generator PDK-agnostic. + cs_bias_netlist = Netlist( + circuit_name="CMIRROR", + nodes=['VREF', 'VOUT', 'VSS', 'B'], + # The body uses Spice subckt-parameter substitution `l={l} w={w}`, + # which means the `.subckt` header must declare those defaults. + # In Python str.format() that requires `{{l}}` so the braces survive + # as literals — see DIFF_TO_SINGLE's netlist for the same pattern. + source_netlist=( + ".subckt {circuit_name} {nodes} " + + f"l={_csb_l} w={_csb_w} mr={_csb_f} mo={_csb_f * _csb_m} " + + f"dr={2} do={2 * _csb_m}\n" + + "XREFL VREF VREF VSS B {model} l={{l}} w={{w}} m={{mr}}\n" + + "XREFR VREF VREF VSS B {model} l={{l}} w={{w}} m={{mr}}\n" + + "XOUTL VOUT VREF VSS B {model} l={{l}} w={{w}} m={{mo}}\n" + + "XOUTR VOUT VREF VSS B {model} l={{l}} w={{w}} m={{mo}}\n" + + "XDREFL B B B B {model} l={{l}} w={{w}} m={{dr}}\n" + + "XDREFR B B B B {model} l={{l}} w={{w}} m={{dr}}\n" + + "XDOUTL B B B B {model} l={{l}} w={{w}} m={{do}}\n" + + "XDOUTR B B B B {model} l={{l}} w={{w}} m={{do}}\n" + + ".ends {circuit_name}" + ), + instance_format=( + "X{name} {nodes} {circuit_name} l={length} w={width} " + "mr={mr} mo={mo} dr={dr} do={do}" + ), + parameters={ + 'model': _nfet_model, + 'width': _csb_w, + 'length': _csb_l, + 'mr': _csb_f, + 'mo': _csb_f * _csb_m, + 'dr': 2, + 'do': 2 * _csb_m, + } ) ydim_ncomps = opamp_top.ymax diff --git a/src/glayout/cells/composite/opamp/row_csamplifier_diff_to_single_ended_converter.py b/src/glayout/cells/composite/opamp/row_csamplifier_diff_to_single_ended_converter.py index 8078c330..338ef4be 100644 --- a/src/glayout/cells/composite/opamp/row_csamplifier_diff_to_single_ended_converter.py +++ b/src/glayout/cells/composite/opamp/row_csamplifier_diff_to_single_ended_converter.py @@ -58,9 +58,12 @@ def __connect_cs_netlist(pmos_comps: Component, half_cs_pmos: Component): else: raise ValueError("No netlist_data found for string netlist in half_cs_pmos component.info") + # DUM is fet_netlist's 5th port (dummies' tied G/S/D net). Tie it to + # VSS — the layout has the half-cs pmos's dummies on its bulk via the + # welltie ring, and that's the same net as VSS for this sub-cell. pmos_comps.info['netlist'].connect_netlist( half_cs_netlist, - [('D', 'VOUT'), ('S', 'VSS'), ('B', 'VSS'), ('G', 'VIN2')] + [('D', 'VOUT'), ('S', 'VSS'), ('B', 'VSS'), ('G', 'VIN2'), ('DUM', 'VSS')] ) def row_csamplifier_diff_to_single_ended_converter(pdk: MappedPDK, diff_to_single_ended_converter: Component, pamp_hparams, rmult) -> Component: diff --git a/src/glayout/cells/composite/stacked_current_mirror/stacked_current_mirror.py b/src/glayout/cells/composite/stacked_current_mirror/stacked_current_mirror.py index 76cab0ba..c4c5c3db 100644 --- a/src/glayout/cells/composite/stacked_current_mirror/stacked_current_mirror.py +++ b/src/glayout/cells/composite/stacked_current_mirror/stacked_current_mirror.py @@ -20,7 +20,7 @@ @validate_arguments -def stacked_nfet_current_mirror(pdk: MappedPDK, half_common_source_nbias: tuple[float, float, int, int], rmult: int, sd_route_left: bool) -> Component: +def stacked_nfet_current_mirror(pdk: MappedPDK, half_common_source_nbias: tuple[float, float, int, int], rmult: int, sd_route_left: bool, sd_route_topmet: Optional[str] = "met2") -> Component: cmirror_output = nmos( pdk, width=half_common_source_nbias[0], @@ -33,7 +33,8 @@ def stacked_nfet_current_mirror(pdk: MappedPDK, half_common_source_nbias: tuple[ with_dummy=True, sd_route_left = sd_route_left, rmult=rmult, - tie_layers=("met2","met2") + tie_layers=("met2","met2"), + sd_route_topmet=sd_route_topmet, ) cmirrorref = nmos( pdk, @@ -47,7 +48,8 @@ def stacked_nfet_current_mirror(pdk: MappedPDK, half_common_source_nbias: tuple[ with_dummy=True, sd_route_left = sd_route_left, rmult=rmult, - tie_layers=("met2","met2") + tie_layers=("met2","met2"), + sd_route_topmet=sd_route_topmet, ) cmirrorref_ref = prec_ref_center(cmirrorref) cmirrorout_ref = prec_ref_center(cmirror_output) diff --git a/src/glayout/cells/elementary/FVF/fvf.py b/src/glayout/cells/elementary/FVF/fvf.py index 91ba12b6..bedaa46f 100644 --- a/src/glayout/cells/elementary/FVF/fvf.py +++ b/src/glayout/cells/elementary/FVF/fvf.py @@ -1,3 +1,4 @@ +from typing import Optional from glayout.pdk.mappedpdk import MappedPDK from glayout.pdk.sky130_mapped import sky130_mapped_pdk from gdsfactory.cell import cell @@ -45,17 +46,25 @@ def get_component_netlist(component): def fvf_netlist(fet_1: Component, fet_2: Component) -> Netlist: netlist = Netlist(circuit_name='FLIPPED_VOLTAGE_FOLLOWER', nodes=['VIN', 'VBULK', 'VOUT', 'Ib']) - - # Use helper function to get netlist objects regardless of gdsfactory version + + # Both fets' dummies tie to VBULK: + # * sky130 magic absorbs floating dummies into the bulk during + # parallel-device merging, so DUM=VBULK matches the extraction. + # * gf180 klayout: add_fvf_labels now stamps VBULK on BOTH fets' + # welltie rings, so the dummies' G/S/D (which physically connect + # to the welltie metal via the inter-finger diffusion contacts) + # end up on the labeled VBULK net. fet_1_netlist = get_component_netlist(fet_1) fet_2_netlist = get_component_netlist(fet_2) - netlist.connect_netlist(fet_1_netlist, [('D', 'Ib'), ('G', 'VIN'), ('S', 'VOUT'), ('B', 'VBULK')]) - netlist.connect_netlist(fet_2_netlist, [('D', 'VOUT'), ('G', 'Ib'), ('S', 'VBULK'), ('B', 'VBULK')]) + netlist.connect_netlist(fet_1_netlist, [('D', 'Ib'), ('G', 'VIN'), ('S', 'VOUT'), ('B', 'VBULK'), ('DUM', 'VBULK')]) + netlist.connect_netlist(fet_2_netlist, [('D', 'VOUT'), ('G', 'Ib'), ('S', 'VBULK'), ('B', 'VBULK'), ('DUM', 'VBULK')]) return netlist + + def sky130_add_fvf_labels(fvf_in: Component) -> Component: - + fvf_in.unlock() # define layers` met1_pin = (68,16) @@ -69,28 +78,72 @@ def sky130_add_fvf_labels(fvf_in: Component) -> Component: gnd2label = rectangle(layer=met1_pin,size=(0.5,0.5),centered=True).copy() gnd2label.add_label(text="VBULK",layer=met1_label) move_info.append((gnd2label,fvf_in.ports["B_tie_N_top_met_N"],None)) - + #currentbias ibiaslabel = rectangle(layer=met2_pin,size=(0.5,0.5),centered=True).copy() ibiaslabel.add_label(text="Ib",layer=met2_label) move_info.append((ibiaslabel,fvf_in.ports["A_drain_bottom_met_N"],None)) - + # output (3rd stage) outputlabel = rectangle(layer=met2_pin,size=(0.5,0.5),centered=True).copy() outputlabel.add_label(text="VOUT",layer=met2_label) move_info.append((outputlabel,fvf_in.ports["A_source_bottom_met_N"],None)) - + # input inputlabel = rectangle(layer=met1_pin,size=(0.5,0.5),centered=True).copy() inputlabel.add_label(text="VIN",layer=met1_label) move_info.append((inputlabel,fvf_in.ports["A_multiplier_0_gate_N"], None)) - + # move everything to position for comp, prt, alignment in move_info: alignment = ('c','b') if alignment is None else alignment compref = align_comp_to_port(comp, prt, alignment=alignment) fvf_in.add(compref) - return fvf_in.flatten() + return fvf_in.flatten() + + +def add_fvf_labels(fvf_in: Component, pdk: MappedPDK) -> Component: + """PDK-aware FVF label adder. Welltie ring & gate sit on glayout met2; + drain/source via tops sit on glayout met3 (via_stack(met2,met3)).""" + fvf_in.unlock() + move_info = list() + + # VBULK — stamp on BOTH fets' welltie rings so klayout's gf180 deck + # binds both pwells (fet_1 + fet_2) to the same VBULK net. Without + # the fet_1-side stamp, the input fet's dummies and other floating + # diffusion areas land on a per-cell auto-named net, which breaks the + # schematic-vs-layout dummy match (the schematic puts both fets' + # dummies on the bulk via the welltie ring's own substrate contact). + for _portname in ("A_tie_N_top_met_N", "B_tie_N_top_met_N"): + if _portname not in fvf_in.ports: + continue + vbulklabel = rectangle(layer=pdk.get_glayer("met2_pin"), size=(0.5,0.5), centered=True).copy() + vbulklabel.add_label(text="VBULK", layer=pdk.get_glayer("met2_label")) + # ('c','c') keeps the label inside the welltie metal regardless of + # port orientation (south-facing ports + 'b' default would land + # the rectangle below the ring with no underlying metal). + move_info.append((vbulklabel, fvf_in.ports[_portname], ('c','c'))) + + # Ib — drain via top (glayout met3) + ibiaslabel = rectangle(layer=pdk.get_glayer("met3_pin"), size=(0.5,0.5), centered=True).copy() + ibiaslabel.add_label(text="Ib", layer=pdk.get_glayer("met3_label")) + move_info.append((ibiaslabel, fvf_in.ports["A_drain_bottom_met_N"], None)) + + # VOUT — source via top (glayout met3) + voutlabel = rectangle(layer=pdk.get_glayer("met3_pin"), size=(0.5,0.5), centered=True).copy() + voutlabel.add_label(text="VOUT", layer=pdk.get_glayer("met3_label")) + move_info.append((voutlabel, fvf_in.ports["A_source_bottom_met_N"], None)) + + # VIN — gate (glayout met2) + vinlabel = rectangle(layer=pdk.get_glayer("met2_pin"), size=(0.5,0.5), centered=True).copy() + vinlabel.add_label(text="VIN", layer=pdk.get_glayer("met2_label")) + move_info.append((vinlabel, fvf_in.ports["A_multiplier_0_gate_N"], None)) + + for comp, prt, alignment in move_info: + alignment = ('c','b') if alignment is None else alignment + compref = align_comp_to_port(comp, prt, alignment=alignment) + fvf_in.add(compref) + return fvf_in.flatten() @cell def flipped_voltage_follower( @@ -126,7 +179,7 @@ def flipped_voltage_follower( """ #top level component - top_level = Component(name="flipped_voltage_follower") + top_level = Component() #two fets device_map = { @@ -141,14 +194,39 @@ def flipped_voltage_follower( fet_1 = device(pdk, width=width[0], fingers=fingers[0], multipliers=multipliers[0], with_dummy=dummy_1, with_substrate_tap=False, length=length[0], tie_layers=tie_layers1, sd_rmult=sd_rmult, **kwargs) fet_2 = device(pdk, width=width[1], fingers=fingers[1], multipliers=multipliers[1], with_dummy=dummy_2, with_substrate_tap=False, length=length[1], tie_layers=tie_layers2, sd_rmult=sd_rmult, **kwargs) - well = "pwell" if device == nmos else "nwell" + well = "pwell" if device == nmos else "nwell" + sd_layer = "p+s/d" if device == nmos else "n+s/d" fet_1_ref = top_level << fet_1 - fet_2_ref = top_level << fet_2 + fet_2_ref = top_level << fet_2 #Relative move ref_dimensions = evaluate_bbox(fet_2) if placement == "horizontal": - fet_2_ref.movex(fet_1_ref.xmax + ref_dimensions[0]/2 + pdk.util_max_metal_seperation()-0.5) + # Legacy formula `metal_sep - 0.5` overlaps the two fet bboxes so their + # pwells merge. Trim the overlap slightly (-0.46 instead of -0.5) so the + # fets' inner S/D m2 finger contacts respect the strictest PDK m2 + # spacing — gf180 M2.2a (0.28um). Sky130 m2 spacing (0.14um) is well + # under the resulting gap either way. + fet_2_ref.movex(fet_1_ref.xmax + ref_dimensions[0]/2 + pdk.util_max_metal_seperation()-0.46) + # The two fets' welltie tap-implant rings end up `2*well_enc - bbox_overlap` + # apart at their inner edges. On stricter PDKs (gf180 PP.2 = 0.4um) that + # gap trips a min-spacing rule. Bridge the implants with a thin rectangle + # so they merge into one polygon — geometrically inert (it sits inside + # the merged pwell, on top of existing tap diffusion area), DRC-clean. + well_enc = pdk.get_grule(well, "active_tap")["min_enclosure"] + fet1_pp_right = fet_1_ref.xmax - well_enc + fet2_pp_left = fet_2_ref.xmin + well_enc + if fet2_pp_left > fet1_pp_right: + bridge_x = (fet1_pp_right + fet2_pp_left) / 2 + bridge_w = (fet2_pp_left - fet1_pp_right) + 0.04 + bridge_h = (fet_1_ref.ymax - fet_1_ref.ymin) - 2 * well_enc + bridge_y = (fet_1_ref.ymax + fet_1_ref.ymin) / 2 + bridge = top_level << rectangle( + size=(bridge_w, bridge_h), + layer=pdk.get_glayer(sd_layer), + centered=True, + ) + bridge.movex(bridge_x).movey(bridge_y) if placement == "vertical": fet_2_ref.movey(fet_1_ref.ymin - ref_dimensions[1]/2 - pdk.util_max_metal_seperation()-1) @@ -170,7 +248,10 @@ def flipped_voltage_follower( top_level << c_route(pdk, drain_1_via.ports["top_met_S"], gate_2_via.ports["top_met_S"], extension=1.2*max(width[0],width[1]), cglayer="met2") top_level << straight_route(pdk, fet_2_ref.ports["multiplier_0_gate_E"], gate_2_via.ports["bottom_met_W"]) try: - top_level << straight_route(pdk, fet_2_ref.ports["multiplier_0_source_W"], fet_2_ref.ports["tie_W_top_met_W"], glayer1=tie_layers2[1], width=0.2*sd_rmult, fullbottom=True) + # Use the PDK's own min_width for the tie route layer rather than a + # hardcoded 0.2um (gf180's met1 min_width is 0.23um, so 0.2 trips M1.1). + _tie_width = max(0.2 * sd_rmult, pdk.get_grule(tie_layers2[1])["min_width"]) + top_level << straight_route(pdk, fet_2_ref.ports["multiplier_0_source_W"], fet_2_ref.ports["tie_W_top_met_W"], glayer1=tie_layers2[1], width=_tie_width, fullbottom=True) except: pass #Renaming Ports @@ -199,7 +280,20 @@ def flipped_voltage_follower( 'nodes': netlist_obj.nodes, 'source_netlist': netlist_obj.source_netlist } - + + # gf180 LVS uses klayout's official deck which strictly requires named + # pin labels on met*_label layers. sky130 magic+netgen tolerates missing + # labels, so we only stamp them for gf180. Composite cells (LVCM, opamp) + # set GLAYOUT_NO_PIN_LABELS=1 around their sub-cell builds so the inner + # FVF labels don't leak into the parent cell's GDS and confuse top-level + # pin extraction. + import os + if pdk.name.lower() == "gf180" and not os.environ.get("GLAYOUT_NO_PIN_LABELS"): + try: + component = add_fvf_labels(component, pdk) + except KeyError: + pass + return component if __name__=="__main__": diff --git a/src/glayout/cells/elementary/current_mirror/current_mirror.py b/src/glayout/cells/elementary/current_mirror/current_mirror.py index 8c6b9c11..835c3bf8 100644 --- a/src/glayout/cells/elementary/current_mirror/current_mirror.py +++ b/src/glayout/cells/elementary/current_mirror/current_mirror.py @@ -47,10 +47,14 @@ def add_cm_labels(cm_in: Component, vcopylabel.add_label(text="VOUT",layer=pdk.get_glayer("met2_label")) move_info.append((vcopylabel,cm_in.ports["fet_B_drain_N"],None)) - # VB + # VB — center the label on the port (alignment ('c','c')) so the label + # box overlaps the welltie's met2 ring polygon. The default ('c','b') + # alignment lands the label OUTSIDE the south side of the ring (port + # faces south, so 'b' places the label below the port), which leaves it + # floating off any metal and the gf180 klayout deck can't bind it. vblabel = rectangle(layer=pdk.get_glayer("met2_pin"),size=(0.5,0.5),centered=True).copy() vblabel.add_label(text="B",layer=pdk.get_glayer("met2_label")) - move_info.append((vblabel,cm_in.ports["welltie_S_top_met_S"], None)) + move_info.append((vblabel,cm_in.ports["welltie_S_top_met_S"], ('c','c'))) # move everything to position for comp, prt, alignment in move_info: @@ -64,13 +68,19 @@ def current_mirror_interdigitized_netlist( width: float, length: float, fingers: int, - multipliers: int, + multipliers: int, with_dummy: bool = True, n_or_p_fet: Optional[str] = 'nfet', - subckt_only: Optional[bool] = False + subckt_only: Optional[bool] = False, + dummies_tied_to_bulk: bool = True, ) -> Netlist: """ - Current mirror netlist built from a two-transistor interdigitized primitive + Current mirror netlist built from a two-transistor interdigitized primitive. + + `dummies_tied_to_bulk` passes through to the underlying primitive netlist + so a composite parent that builds a cmirror via `two_nfet_interdigitized` + *without* the standalone-cell's dummy-to-welltie routing can opt out and + keep the dummies on a local floating net — see two_tran_interdigitized_netlist. """ current_mirror_netlist = Netlist(circuit_name="CMIRROR", nodes=["VREF", "VOUT", "VSS", "B"]) @@ -84,6 +94,7 @@ def current_mirror_interdigitized_netlist( multipliers=multipliers, with_dummy=with_dummy, n_or_p_fet=n_or_p_fet, + dummies_tied_to_bulk=dummies_tied_to_bulk, ), [ ("VDD1", "VREF"), # reference drain @@ -205,17 +216,31 @@ def current_mirror( top_level.add_ports(source_short.get_ports_list(), prefix='purposegndports') + # length default must be None (not 0.15) so the netlist function falls + # back to `pdk.get_grule('poly')['min_width']` — sky130=0.15, gf180=0.28. + # Hardcoding 0.15 produced an L mismatch (schematic 0.15 vs gf180 layout + # 0.28) that broke gf180 LVS. top_level.info["netlist"] = current_mirror_interdigitized_netlist( pdk=pdk, width=kwargs.get("width", 3), - length=kwargs.get("length", 0.15), + length=kwargs.get("length"), fingers=kwargs.get("fingers",1), multipliers=numcols, with_dummy=with_dummy, n_or_p_fet=device, subckt_only=True ) - + + # gf180 LVS uses klayout's official deck which strictly requires named + # pin labels on met*_label layers — without them, klayout extracts the + # cell with only an implicit substrate port and LVS fails. sky130 LVS + # via magic+netgen tolerates missing labels, so we only add them for + # gf180. Composite cells set GLAYOUT_NO_PIN_LABELS=1 around their sub- + # cell builds so inner labels don't leak into the parent cell's GDS. + import os + if pdk.name.lower() == "gf180" and with_tie and not os.environ.get("GLAYOUT_NO_PIN_LABELS"): + top_level = add_cm_labels(top_level, pdk) + return top_level if __name__=="__main__": diff --git a/src/glayout/cells/elementary/diff_pair/diff_pair.py b/src/glayout/cells/elementary/diff_pair/diff_pair.py index d19c330c..0a21c4db 100644 --- a/src/glayout/cells/elementary/diff_pair/diff_pair.py +++ b/src/glayout/cells/elementary/diff_pair/diff_pair.py @@ -82,31 +82,36 @@ def add_df_labels(df_in: Component, df_in.add(compref) return df_in.flatten() -def diff_pair_netlist(fetL: Component, fetR: Component) -> Netlist: +def diff_pair_netlist(fetL: Component, fetR: Component, pdk: Optional[MappedPDK] = None, dum_net: Optional[str] = None) -> Netlist: diff_pair_netlist = Netlist(circuit_name='DIFF_PAIR', nodes=['VP', 'VN', 'VDD1', 'VDD2', 'VTAIL', 'B']) # The physical layout uses an AB/BA common-centroid placement with four # mirrored device references (two copies of the left device and two copies of # the right device). Model that explicitly in the reference netlist so LVS # compares against the same effective device count/width. - diff_pair_netlist.connect_netlist( - fetL.info['netlist'], - [('D', 'VDD1'), ('G', 'VP'), ('S', 'VTAIL'), ('B', 'B')] - ) - diff_pair_netlist.connect_netlist( - fetL.info['netlist'], - [('D', 'VDD1'), ('G', 'VP'), ('S', 'VTAIL'), ('B', 'B')] - ) - diff_pair_netlist.connect_netlist( - fetR.info['netlist'], - [('D', 'VDD2'), ('G', 'VN'), ('S', 'VTAIL'), ('B', 'B')] - ) - diff_pair_netlist.connect_netlist( - fetR.info['netlist'], - [('D', 'VDD2'), ('G', 'VN'), ('S', 'VTAIL'), ('B', 'B')] - ) + # + # DUM maps to the dummies' G/S/D net. Standalone: + # * gf180 klayout extracts the four dummies' diffusion fingers as one + # shared floating net (the inter-dummy contacts merge them), so we + # map DUM→'dum' (a local subckt-level net). + # * sky130 magic+netgen absorbs the floating dummies into the bulk + # during parallel-device merging, so the schematic must put them on B + # directly — leaving DUM as a separate `dum` net there counts an extra + # net on the schematic side and trips the LVS comparison. + # `dum_net` lets a composite parent override this when the surrounding + # layout context (extra tap rings, shared pwell paths) physically forces + # the dummies onto a different net than the standalone-cell extraction. + if dum_net is None: + dum_net = 'B' if (pdk is not None and pdk.name.lower() == 'sky130') else 'dum' + for net, fet in (('VDD1', fetL), ('VDD1', fetL), ('VDD2', fetR), ('VDD2', fetR)): + gate = 'VP' if net == 'VDD1' else 'VN' + diff_pair_netlist.connect_netlist( + fet.info['netlist'], + [('D', net), ('G', gate), ('S', 'VTAIL'), ('B', 'B'), ('DUM', dum_net)], + ) return diff_pair_netlist + @cell def diff_pair( pdk: MappedPDK, @@ -117,7 +122,8 @@ def diff_pair( plus_minus_seperation: float = 0, rmult: int = 1, dummy: Union[bool, tuple[bool, bool]] = True, - substrate_tap: bool=True + substrate_tap: bool=True, + dum_net: Optional[str] = None, ) -> Component: """create a diffpair with 2 transistors placed in two rows with common centroid place. Sources are shorted width = width of the transistors @@ -198,7 +204,11 @@ def diff_pair( drain_bl_viatm.move(b_botl.ports["multiplier_0_drain_N"].center).movey(-1.5 * evaluate_bbox(viam2m3)[1] - metal_space) # create route to drain via width_drain_route = b_topr.ports["multiplier_0_drain_E"].width - dextension = source_routeE.xmax - b_topr.ports["multiplier_0_drain_E"].center[0] + metal_space + # Add an rmult-scaled margin so the drain c-bar clears the source c-bar + # even at higher rmult (where both bars get wider). The original + # `+ metal_space` left only 0.05um at rmult=2 and 0.1um at rmult=3 on + # gf180 (M3.2a slivers); scaling with rmult keeps a full met3 spacing. + dextension = source_routeE.xmax - b_topr.ports["multiplier_0_drain_E"].center[0] + (1 + rmult) * metal_space bottom_extension = viam2m3.ymax + width_drain_route/2 + 2*metal_space drain_br_viatm.movey(0-bottom_extension - metal_space - width_drain_route/2 - viam2m3.ymax) diffpair << route_quad(drain_br_viatm.ports["top_met_N"], drain_br_via.ports["top_met_S"], layer=pdk.get_glayer("met3")) @@ -238,7 +248,19 @@ def diff_pair( component = component_snap_to_grid(rename_ports_by_orientation(diffpair)) - component.info['netlist'] = diff_pair_netlist(fetL, fetR) + component.info['netlist'] = diff_pair_netlist(fetL, fetR, pdk=pdk, dum_net=dum_net) + + # gf180 LVS uses klayout's official deck which strictly requires named + # pin labels on met*_label layers — without them, klayout extracts the + # cell with only an implicit substrate port and LVS fails. sky130 LVS + # via magic+netgen tolerates missing labels, so only emit the labels + # for gf180. The B (bulk) label needs `substrate_tap=True` since it + # anchors on `tap_N_top_met_S`, which only exists when the diffpair's + # tap ring is drawn. Composite cells suppress this via GLAYOUT_NO_PIN_LABELS + # so inner labels don't leak into the parent cell's GDS. + import os + if pdk.name.lower() == "gf180" and substrate_tap and not os.environ.get("GLAYOUT_NO_PIN_LABELS"): + component = add_df_labels(component, pdk) return component diff --git a/src/glayout/cells/elementary/transmission_gate/transmission_gate.py b/src/glayout/cells/elementary/transmission_gate/transmission_gate.py index 7d9141df..6fd66d3b 100644 --- a/src/glayout/cells/elementary/transmission_gate/transmission_gate.py +++ b/src/glayout/cells/elementary/transmission_gate/transmission_gate.py @@ -141,14 +141,19 @@ def sky130_add_tg_labels(tg_in: Component) -> Component: def tg_netlist(nfet: Component, pfet: Component) -> Netlist: netlist = Netlist(circuit_name='Transmission_Gate', nodes=['VIN', 'VSS', 'VOUT', 'VCC', 'VGP', 'VGN']) - # Use helper function to get netlist objects regardless of gdsfactory version + # Use helper function to get netlist objects regardless of gdsfactory version. + # Each fet's dummies physically tie to the fet's own welltie ring (NMOS + # bulk = VSS, PMOS bulk = VCC), so DUM gets mapped to the same bulk net + # so the schematic matches the layout extraction. nfet_netlist = get_component_netlist(nfet) pfet_netlist = get_component_netlist(pfet) - netlist.connect_netlist(nfet_netlist, [('D', 'VOUT'), ('G', 'VGN'), ('S', 'VIN'), ('B', 'VSS')]) - netlist.connect_netlist(pfet_netlist, [('D', 'VOUT'), ('G', 'VGP'), ('S', 'VIN'), ('B', 'VCC')]) + netlist.connect_netlist(nfet_netlist, [('D', 'VOUT'), ('G', 'VGN'), ('S', 'VIN'), ('B', 'VSS'), ('DUM', 'VSS')]) + netlist.connect_netlist(pfet_netlist, [('D', 'VOUT'), ('G', 'VGP'), ('S', 'VIN'), ('B', 'VCC'), ('DUM', 'VCC')]) return netlist + + @cell def transmission_gate( pdk: MappedPDK, @@ -167,7 +172,7 @@ def transmission_gate( """ #top level component - top_level = Component(name="transmission_gate") + top_level = Component() #two fets nfet = nmos(pdk, width=width[0], fingers=fingers[0], multipliers=multipliers[0], with_dummy=True, with_dnwell=False, with_substrate_tap=False, length=length[0], **kwargs) @@ -200,7 +205,7 @@ def transmission_gate( guardring_ref.move(nfet_ref.center).movey(evaluate_bbox(pfet_ref)[1]/2 + pdk.util_max_metal_seperation()/2) top_level.add_ports(guardring_ref.get_ports_list(),prefix="tap_") - component = component_snap_to_grid(rename_ports_by_orientation(top_level)) + component = component_snap_to_grid(rename_ports_by_orientation(top_level)) # Store netlist as string to avoid gymnasium info dict type restrictions # Compatible with both gdsfactory 7.7.0 and 7.16.0+ strict Pydantic validation netlist_obj = tg_netlist(nfet, pfet) @@ -214,6 +219,16 @@ def transmission_gate( 'source_netlist': netlist_obj.source_netlist } + # gf180 LVS uses klayout's official deck which strictly requires named + # pin labels on met*_label layers. sky130 magic+netgen tolerates missing + # labels, so we only stamp them for gf180. Composite cells suppress with + # GLAYOUT_NO_PIN_LABELS so inner labels don't leak into the parent's GDS. + import os + if pdk.name.lower() == "gf180" and not os.environ.get("GLAYOUT_NO_PIN_LABELS"): + try: + component = add_tg_labels(component, pdk) + except KeyError: + pass return component if __name__ == "__main__": diff --git a/src/glayout/pdk/mappedpdk.py b/src/glayout/pdk/mappedpdk.py index dd8b9732..2de43b94 100644 --- a/src/glayout/pdk/mappedpdk.py +++ b/src/glayout/pdk/mappedpdk.py @@ -797,46 +797,35 @@ def write_spice(input_cdl, output_spice, lvs_schematic_ref_file): write_spice(str(netlist_from_comp), str(spice_path), lvsschemref_file) magic_script_content = f""" -drc off +drc off gds flatglob *\\$\\$* gds read {gds_path} -# LVS Netlist +# LVS Netlist — extract transistors only. Skipping ext2resist/extresist on +# the lvsmag output keeps long routes from showing up as thousands of +# parasitic resistors that the schematic does not model (e.g. opamp's +# 7000+ r:N instances would otherwise swamp the comparison). load {design_name} select top cell extract all -ext2resist all ext2spice lvs -ext2spice extresist on +# Force the top cell to be wrapped in a `.subckt {{design_name}}` block. +# Without this, magic emits the top circuit as flat top-level cards, and +# netgen reports `Cannot find cell {{design_name}}` when given the file — +# which silently aborts before any report is written. See diff_to_single +# / transmission_gate ERRORs. +ext2spice subcircuit top on ext2spice -o {str(lvsmag_path)} - -# Sim Netlist -load {design_name} -extract all -ext2sim cthresh 0 -ext2sim -o {str(sim_path)} - -# Pex Netlist -flatten {design_name} -load {design_name} -select top cell - -extract do local -extract all - -ext2sim labels on -ext2sim -extresist tolerance 10 -extresist - -ext2spice lvs -ext2spice cthresh 0 -ext2spice extresist on -ext2spice -o {str(pex_path)} exit """ + # The sim/pex netlists are only consumed by copy_intermediate_files + # below. Running their extract/extresist passes on large designs + # (opamp, diff_to_single) caused magic state to corrupt the + # already-written lvsmag.spice for the next cell in some setups — + # producing the "netgen::readnet -1" symptom. Skip them here; the + # copy step is now tolerant of missing intermediate files. if show_scripts: print("Creating magic script for LVS...") # Print the magic script content to the terminal instead of writing to a file @@ -855,11 +844,18 @@ def write_spice(input_cdl, output_spice, lvs_schematic_ref_file): magicrc_file = self.pdk_files['magic_drc_file'] if magic_drc_file is None else magic_drc_file magic_cmd = f"bash -c 'magic -rcfile {magicrc_file} -noconsole -dnull < {magic_script_path}'", + # Run magic with CWD pinned to the temp directory. Magic + # writes per-cell ``.ext`` files to its current + # working directory; if multiple LVS runs happen in + # parallel from a shared CWD they race on those files and + # the resulting lvsmag.spice can be silently truncated / + # corrupted (netgen then aborts with "netgen::readnet -1"). magic_subproc = subprocess.run( - magic_cmd, + magic_cmd, shell=True, check=True, - capture_output=True + capture_output=True, + cwd=str(temp_dir_path), ) magic_subproc_code = magic_subproc.returncode @@ -910,14 +906,16 @@ def write_spice(input_cdl, output_spice, lvs_schematic_ref_file): # else: # raise ValueError("LVS run failed") - finally: + finally: os.remove(magic_script_path) - if os.path.exists(f'{design_name}.ext'): - os.remove(f'{design_name}.ext') - # remove all files with suffix .ext - for file in os.listdir(temp_dir_path): - if file.endswith(".ext"): - os.remove(file) + # Magic now runs with cwd=temp_dir_path, so its per-cell + # ``.ext`` files land inside temp_dir and are cleaned up + # automatically when the TemporaryDirectory exits. Still + # tidy up any stragglers a caller created in the process + # CWD before this fix. + stray = Path(f'{design_name}.ext') + if stray.is_file(): + stray.unlink() # copy the report from the temp directory to the specified location if output_file_path is not None: @@ -928,8 +926,12 @@ def write_spice(input_cdl, output_spice, lvs_schematic_ref_file): path_to_dir.mkdir(parents=True, exist_ok=False) #new_output_file_path = path_to_dir / output_file_path new_output_file_path = path_to_dir / Path(report_path).name - # Overwrite the report file if it exists - shutil.copy(report_path, new_output_file_path) + # Overwrite the report file if it exists. When the run + # failed before the report was written, leave the + # original exception (raised in the try block) intact + # rather than masking it with a FileNotFoundError here. + if Path(report_path).is_file(): + shutil.copy(report_path, new_output_file_path) # if not new_output_file_path.exists(): # shutil.copy(report_path, path_to_dir / output_file_path) # else: @@ -939,9 +941,13 @@ def write_spice(input_cdl, output_spice, lvs_schematic_ref_file): lvsmag_dest = path_to_dir / f"{design_name}_lvsmag.spice" sim_dest = path_to_dir / f"{design_name}_sim.spice" pex_dest = path_to_dir / f"{design_name}_pex.spice" - shutil.copy(lvsmag_path, lvsmag_dest) - shutil.copy(sim_path, sim_dest) - shutil.copy(pex_path, pex_dest) + for src, dst in ( + (lvsmag_path, lvsmag_dest), + (sim_path, sim_dest), + (pex_path, pex_dest), + ): + if Path(src).is_file(): + shutil.copy(src, dst) print(f"Copied intermediate files to {path_to_dir}") # shutil.copy(lvsmag_path, str(Path.cwd() / f"{design_name}_lvsmag.spice")) # shutil.copy(sim_path, str(Path.cwd() / f"{design_name}_sim.spice")) diff --git a/src/glayout/placement/two_transistor_interdigitized.py b/src/glayout/placement/two_transistor_interdigitized.py index 1e93047b..3b659704 100644 --- a/src/glayout/placement/two_transistor_interdigitized.py +++ b/src/glayout/placement/two_transistor_interdigitized.py @@ -67,42 +67,53 @@ def add_two_int_labels(two_int_in: Component, def two_tran_interdigitized_netlist( - pdk: MappedPDK, + pdk: MappedPDK, width: float, length: float, fingers: int, - multipliers: int, + multipliers: int, with_dummy: True, n_or_p_fet: Optional[str] = 'nfet', - subckt_only: Optional[bool] = False + subckt_only: Optional[bool] = False, + dummies_tied_to_bulk: bool = True, ) -> Netlist: if length is None: length = pdk.get_grule('poly')['min_width'] if width is None: - width = 3 - #mtop = multipliers if subckt_only else 1 - #mtop=1 + width = 3 model = pdk.models[n_or_p_fet] mtop = fingers * multipliers - + + # `dummies_tied_to_bulk` controls where the cmirror's dummies' G/S/D + # land. The standalone current_mirror cell adds a straight_route from + # the dummies' gsdcon to the welltie ring, so the layout extracts them + # on bulk (VB) — pass True (the default). When this same cmirror is + # built without that routing (e.g. via raw two_nfet_interdigitized in + # diff_pair_ibias / opamp's cs_bias), the layout leaves the dummies' + # G/S/D on a per-cmirror floating net; pass False so the schematic + # uses a local 'cmdum' net that --combine collapses to one device. + dum_node = "VB" if dummies_tied_to_bulk else "cmdum" + # X-prefix is sky130's magic+netgen tech expectation; klayout decks + # that classify primitive MOSFETs by SPICE prefix get their netlist + # X→M-rewritten by the LVS runner. source_netlist = """.subckt {circuit_name} {nodes} """ + f'l={length} w={width} m={1} '+ f""" XA VDD1 VG1 VSS1 VB {model} l={length} w={width} m={mtop} XB VDD2 VG2 VSS2 VB {model} l={length} w={width} m={mtop}""" if with_dummy: - source_netlist += f"\nXDUMMY VB VB VB VB {model} l={length} w={width} m={2}" + source_netlist += f"\nXDUMMY {dum_node} {dum_node} {dum_node} VB {model} l={length} w={width} m={2}" source_netlist += "\n.ends {circuit_name}" instance_format = "X{name} {nodes} {circuit_name} l={length} w={width} m={{1}}" - + return Netlist( circuit_name='two_trans_interdigitized', - nodes=['VDD1', 'VDD2', 'VSS1', 'VSS2', 'VG1', 'VG2', 'VB'], + nodes=['VDD1', 'VDD2', 'VSS1', 'VSS2', 'VG1', 'VG2', 'VB'], source_netlist=source_netlist, instance_format=instance_format, parameters={ 'model': model, 'width': width, - 'length': length, + 'length': length, 'mult': multipliers } ) @@ -265,8 +276,8 @@ def two_nfet_interdigitized( base_multiplier.info["route_genid"] = "two_transistor_interdigitized" base_multiplier.info['netlist'] = two_tran_interdigitized_netlist( - pdk, - width=kwargs.get('width', 3), length=kwargs.get('length', 0.15), fingers=kwargs.get('fingers', 1), multipliers=numcols, with_dummy=dummy, + pdk, + width=kwargs.get('width', 3), length=kwargs.get('length'), fingers=kwargs.get('fingers', 1), multipliers=numcols, with_dummy=dummy, n_or_p_fet="nfet", subckt_only=True ) @@ -350,8 +361,8 @@ def two_pfet_interdigitized( base_multiplier.info["route_genid"] = "two_transistor_interdigitized" base_multiplier.info['netlist'] = two_tran_interdigitized_netlist( - pdk, - width=kwargs.get('width', 3), length=kwargs.get('length', 0.15), fingers=kwargs.get('fingers', 1), multipliers=numcols, with_dummy=dummy, + pdk, + width=kwargs.get('width', 3), length=kwargs.get('length'), fingers=kwargs.get('fingers', 1), multipliers=numcols, with_dummy=dummy, n_or_p_fet="pfet", subckt_only=True ) diff --git a/src/glayout/primitives/fet.py b/src/glayout/primitives/fet.py index 0da08841..7ba166b3 100644 --- a/src/glayout/primitives/fet.py +++ b/src/glayout/primitives/fet.py @@ -99,21 +99,40 @@ def fet_netlist( mtop = fingers * multipliers dmtop = multipliers + # Always emit X-prefix at the leaf — that's what sky130's magic+netgen + # tech setup expects (recognising X-instances of `sky130_fd_pr__nfet_01v8` + # / `sky130_fd_pr__pfet_01v8` via the netgen tech file). PDKs whose LVS + # deck classifies primitive MOSFETs by SPICE prefix instead (e.g. + # klayout's gf180mcu deck only auto-promotes M-prefix instances) have + # their netlist X→M-rewritten by the LVS runner before extraction — + # see :func:`tests.lvs.klayout_gf180._stage_inputs`. This keeps the + # generator output PDK-agnostic. + # + # DUM is exposed as a 5th port so the parent cell can decide where the + # dummies' tied G/S/D net lives. Parents that route the dummies to bulk + # in the layout (e.g. transmission_gate) map DUM→B; cells where the + # layout leaves them floating (e.g. diff_pair on gf180) map DUM to a + # shared local net so klayout's `combine_devices` collapses them into + # one extracted dummy. sky130 callers always map DUM to a tied node + # (typically B) since magic absorbs floating dummies into the bulk. + main_prefix = "XMAIN" + dum_prefix = "XDUMMY" + source_netlist = """.subckt {circuit_name} {nodes} """ + f"l={ltop} w={wtop}" # Emit one explicit main device per effective finger instance. for i in range(mtop): - source_netlist += f"\nXMAIN{i+1} D G S B {{model}} l={ltop} w={wtop}" + source_netlist += f"\n{main_prefix}{i+1} D G S B {{model}} l={ltop} w={wtop}" # Emit one dummy device per side, per multiplier row. for i in range(num_dummies * dmtop): - source_netlist += f"\nXDUMMY{i+1} B B B B {{model}} l={ltop} w={wtop}" + source_netlist += f"\n{dum_prefix}{i+1} DUM DUM DUM B {{model}} l={ltop} w={wtop}" source_netlist += "\n.ends {circuit_name}" return Netlist( circuit_name=circuit_name, - nodes=['D', 'G', 'S', 'B'], + nodes=['D', 'G', 'S', 'B', 'DUM'], source_netlist=source_netlist, instance_format="X{name} {nodes} {circuit_name} l={length} w={width}", parameters={ diff --git a/src/glayout/primitives/guardring.py b/src/glayout/primitives/guardring.py index e44d6197..5e16c719 100644 --- a/src/glayout/primitives/guardring.py +++ b/src/glayout/primitives/guardring.py @@ -71,6 +71,24 @@ def tapring( centered=True, layer=pdk.get_glayer(sdlayer), ) + # For an n+ tap (pmos body tie), draw the enclosing nwell inside the + # tapring itself so the tap diffusion lives in nwell at the same + # hierarchy level. Without this, callers add nwell at a parent level + # via add_padding() and magic's hierarchical LVS extraction does not + # see the n+s/d ring as in-nwell — leaving the welltie metal on a + # separate node from the body. (The nfet/p+ case is fine because pwell + # is the implicit substrate; nwell has no such global net.) + if sdlayer == "n+s/d": + nwell_enc = pdk.get_grule("nwell", "active_tap")["min_enclosure"] + nwell_outer = ( + enclosed_rectangle[0] + 2 * tap_width + 2 * nwell_enc, + enclosed_rectangle[1] + 2 * tap_width + 2 * nwell_enc, + ) + ptapring << rectangle( + size=nwell_outer, + layer=pdk.get_glayer("nwell"), + centered=True, + ) # create via arrs via_width_horizontal = evaluate_bbox(via_stack(pdk, "active_tap", horizontal_glayer))[0] arr_size_horizontal = enclosed_rectangle[0] diff --git a/src/glayout/primitives/mimcap.py b/src/glayout/primitives/mimcap.py index fa8f622b..1b617177 100644 --- a/src/glayout/primitives/mimcap.py +++ b/src/glayout/primitives/mimcap.py @@ -127,6 +127,19 @@ def mimcap_array(pdk: MappedPDK, rows: int, columns: int, size: tuple[float,floa port_pairs.append((bl_north_port,top_south_port,layer)) for port_pair in port_pairs: mimcap_arr << straight_route(pdk,port_pair[0],port_pair[1],width=rmult*pdk.get_grule(port_pair[2])["min_width"]) + # Cover the whole array on capmettop/capmetbottom with one solid plate + # (+0.4um pad past the array bbox) so klayout's huge-metal "ab" rules + # don't fire on the per-cap plate ↔ inter-cap-bridge boundaries: with + # the unified plate they all merge into one huge region. + arr_bbox = array_ref.bbox + pad = 0.4 + xmin, ymin = float(arr_bbox[0][0]) - pad, float(arr_bbox[0][1]) - pad + xmax, ymax = float(arr_bbox[1][0]) + pad, float(arr_bbox[1][1]) + pad + plate = Component() + for level_layer in (capmettop, capmetbottom): + plate.add_polygon([(xmin,ymin),(xmax,ymin),(xmax,ymax),(xmin,ymax)], + layer=pdk.get_glayer(level_layer)) + mimcap_arr << plate # add netlist mimcap_arr.info['netlist'] = __generate_mimcap_array_netlist(mimcap_single.info['netlist'], rows * columns) diff --git a/tests/drc/README.md b/tests/drc/README.md new file mode 100644 index 00000000..11015724 --- /dev/null +++ b/tests/drc/README.md @@ -0,0 +1,99 @@ +# Cell DRC + +Runs Klayout DRC against every glayout cell for a given PDK. + +## Local usage (with iic-osic-tools) + +The CI workflow uses [`hpretl/iic-osic-tools`](https://github.com/iic-jku/iic-osic-tools), +which ships klayout, magic, netgen and the sky130A / gf180mcuD PDKs at +`/foss/pdks`. The image is Ubuntu 24.04 with only Python 3.12, but glayout pins +`gdsfactory<=7.7.0` / `numpy<=1.24`, so we install Python 3.10 from deadsnakes +and run glayout in a venv. + +```bash +docker run --rm -it \ + -v "$PWD":/work -w /work \ + --user root --entrypoint /bin/bash \ + hpretl/iic-osic-tools:latest -lc ' + set -euxo pipefail + unset PYTHONPATH # the image sets it to 3.12 paths + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y --no-install-recommends \ + software-properties-common ca-certificates gnupg curl >/dev/null + add-apt-repository -y ppa:deadsnakes/ppa >/dev/null + apt-get update -qq + apt-get install -y --no-install-recommends python3.10 python3.10-venv >/dev/null + python3.10 -m venv /tmp/venv + . /tmp/venv/bin/activate + python -m pip install --upgrade pip wheel + python -m pip install -e . + python tests/drc/run_cell_drc.py --pdk sky130 --out-dir drc_results/sky130 + python tests/drc/run_cell_drc.py --pdk gf180 --out-dir drc_results/gf180 + ' +``` + +## Local usage (host install) + +```bash +# Klayout CLI must be installed and on PATH (https://www.klayout.org/). +pip install -e . + +# PDK_ROOT must point at a directory; klayout DRC against the bundled deck +# does not need a real PDK install, but the gf180 PDK reads the env var at +# import time. +export PDK_ROOT=$(mktemp -d) + +python tests/drc/run_cell_drc.py --pdk sky130 --out-dir drc_results/sky130 +python tests/drc/run_cell_drc.py --pdk gf180 --out-dir drc_results/gf180 +``` + +Pass `--deck ` to use a PDK-installed DRC deck instead of the bundled one. + +A subset of cells can be selected with `--cells`: + +```bash +python tests/drc/run_cell_drc.py --pdk sky130 --cells current_mirror_nfet,opamp +``` + +## Cell parameters + +Per-cell kwargs live in CSV files alongside this README: + +- `tests/parameters/ci_drc_sky130.csv` +- `tests/parameters/ci_drc_gf180.csv` + +Each row is `cell,params_json` where `params_json` is a JSON object passed as +**kwargs to the cell's builder. Tuples are written as JSON arrays (the runner +recursively coerces lists back to tuples for builders that pydantic-validate +`tuple[...]`). + +```csv +cell,params_json +current_mirror_nfet,"{""device"": ""nfet"", ""numcols"": 2}" +flipped_voltage_follower,"{""device_type"": ""nmos"", ""width"": [5.0, 5.0], ""fingers"": [2, 2]}" +transmission_gate,{} +``` + +The runner picks the file matching `--pdk` automatically; override with +`--params `. Cells in the CSV but missing from the builder registry in +`run_cell_drc.py` (`_CELL_BUILDERS`) raise an error at startup; cells in the +registry but missing from the CSV are silently skipped — the CSV is the source +of truth for what runs in CI. + +## Output + +For each run the script writes: + +- `/gds/.gds` — generated layout per cell +- `/reports/.lyrdb` — klayout violation database +- `/summary.json` — machine-readable summary +- `/junit.xml` — JUnit report consumed by the CI workflow + +The script exits non-zero if any cell fails to build or has DRC violations. + +## CI + +`.github/workflows/drc.yml` runs the same script on every push and PR with a +matrix over `sky130` and `gf180`, and uploads the per-PDK output directory as +a build artifact. diff --git a/tests/drc/diagnose.py b/tests/drc/diagnose.py new file mode 100644 index 00000000..58dcb423 --- /dev/null +++ b/tests/drc/diagnose.py @@ -0,0 +1,105 @@ +"""Summarize a klayout lyrdb into rule -> count and a few sample bboxes. + +Usage: + python tests/drc/diagnose.py path/to/cell.lyrdb [more.lyrdb ...] +""" +from __future__ import annotations + +import sys +import xml.etree.ElementTree as ET +from collections import Counter +from pathlib import Path +from typing import List, Tuple + + +def _strip_polygon(text: str) -> Tuple[float, float, float, float] | None: + """Pull a (x1,y1,x2,y2) bbox out of a klayout polygon/edge value string.""" + pts: List[Tuple[float, float]] = [] + for chunk in text.replace("(", " ").replace(")", " ").split(";"): + bits = [b for b in chunk.replace(",", " ").split() if b] + nums: List[float] = [] + for b in bits: + try: + nums.append(float(b)) + except ValueError: + pass + for i in range(0, len(nums) - 1, 2): + pts.append((nums[i], nums[i + 1])) + if not pts: + return None + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + return (min(xs), min(ys), max(xs), max(ys)) + + +def summarize(report: Path) -> None: + if not report.exists(): + print(f"missing: {report}") + return + tree = ET.parse(report) + root = tree.getroot() + items = None + cats: dict[str, str] = {} + for child in root: + tag = child.tag.split("}")[-1] + if tag == "items": + items = child + elif tag == "categories": + for cat in child: + cname = "" + cdesc = "" + for sub in cat: + stag = sub.tag.split("}")[-1] + if stag == "name": + cname = (sub.text or "").strip() + elif stag == "description": + cdesc = (sub.text or "").strip() + if cname: + cats[cname] = cdesc or cname + if items is None: + print(f"{report.name}: no items element") + return + counts: Counter[str] = Counter() + samples: dict[str, list] = {} + for item in items: + cat = "" + bbox = None + for sub in item: + stag = sub.tag.split("}")[-1] + if stag == "category": + cat = (sub.text or "").strip().strip("'") + elif stag == "values": + for val in sub: + text = (val.text or "") + bb = _strip_polygon(text) + if bb: + bbox = bb + break + counts[cat] += 1 + if cat not in samples: + samples[cat] = [] + if bbox is not None and len(samples[cat]) < 2: + samples[cat].append(bbox) + + print(f"\n=== {report.name} : {sum(counts.values())} violations across {len(counts)} rules ===") + for cat, n in counts.most_common(): + desc = cats.get(cat, "") + head = f" {n:>4d} {cat}" + if desc and desc != cat: + head += f" — {desc[:80]}" + print(head) + for bb in samples.get(cat, []): + print(f" sample bbox um: ({bb[0]:.3f},{bb[1]:.3f})-({bb[2]:.3f},{bb[3]:.3f})") + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + for arg in sys.argv[1:]: + summarize(Path(arg)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/drc/run_cell_drc.py b/tests/drc/run_cell_drc.py new file mode 100644 index 00000000..65fb5c9b --- /dev/null +++ b/tests/drc/run_cell_drc.py @@ -0,0 +1,545 @@ +"""Runs Klayout DRC on every glayout cell for a chosen PDK. + +Used by the GitHub Actions CI workflow at ``.github/workflows/drc.yml``. + +The script: + * builds each registered cell with a small, deterministic parameter set, + * writes a GDS to a per-cell output directory, + * invokes ``klayout -b -r `` with ``input``/``report`` runtime + variables, mirroring ``MappedPDK.drc`` for klayout <= 0.29, + * 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. +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import subprocess +import sys +import traceback +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + + +REPO_ROOT = Path(__file__).resolve().parents[2] +BUNDLED_DECKS = { + "sky130": REPO_ROOT / "src" / "glayout" / "pdk" / "sky130_mapped" / "sky130.lydrc", + "gf180": REPO_ROOT / "src" / "glayout" / "pdk" / "gf180_mapped" / "gf180mcu.drc", +} +DEFAULT_PARAM_DIR = REPO_ROOT / "tests" / "parameters" + + +@dataclass +class CellSpec: + name: str + builder: Callable[..., Any] + kwargs: Dict[str, Any] = field(default_factory=dict) + + +# Cell-name -> import path for the builder. Builders are imported lazily so +# that an import error in one cell doesn't kill the whole runner. +_CELL_BUILDERS: Dict[str, str] = { + "current_mirror_nfet": "glayout.cells.elementary:current_mirror", + "current_mirror_pfet": "glayout.cells.elementary:current_mirror", + "diff_pair": "glayout.cells.elementary:diff_pair", + "flipped_voltage_follower": "glayout.cells.elementary:flipped_voltage_follower", + "transmission_gate": "glayout.cells.elementary:transmission_gate", + "differential_to_single_ended_converter": "glayout.cells.composite:differential_to_single_ended_converter", + "diff_pair_ibias": "glayout.cells.composite:diff_pair_ibias", + "low_voltage_cmirror": "glayout.cells.composite:low_voltage_cmirror", + "opamp": "glayout.cells.composite:opamp", +} + + +def _resolve_builder(import_path: str) -> Callable[..., Any]: + module_name, attr = import_path.split(":", 1) + module = __import__(module_name, fromlist=[attr]) + return getattr(module, attr) + + +def _coerce_tuples(value: Any) -> Any: + """JSON has no tuples — recursively convert lists back to tuples for builders + that pydantic-validate ``tuple[...]``. Keeps dicts/scalars untouched.""" + if isinstance(value, list): + return tuple(_coerce_tuples(v) for v in value) + if isinstance(value, dict): + return {k: _coerce_tuples(v) for k, v in value.items()} + return value + + +def _load_param_csv(path: Path) -> Dict[str, Dict[str, Any]]: + """Read ``cell,params_json`` rows into a {cell: kwargs} mapping.""" + if not path.exists(): + raise FileNotFoundError(f"parameter file not found: {path}") + out: Dict[str, Dict[str, Any]] = {} + with path.open(newline="") as f: + reader = csv.DictReader(f) + if reader.fieldnames is None or "cell" not in reader.fieldnames or "params_json" not in reader.fieldnames: + raise ValueError(f"{path} must have header 'cell,params_json'") + for row in reader: + name = (row.get("cell") or "").strip() + if not name or name.startswith("#"): + continue + raw = (row.get("params_json") or "").strip() + try: + kwargs = json.loads(raw) if raw else {} + except json.JSONDecodeError as exc: + raise ValueError(f"{path}: row '{name}' has invalid JSON: {exc}") from exc + if not isinstance(kwargs, dict): + raise ValueError(f"{path}: row '{name}' params_json must be a JSON object") + out[name] = {k: _coerce_tuples(v) for k, v in kwargs.items()} + return out + + +def _load_cell_specs(pdk: str, param_csv: Optional[Path]) -> Dict[str, CellSpec]: + """Load a {cell_name: CellSpec} for the given PDK from the CSV. + + Cells listed in the CSV but unknown to ``_CELL_BUILDERS`` raise an error. + Cells defined in ``_CELL_BUILDERS`` but missing from the CSV are silently + skipped — the CSV is the source of truth for what runs in CI. + """ + csv_path = param_csv or DEFAULT_PARAM_DIR / f"ci_drc_{pdk}.csv" + rows = _load_param_csv(csv_path) + unknown = sorted(set(rows) - set(_CELL_BUILDERS)) + if unknown: + raise ValueError(f"{csv_path}: unknown cell(s) {unknown}; add a builder mapping in run_cell_drc.py") + specs: Dict[str, CellSpec] = {} + for name, kwargs in rows.items(): + specs[name] = CellSpec( + name=name, + builder=_resolve_builder(_CELL_BUILDERS[name]), + kwargs=kwargs, + ) + return specs + + +def _resolve_pdk(pdk_name: str): + if pdk_name == "sky130": + from glayout import sky130 + if sky130 is None: + raise RuntimeError("sky130 PDK could not be imported") + return sky130 + if pdk_name == "gf180": + from glayout import gf180 + if gf180 is None: + raise RuntimeError("gf180 PDK could not be imported") + return gf180 + raise ValueError(f"Unsupported PDK: {pdk_name}") + + +def _drc_deck_for(pdk_name: str, override: Optional[str] = None) -> Path: + if override: + return Path(override).resolve() + if pdk_name not in BUNDLED_DECKS: + raise ValueError(f"Unsupported PDK: {pdk_name}") + return BUNDLED_DECKS[pdk_name] + + +# Rules that are not functional defects — fab/density-style; safe to ignore in CI. +# 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), +] + + +def _is_ignored_rule(name: str, desc: str) -> bool: + text = f"{name} {desc}" + return any(p.search(text) for p in _IGNORE_PATTERNS) + + +def _count_lyrdb_violations(report: Path) -> 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, ...}. + """ + if not report.exists(): + return {"total": -1, "effective": -1, "ignored": 0, "by_rule": {}, "ignored_by_rule": {}} + tree = ET.parse(report) + root = tree.getroot() + cats: dict[str, str] = {} + items = None + for child in root: + tag = child.tag.split("}")[-1] + if tag == "items": + items = child + elif tag == "categories": + for cat in child: + cname = cdesc = "" + for sub in cat: + stag = sub.tag.split("}")[-1] + if stag == "name": + cname = (sub.text or "").strip() + elif stag == "description": + cdesc = (sub.text or "").strip() + if cname: + cats[cname] = cdesc + by_rule: dict[str, int] = {} + ignored_by_rule: dict[str, int] = {} + if items is not None: + for item in items: + cat = "" + for sub in item: + if sub.tag.split("}")[-1] == "category": + cat = (sub.text or "").strip().strip("'") + break + desc = cats.get(cat, "") + if _is_ignored_rule(cat, desc): + ignored_by_rule[cat] = ignored_by_rule.get(cat, 0) + 1 + else: + by_rule[cat] = by_rule.get(cat, 0) + 1 + total = sum(by_rule.values()) + sum(ignored_by_rule.values()) + return { + "total": total, + "effective": sum(by_rule.values()), + "ignored": sum(ignored_by_rule.values()), + "by_rule": by_rule, + "ignored_by_rule": ignored_by_rule, + } + + +def _run_klayout(deck: Path, gds: Path, report: Path) -> subprocess.CompletedProcess: + cmd = [ + "klayout", + "-b", + "-r", str(deck), + "-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: + """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. + """ + if not report.exists(): + return {"total": -1, "effective": -1, "ignored": 0, "by_rule": {}, "ignored_by_rule": {}} + text = report.read_text() + by_rule: dict[str, int] = {} + ignored_by_rule: dict[str, int] = {} + current_rule = "" + for line in text.splitlines(): + s = line.strip() + if not s or s.startswith("---"): + continue + # Header line like "{cell} count: N" — ignore. + if "count:" in s and ":" in s.split("count:", 1)[0]: + continue + if _MAGIC_RULE_RE.match(s): + current_rule = s + continue + if _MAGIC_COORD_RE.match(s) and current_rule: + if _is_ignored_rule(current_rule, current_rule): + 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 + return { + "total": sum(by_rule.values()) + sum(ignored_by_rule.values()), + "effective": sum(by_rule.values()), + "ignored": sum(ignored_by_rule.values()), + "by_rule": by_rule, + "ignored_by_rule": ignored_by_rule, + } + + +def _run_magic_drc(item: dict, pdk, comp_name: str, gds_path: Path, magic_dir: Path) -> dict: + """Invoke ``pdk.drc_magic`` on the GDS produced by the build phase. Returns + a result dict with the same shape as the klayout per-cell result. + """ + name = item["name"] + pdk_name = item["pdk"] + out_dir = Path(item["out_dir"]) + 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" + try: + print(f"[MAGIC]{name}", flush=True) + pdk.drc_magic( + layout=str(gds_path), + design_name=comp_name, + output_file=str(magic_dir), + ) + except Exception as exc: + 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) + effective = viols["effective"] + res.update({ + "violations": viols, + "report": str(rpt_path.relative_to(out_dir)) if rpt_path.exists() else None, + }) + if effective < 0: + res["status"] = "error" + res["message"] = "magic report not produced" + elif effective == 0: + res["status"] = "pass" + if viols["ignored"]: + res["message"] = f"clean (ignored {viols['ignored']} density/area)" + else: + res["status"] = "fail" + top = ", ".join(f"{r}:{n}" for r, n in sorted(viols["by_rule"].items(), key=lambda kv: -kv[1])[:3]) + res["message"] = f"{effective} magic violation(s) [{top}]" + print(f"[MAGIC:{res['status'].upper()}] {name}: {res.get('message', 'clean')}", flush=True) + return res + + +def _run_one_cell(item: dict) -> dict: + """Build a single cell, write GDS+netlist, run klayout DRC and (optionally) + magic DRC. Designed to be invoked via ProcessPoolExecutor so each cell + 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) + """ + name = item["name"] + pdk_name = item["pdk"] + deck = Path(item["deck"]) + out_dir = Path(item["out_dir"]) + gds_path = Path(item["gds_path"]) + rpt_path = Path(item["rpt_path"]) + netlist_path = Path(item["netlist_path"]) + result: Dict[str, Any] = {"cell": name, "pdk": pdk_name, "status": "skip"} + try: + print(f"[BUILD] {name}", flush=True) + pdk = _resolve_pdk(pdk_name) + builder = _resolve_builder(_CELL_BUILDERS[name]) + comp = builder(pdk, **item["kwargs"]) + if not hasattr(comp, "write_gds"): + from gdsfactory.component import Component as _Component + wrapper = _Component(name) + wrapper.add(comp) + wrapper.add_ports(comp.get_ports_list()) + if hasattr(comp, "parent") and "netlist" in getattr(comp.parent, "info", {}): + wrapper.info["netlist"] = comp.parent.info["netlist"] + comp = wrapper + comp.name = name + comp.write_gds(str(gds_path)) + netlist_info = comp.info.get("netlist") if hasattr(comp, "info") else None + if netlist_info is not None: + if hasattr(netlist_info, "generate_netlist"): + netlist_text = netlist_info.generate_netlist() + else: + netlist_text = str(netlist_info) + netlist_path.write_text(netlist_text) + except Exception as exc: + result.update({"status": "error", "message": f"build failed: {exc}", "trace": traceback.format_exc()}) + print(f"[ERROR] {name}: build failed\n{result['trace']}", flush=True) + return result + + engines = item.get("engines") or ["klayout"] + engine_results: Dict[str, Dict[str, Any]] = {} + + 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) + effective = viols["effective"] + klayout_res: Dict[str, Any] = { + "engine": "klayout", + "violations": viols, + "report": str(rpt_path.relative_to(out_dir)), + "klayout_returncode": proc.returncode, + "klayout_stderr_tail": (proc.stderr or "")[-400:], + } + if proc.returncode != 0: + klayout_res["status"] = "error" + klayout_res["message"] = f"klayout exited {proc.returncode}" + 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)" + 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]) + klayout_res["message"] = f"{effective} DRC violation(s) [{top}]" + except subprocess.TimeoutExpired: + klayout_res = {"engine": "klayout", "status": "error", "message": "klayout timeout"} + engine_results["klayout"] = klayout_res + print(f"[KLAYOUT:{klayout_res['status'].upper()}] {name}: {klayout_res.get('message', 'clean')}", flush=True) + + if "magic" in engines: + magic_dir = Path(item["magic_dir"]) if item.get("magic_dir") else (out_dir / "magic") + magic_dir.mkdir(parents=True, exist_ok=True) + pdk_obj = _resolve_pdk(pdk_name) + engine_results["magic"] = _run_magic_drc(item, pdk_obj, name, gds_path, magic_dir) + + # Merge into the cell-level result. Cell is "pass" iff every engine passes; + # if any engine errors, the cell is "error"; otherwise "fail". + statuses = [er["status"] for er in engine_results.values()] + if "error" in statuses: + result["status"] = "error" + elif "fail" in statuses: + result["status"] = "fail" + else: + result["status"] = "pass" + result["engines"] = engine_results + result["gds"] = str(gds_path.relative_to(out_dir)) + # Pick the most informative engine message (failing engine first, then passing). + msg_engine = next((e for e, er in engine_results.items() if er["status"] in ("fail", "error")), None) \ + or next((e for e, er in engine_results.items() if er["status"] == "pass"), None) + if msg_engine: + result["message"] = f"{msg_engine}: {engine_results[msg_engine].get('message', engine_results[msg_engine]['status'])}" + print(f"[{result['status'].upper()}] {name}: {result.get('message', 'clean')}", flush=True) + return result + + +def _write_junit(results: List[dict], pdk: str, out: Path) -> None: + suite = ET.Element( + "testsuite", + attrib={ + "name": f"glayout-drc-{pdk}", + "tests": str(len(results)), + "failures": str(sum(1 for r in results if r["status"] == "fail")), + "errors": str(sum(1 for r in results if r["status"] == "error")), + "skipped": str(sum(1 for r in results if r["status"] == "skip")), + }, + ) + for r in results: + case = ET.SubElement( + suite, "testcase", + attrib={"classname": f"drc.{pdk}", "name": r["cell"]}, + ) + if r["status"] == "fail": + ET.SubElement(case, "failure", attrib={"message": r.get("message", "DRC violations")}).text = json.dumps(r, indent=2) + elif r["status"] == "error": + ET.SubElement(case, "error", attrib={"message": r.get("message", "build/DRC error")}).text = json.dumps(r, indent=2) + elif r["status"] == "skip": + ET.SubElement(case, "skipped", attrib={"message": r.get("message", "skipped")}) + tree = ET.ElementTree(suite) + tree.write(out, encoding="utf-8", xml_declaration=True) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--pdk", required=True, choices=["sky130", "gf180"]) + parser.add_argument("--out-dir", default="drc_results") + parser.add_argument( + "--cells", + default=None, + help="Comma-separated cell names; default runs every registered cell.", + ) + parser.add_argument( + "--deck", + default=None, + help="Path to a klayout DRC deck overriding the bundled one (e.g. a PDK-installed deck).", + ) + parser.add_argument( + "--params", + default=None, + help=f"Path to the cell parameter CSV (default: {DEFAULT_PARAM_DIR}/ci_drc_.csv).", + ) + parser.add_argument( + "--jobs", "-j", type=int, default=max(1, (os.cpu_count() or 2) - 1), + help="Worker processes for parallel build+DRC (default: cpu_count-1).", + ) + parser.add_argument( + "--engine", + choices=["klayout", "magic", "both"], + default="klayout", + help="DRC engine(s) to run per cell. 'both' runs klayout and magic in sequence per worker.", + ) + args = parser.parse_args() + engines = ["klayout", "magic"] if args.engine == "both" else [args.engine] + + out_dir = Path(args.out_dir).resolve() + gds_dir = out_dir / "gds" + rpt_dir = out_dir / "reports" + netlist_dir = out_dir / "netlists" + magic_dir = out_dir / "magic" if "magic" in engines else None + out_dir.mkdir(parents=True, exist_ok=True) + gds_dir.mkdir(parents=True, exist_ok=True) + rpt_dir.mkdir(parents=True, exist_ok=True) + netlist_dir.mkdir(parents=True, exist_ok=True) + if magic_dir is not None: + magic_dir.mkdir(parents=True, exist_ok=True) + + deck = _drc_deck_for(args.pdk, args.deck) + if not deck.exists(): + print(f"DRC deck missing: {deck}", file=sys.stderr) + return 2 + + pdk = _resolve_pdk(args.pdk) + specs = _load_cell_specs(args.pdk, Path(args.params).resolve() if args.params else None) + if args.cells: + wanted = {c.strip() for c in args.cells.split(",") if c.strip()} + missing = wanted - set(specs) + if missing: + 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} + + # 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 + # worker (gdsfactory PDK state is per-process). + from concurrent.futures import ProcessPoolExecutor, as_completed + + work_items = [ + { + "name": name, + "pdk": args.pdk, + "deck": str(deck), + "kwargs": spec.kwargs, + "gds_path": str(gds_dir / f"{name}.gds"), + "rpt_path": str(rpt_dir / f"{name}.lyrdb"), + "netlist_path": str(netlist_dir / f"{name}.spice"), + "out_dir": str(out_dir), + "engines": engines, + "magic_dir": str(magic_dir) if magic_dir else None, + } + for name, spec in specs.items() + ] + jobs = max(1, min(args.jobs, len(work_items))) + print(f"running {len(work_items)} cells with {jobs} worker(s)") + results: List[dict] = [] + if jobs == 1: + for item in work_items: + results.append(_run_one_cell(item)) + else: + with ProcessPoolExecutor(max_workers=jobs) as pool: + futures = {pool.submit(_run_one_cell, item): item["name"] for item in work_items} + for fut in as_completed(futures): + results.append(fut.result()) + # Stable order so summary/junit are deterministic regardless of completion order. + name_order = {n: i for i, n in enumerate(specs.keys())} + results.sort(key=lambda r: name_order.get(r["cell"], len(name_order))) + + summary = { + "pdk": args.pdk, + "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"), + "error": sum(1 for r in results if r["status"] == "error"), + "skip": sum(1 for r in results if r["status"] == "skip"), + "results": results, + } + (out_dir / "summary.json").write_text(json.dumps(summary, indent=2)) + _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)) + return 0 if summary["fail"] == 0 and summary["error"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/drc/sweep.py b/tests/drc/sweep.py new file mode 100644 index 00000000..f3a41f38 --- /dev/null +++ b/tests/drc/sweep.py @@ -0,0 +1,193 @@ +"""Brute-force sweep helper to find a DRC-clean parameter set per cell. + +Usage: + python tests/drc/sweep.py --pdk sky130 --cell flipped_voltage_follower + +Iterates a small grid of parameters drawn from the project's parameter sweep +sheet and prints the first combination that produces 0 DRC violations under +the bundled klayout deck (same path as ``run_cell_drc.py``). +""" +from __future__ import annotations + +import argparse +import itertools +import json +import subprocess +import sys +import tempfile +import time +import traceback +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Tuple + +THIS = Path(__file__).resolve() +sys.path.insert(0, str(THIS.parent)) +from run_cell_drc import _drc_deck_for, _resolve_pdk, _count_lyrdb_violations # noqa: E402 + + +def _grid_flipped_voltage_follower() -> Iterable[Dict[str, Any]]: + # Tighter grid: rmult-style sizing first, then placements. + widths = [(3.0, 3.0), (5.0, 5.0), (8.0, 8.0)] + lengths = [(0.5, 0.5), (1.0, 1.0), (2.0, 2.0)] + placements = ["horizontal", "vertical"] + multipliers = [(2, 2), (1, 1)] + for w, l, p, m in itertools.product(widths, lengths, placements, multipliers): + yield { + "device_type": "nmos", + "placement": p, + "width": w, + "length": l, + "fingers": (2, 2), + "multipliers": m, + } + + +def _grid_diff_to_single() -> Iterable[Dict[str, Any]]: + for rmult in (2, 1): + for w in (5.0, 6.0, 7.0, 3.0): + for l in (1.0, 1.5): + for fingers in (2, 4): + for via in (0, 1): + yield {"rmult": rmult, "half_pload": (w, l, fingers), "via_xlocation": via} + + +def _grid_diff_pair_ibias() -> Iterable[Dict[str, Any]]: + # rmult=2 cleaned sky130 immediately; try it first for gf180 too. + for rmult in (2, 1): + for hdp in [(5.0, 1.0, 1), (6.0, 1.0, 1), (6.0, 1.5, 2), (7.0, 1.0, 2), (5.0, 0.5, 4)]: + for db in [(5.0, 2.0, 1), (6.0, 2.0, 1), (6.0, 2.5, 2), (7.0, 2.0, 2)]: + yield { + "half_diffpair_params": hdp, + "diffpair_bias": db, + "rmult": rmult, + "with_antenna_diode_on_diffinputs": 0, + } + + +def _grid_low_voltage_cmirror() -> Iterable[Dict[str, Any]]: + # Smaller, on-grid lengths first; sweep widths/fingers second. + for length in (2.0, 1.5, 3.0): + for w in [(4.0, 1.5), (5.0, 2.0), (6.0, 2.0), (3.0, 1.0)]: + for f in [(2, 1), (3, 1), (2, 2)]: + for m in [(1, 1), (2, 1)]: + yield { + "width": w, + "length": length, + "fingers": f, + "multipliers": m, + } + + +def _grid_opamp() -> Iterable[Dict[str, Any]]: + # Sweep midpoints around the sheet ranges; keep small to bound runtime. + hdp_set = [(5.0, 1.0, 1), (6.0, 1.0, 1), (6.0, 1.5, 2), (7.0, 1.0, 2)] + db_set = [(5.0, 2.0, 1), (6.0, 2.0, 1), (7.0, 2.0, 2)] + cs_p_set = [(6.0, 1.0, 8, 5), (7.0, 1.0, 10, 5), (8.0, 1.5, 12, 5)] + cs_b_set = [(5.0, 2.0, 7, 4), (6.0, 2.0, 8, 4), (7.0, 2.0, 9, 4)] + pload_set = [(5.0, 1.0, 4), (6.0, 1.0, 5), (7.0, 1.0, 6)] + rmult_set = (1, 2) + for hdp, db, csp, csb, pl, rm in itertools.product( + hdp_set, db_set, cs_p_set, cs_b_set, pload_set, rmult_set + ): + yield { + "half_diffpair_params": hdp, + "diffpair_bias": db, + "half_common_source_params": csp, + "half_common_source_bias": csb, + "half_pload": pl, + "add_output_stage": False, + "with_antenna_diode_on_diffinputs": 0, + "rmult": rm, + } + + +GRIDS: Dict[str, Tuple[str, Callable[[], Iterable[Dict[str, Any]]]]] = { + "flipped_voltage_follower": ("glayout.cells.elementary.flipped_voltage_follower", _grid_flipped_voltage_follower), + "differential_to_single_ended_converter":("glayout.cells.composite.differential_to_single_ended_converter", _grid_diff_to_single), + "diff_pair_ibias": ("glayout.cells.composite.diff_pair_ibias", _grid_diff_pair_ibias), + "low_voltage_cmirror": ("glayout.cells.composite.low_voltage_cmirror", _grid_low_voltage_cmirror), + "opamp": ("glayout.cells.composite.opamp.opamp", _grid_opamp), +} + + +def _builder_for(import_path: str) -> Callable[..., Any]: + parts = import_path.split(".") + mod = __import__(".".join(parts[:-1]), fromlist=[parts[-1]]) + return getattr(mod, parts[-1]) + + +def _run_klayout(deck: Path, gds: Path, report: Path) -> int: + cmd = [ + "klayout", "-b", + "-r", str(deck), + "-rd", f"input={gds}", + "-rd", f"report={report}", + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + return proc.returncode + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--pdk", required=True, choices=["sky130", "gf180"]) + parser.add_argument("--cell", required=True, choices=list(GRIDS.keys())) + parser.add_argument("--max-trials", type=int, default=200) + parser.add_argument("--stop-on-first", action="store_true", default=True) + args = parser.parse_args() + + pdk = _resolve_pdk(args.pdk) + deck = _drc_deck_for(args.pdk) + if not deck.exists(): + print(f"DRC deck missing: {deck}", file=sys.stderr) + return 2 + import_path, grid = GRIDS[args.cell] + builder = _builder_for(import_path) + + workdir = Path(tempfile.mkdtemp(prefix=f"sweep_{args.cell}_{args.pdk}_")) + print(f"workdir: {workdir}") + cleanest = (10**9, None) # (violations, params) + tried = 0 + for params in grid(): + if tried >= args.max_trials: + break + tried += 1 + gds = workdir / f"trial_{tried}.gds" + rpt = workdir / f"trial_{tried}.lyrdb" + t0 = time.time() + try: + comp = builder(pdk, **params) + if not hasattr(comp, "write_gds"): + from gdsfactory.component import Component as _Component + wrapper = _Component(args.cell) + wrapper.add(comp) + wrapper.add_ports(comp.get_ports_list()) + comp = wrapper + comp.name = args.cell + comp.write_gds(str(gds)) + except Exception as exc: + print(f"[{tried}] BUILD FAIL: {params} -> {exc}") + continue + + rc = _run_klayout(deck, gds, rpt) + if rc != 0: + print(f"[{tried}] klayout rc={rc}: {params}") + continue + viols = _count_lyrdb_violations(rpt) + v = viols["effective"] + elapsed = time.time() - t0 + print(f"[{tried}] violations={v:<4d} (ignored {viols['ignored']:>2d}) ({elapsed:5.1f}s) params={json.dumps(params, default=str)}") + if v >= 0 and v < cleanest[0]: + cleanest = (v, params) + if v == 0 and args.stop_on_first: + print(f"\nCLEAN POINT for {args.cell} on {args.pdk}:") + print(json.dumps(params, indent=2, default=str)) + return 0 + + print(f"\nNo clean point in {tried} trials. Cleanest had {cleanest[0]} violations:") + print(json.dumps(cleanest[1], indent=2, default=str)) + return 1 if cleanest[0] != 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/lvs/klayout_gf180.py b/tests/lvs/klayout_gf180.py new file mode 100644 index 00000000..416f57b3 --- /dev/null +++ b/tests/lvs/klayout_gf180.py @@ -0,0 +1,234 @@ +"""gf180 LVS via klayout's bundled gf180mcu deck. + +magic+netgen on gf180 mis-extracts the substrate (NMOS bulks merge into +VDD via the n-well), so for gf180 we drive the official gf180mcu klayout +LVS deck instead. The deck lives inside the PDK install: + + $PDK_ROOT/ciel/gf180mcu/versions//gf180mcuD/libs.tech/klayout/tech/lvs/run_lvs.py + +The version `` is recorded in `$PDK_ROOT/ciel/gf180mcu/current`, so +we resolve the deck path through that pointer (no hard-coded version). + +This module exposes one entry point, :func:`run_lvs_klayout_gf180`, that +mirrors `pdk.lvs_netgen`'s call signature so the CI harness in +`tests/lvs/run_cell_lvs.py` can dispatch by PDK without restructuring. +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Dict, Optional + + +# Reference SPICE bundled with gf180_mapped — included in the staged netlist +# so klayout can resolve any standard-cell sub-circuits referenced in tests. +_REF_SPICE = ( + Path(__file__).resolve().parents[2] + / "src" / "glayout" / "pdk" / "gf180_mapped" / "gf180mcu_osu_sc_9T.spice" +) + + +def _resolve_deck_dir(pdk_root: str) -> Path: + """Resolve the gf180mcu klayout LVS deck directory from $PDK_ROOT. + + Reads `$PDK_ROOT/ciel/gf180mcu/current` to pick the version hash, then + points at the variant-D (5LM, 11K top metal) klayout LVS folder. + """ + pointer = Path(pdk_root) / "ciel" / "gf180mcu" / "current" + if not pointer.is_file(): + raise FileNotFoundError(f"missing gf180mcu version pointer at {pointer}") + version = pointer.read_text().strip() + deck = ( + Path(pdk_root) + / "ciel" / "gf180mcu" / "versions" / version + / "gf180mcuD" / "libs.tech" / "klayout" / "tech" / "lvs" + ) + if not (deck / "run_lvs.py").is_file(): + raise FileNotFoundError(f"missing run_lvs.py under {deck}") + 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. + + 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. + """ + 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: + return cand + return tokens[-1] if tokens else "gf180mcu_gnd" + + +_GF180_PRIMITIVE_FETS = ("nfet_03v3", "pfet_03v3") + + +def _rewrite_x_to_m_for_primitives(cdl_text: str) -> str: + """Rewrite X-prefix instances of gf180 primitive MOSFETs to M-prefix. + + 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. + """ + 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, + ) + return pat.sub(r"M\1\2", cdl_text) + + +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. + """ + 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") + parts.append(cdl_text) + spice_dst.write_text("".join(parts)) + return spice_dst + + +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. + if "ModuleNotFoundError: No module named 'docopt'" in log: + return {"is_pass": False, "conclusion": "missing dep: docopt (pip install docopt in the LVS venv)"} + if "ModuleNotFoundError: No module named 'klayout'" in log: + return {"is_pass": False, "conclusion": "missing dep: klayout (pip install klayout in the LVS venv)"} + if "klayout: command not found" in log or "klayout: not found" in log: + return {"is_pass": False, "conclusion": "klayout binary not on PATH"} + 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: + return {"is_pass": False, "conclusion": "Netlists do not match"} + return {"is_pass": False, "conclusion": "LVS inconclusive"} + + +def run_lvs_klayout_gf180( + layout: str, + design_name: str, + netlist: str, + output_file_path: str, + pdk_root: Optional[str] = None, +) -> 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. + """ + layout_path = Path(layout) + netlist_path = Path(netlist) + out_root = Path(output_file_path) + rpt_dir = out_root / "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" + + with tempfile.TemporaryDirectory(prefix=f"klvs_{design_name}_") as tmp: + tmpdir = Path(tmp) + 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}", + ] + 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 "") + 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"): + shutil.copy(src, rpt_dir / src.name) + + summary = _classify_log(log_text) + return { + "subproc_code": proc.returncode, + "report_path": str(rpt_file), + "is_pass": summary["is_pass"], + "conclusion": summary["conclusion"], + } diff --git a/tests/lvs/run_cell_lvs.py b/tests/lvs/run_cell_lvs.py new file mode 100644 index 00000000..1de934a7 --- /dev/null +++ b/tests/lvs/run_cell_lvs.py @@ -0,0 +1,275 @@ +"""Runs ``pdk.lvs_netgen`` on every cell using the GDS + reference netlist +emitted by ``tests/drc/run_cell_drc.py``. + +The LVS CI workflow pulls the DRC artifact (``drc_results//``), which +contains: + + drc_results// + gds/.gds + netlists/.spice <-- written by run_cell_drc.py + reports/.lyrdb + summary.json + +This script iterates the cells whose GDS + netlist are present, calls +``pdk.lvs_netgen``, and emits ``summary.json`` + ``junit.xml`` mirroring the +DRC runner's shape so the same workflow plumbing (artifact upload, JUnit +publication) works. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import traceback +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List, Optional + + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "tests" / "drc")) +from run_cell_drc import _resolve_pdk # noqa: E402 (reuse PDK resolver) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from klayout_gf180 import run_lvs_klayout_gf180 # noqa: E402 + + +def _parse_lvs_report(text: str) -> Dict[str, Any]: + """Lightweight parse of a netgen LVS report. + + Looks for the canonical 'Circuits match uniquely' / 'Netlists match' lines + and pulls counts of mismatched nets and instances if present. Returns a + dict suitable for embedding in summary.json. + """ + summary: Dict[str, Any] = { + "is_pass": False, + "conclusion": "LVS inconclusive", + "unmatched_nets": 0, + "unmatched_instances": 0, + "raw_tail": text[-1200:] if text else "", + } + if not text: + return summary + # Surface the most common environment failures explicitly so the CI + # status doesn't read as the catch-all "LVS inconclusive". The gf180 + # klayout deck's `run_lvs.py` imports `docopt` and `klayout.db` before + # doing any work, and a missing module aborts the whole script — + # without these branches the `lvs.rpt` is just a Python traceback and + # the runner reports it as "LVS inconclusive (0 mismatches)". + if "ModuleNotFoundError: No module named 'docopt'" in text: + summary["conclusion"] = "missing dep: docopt" + return summary + if "ModuleNotFoundError: No module named 'klayout'" in text: + summary["conclusion"] = "missing dep: klayout" + return summary + if "klayout: command not found" in text or "klayout: not found" in text: + summary["conclusion"] = "klayout binary not on PATH" + return summary + if "Netlists match" in text or "Circuits match uniquely" in text: + summary["is_pass"] = True + summary["conclusion"] = "Netlists match" + elif ( + "Netlists do not match" in text + or "Netlist mismatch" in text + # gf180 klayout deck emits this exact phrasing on a failed compare. + or "Netlists don't match" in text + ): + summary["conclusion"] = "Netlists do not match" + + summary["unmatched_nets"] = sum(1 for _ in re.finditer(r"\(no matching net\)", text)) + summary["unmatched_instances"] = sum(1 for _ in re.finditer(r"\(no matching instance\)", text)) + if summary["unmatched_nets"] or summary["unmatched_instances"]: + summary["is_pass"] = False + if "match" in summary["conclusion"].lower() and "do not" not in summary["conclusion"].lower(): + summary["conclusion"] = "Mismatches found" + return summary + + +def _write_junit(results: List[dict], pdk: str, out: Path) -> None: + suite = ET.Element( + "testsuite", + attrib={ + "name": f"glayout-lvs-{pdk}", + "tests": str(len(results)), + "failures": str(sum(1 for r in results if r["status"] == "fail")), + "errors": str(sum(1 for r in results if r["status"] == "error")), + "skipped": str(sum(1 for r in results if r["status"] == "skip")), + }, + ) + for r in results: + case = ET.SubElement( + suite, "testcase", + attrib={"classname": f"lvs.{pdk}", "name": r["cell"]}, + ) + if r["status"] == "fail": + ET.SubElement(case, "failure", attrib={"message": r.get("message", "LVS mismatch")}).text = json.dumps(r, indent=2) + elif r["status"] == "error": + ET.SubElement(case, "error", attrib={"message": r.get("message", "LVS error")}).text = json.dumps(r, indent=2) + elif r["status"] == "skip": + ET.SubElement(case, "skipped", attrib={"message": r.get("message", "skipped")}) + ET.ElementTree(suite).write(out, encoding="utf-8", xml_declaration=True) + + +def _enumerate_cells(inputs_dir: Path) -> List[str]: + """Cells that have BOTH a GDS and a reference netlist.""" + gds = {p.stem for p in (inputs_dir / "gds").glob("*.gds")} if (inputs_dir / "gds").is_dir() else set() + nets = {p.stem for p in (inputs_dir / "netlists").glob("*.spice")} if (inputs_dir / "netlists").is_dir() else set() + return sorted(gds & nets) + + +def _run_one_lvs(item: dict) -> dict: + """Run LVS for one cell. Designed for ProcessPoolExecutor. + + sky130 uses magic+netgen (`pdk.lvs_netgen`). gf180 uses the gf180mcu + PDK's official klayout LVS deck — magic+netgen mis-extracts the gf180 + substrate (NMOS bulks merge into VDD via the n-well), so we drive the + deck's own run_lvs.py instead. See tests/lvs/klayout_gf180.py. + """ + name = item["name"] + pdk_name = item["pdk"] + gds_path = item["gds_path"] + netlist_path = item["netlist_path"] + out_dir = Path(item["out_dir"]) + rpt_dir = Path(item["rpt_dir"]) + result: Dict[str, Any] = {"cell": name, "pdk": pdk_name, "status": "skip"} + try: + print(f"[LVS] {name}", flush=True) + if pdk_name == "gf180": + ret = run_lvs_klayout_gf180( + layout=str(gds_path), + design_name=name, + netlist=str(netlist_path), + output_file_path=str(rpt_dir), + ) + else: + pdk = _resolve_pdk(pdk_name) + ret = pdk.lvs_netgen( + layout=str(gds_path), + design_name=name, + netlist=str(netlist_path), + output_file_path=str(rpt_dir), + ) + except Exception as exc: + result.update({"status": "error", "message": f"lvs failed: {exc}", "trace": traceback.format_exc()}) + print(f"[ERROR] {name}: {exc}", flush=True) + return result + rpt_file = rpt_dir / "lvs" / name / f"{name}_lvs.rpt" + report_text = rpt_file.read_text() if rpt_file.exists() else "" + parsed = _parse_lvs_report(report_text) + result.update({ + "summary": parsed, + "subproc_code": ret.get("subproc_code") if isinstance(ret, dict) else None, + "report": str(rpt_file.relative_to(out_dir)) if rpt_file.exists() else None, + }) + if not rpt_file.exists(): + result["status"] = "error" + result["message"] = "lvs report not produced" + elif parsed["is_pass"]: + result["status"] = "pass" + result["message"] = "Netlists match" + else: + result["status"] = "fail" + mismatches = parsed["unmatched_nets"] + parsed["unmatched_instances"] + result["message"] = f"{parsed['conclusion']} ({mismatches} mismatch{'es' if mismatches != 1 else ''})" + print(f"[{result['status'].upper()}] {name}: {result.get('message','')}", flush=True) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--pdk", required=True, choices=["sky130", "gf180"]) + parser.add_argument( + "--inputs-dir", required=True, + help="Directory containing gds/.gds and netlists/.spice (DRC artifact root for the PDK).", + ) + parser.add_argument("--out-dir", default="lvs_results") + parser.add_argument( + "--cells", + default=None, + help="Comma-separated cell names; default runs every cell with both GDS+netlist.", + ) + parser.add_argument( + "--skip-cells", + default="differential_to_single_ended_converter", + 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)." + ), + ) + 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).", + ) + args = parser.parse_args() + + inputs_dir = Path(args.inputs_dir).resolve() + out_dir = Path(args.out_dir).resolve() + rpt_dir = out_dir / "reports" + out_dir.mkdir(parents=True, exist_ok=True) + rpt_dir.mkdir(parents=True, exist_ok=True) + + cells = _enumerate_cells(inputs_dir) + if not cells: + print(f"no cells found under {inputs_dir} (expected gds/ and netlists/)", file=sys.stderr) + return 2 + + if args.cells: + wanted = {c.strip() for c in args.cells.split(",") if c.strip()} + missing = wanted - set(cells) + if missing: + print(f"warning: cells without inputs: {sorted(missing)}", file=sys.stderr) + cells = [c for c in cells if c in wanted] + elif args.skip_cells: + skip = {c.strip() for c in args.skip_cells.split(",") if c.strip()} + skipped = [c for c in cells if c in skip] + cells = [c for c in cells if c not in skip] + for s in skipped: + print(f"skipping cell on the --skip-cells list: {s}") + + work_items = [ + { + "name": name, + "pdk": args.pdk, + "gds_path": str(inputs_dir / "gds" / f"{name}.gds"), + "netlist_path": str(inputs_dir / "netlists" / f"{name}.spice"), + "out_dir": str(out_dir), + "rpt_dir": str(rpt_dir), + } + for name in cells + ] + jobs = max(1, min(args.jobs, len(work_items))) + print(f"running {len(work_items)} cells with {jobs} worker(s)") + from concurrent.futures import ProcessPoolExecutor, as_completed + results: List[dict] = [] + if jobs == 1: + for item in work_items: + results.append(_run_one_lvs(item)) + else: + with ProcessPoolExecutor(max_workers=jobs) as pool: + futures = {pool.submit(_run_one_lvs, item): item["name"] for item in work_items} + for fut in as_completed(futures): + results.append(fut.result()) + name_order = {n: i for i, n in enumerate(cells)} + results.sort(key=lambda r: name_order.get(r["cell"], len(name_order))) + + summary = { + "pdk": args.pdk, + "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"), + "error": sum(1 for r in results if r["status"] == "error"), + "skip": sum(1 for r in results if r["status"] == "skip"), + "results": results, + } + (out_dir / "summary.json").write_text(json.dumps(summary, indent=2)) + _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)) + return 0 if summary["fail"] == 0 and summary["error"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/parameters/ci_drc_gf180.csv b/tests/parameters/ci_drc_gf180.csv new file mode 100644 index 00000000..165214f7 --- /dev/null +++ b/tests/parameters/ci_drc_gf180.csv @@ -0,0 +1,10 @@ +cell,params_json +current_mirror_nfet,"{""device"": ""nfet"", ""numcols"": 2}" +current_mirror_pfet,"{""device"": ""pfet"", ""numcols"": 2}" +diff_pair,"{""width"": 3, ""fingers"": 4, ""n_or_p_fet"": true}" +flipped_voltage_follower,"{""device_type"": ""nmos"", ""placement"": ""vertical"", ""width"": [3.0, 3.0], ""length"": [0.5, 0.5], ""fingers"": [2, 2], ""multipliers"": [1, 1]}" +transmission_gate,{} +differential_to_single_ended_converter,"{""rmult"": 3, ""half_pload"": [3.0, 1.0, 2], ""via_xlocation"": 12}" +diff_pair_ibias,"{""half_diffpair_params"": [5.0, 1.0, 1], ""diffpair_bias"": [5.0, 2.0, 1], ""rmult"": 2, ""with_antenna_diode_on_diffinputs"": 0}" +low_voltage_cmirror,"{""width"": [4.0, 1.5], ""length"": 2.0, ""fingers"": [2, 1], ""multipliers"": [1, 1]}" +opamp,"{""half_diffpair_params"": [5.0, 1.0, 1], ""diffpair_bias"": [5.0, 2.0, 1], ""half_common_source_params"": [7.0, 1.0, 10, 5], ""half_common_source_bias"": [6.0, 2.0, 8, 4], ""half_pload"": [6.0, 1.0, 5], ""add_output_stage"": false, ""with_antenna_diode_on_diffinputs"": 0, ""rmult"": 1}" diff --git a/tests/parameters/ci_drc_sky130.csv b/tests/parameters/ci_drc_sky130.csv new file mode 100644 index 00000000..b4a4f46d --- /dev/null +++ b/tests/parameters/ci_drc_sky130.csv @@ -0,0 +1,10 @@ +cell,params_json +current_mirror_nfet,"{""device"": ""nfet"", ""numcols"": 2}" +current_mirror_pfet,"{""device"": ""pfet"", ""numcols"": 2}" +diff_pair,"{""width"": 3, ""fingers"": 4, ""n_or_p_fet"": true}" +flipped_voltage_follower,"{""device_type"": ""nmos"", ""placement"": ""horizontal"", ""width"": [5.0, 5.0], ""length"": [1.0, 1.0], ""fingers"": [2, 2], ""multipliers"": [1, 1]}" +transmission_gate,{} +differential_to_single_ended_converter,"{""rmult"": 1, ""half_pload"": [3.0, 1.0, 2], ""via_xlocation"": 0}" +diff_pair_ibias,"{""half_diffpair_params"": [5.0, 1.0, 1], ""diffpair_bias"": [5.0, 2.0, 1], ""rmult"": 1, ""with_antenna_diode_on_diffinputs"": 0}" +low_voltage_cmirror,"{""width"": [4.0, 1.5], ""length"": 2.0, ""fingers"": [2, 1], ""multipliers"": [1, 1]}" +opamp,"{""half_diffpair_params"": [6, 1, 4], ""diffpair_bias"": [6, 2, 4], ""half_common_source_params"": [7, 1, 10, 3], ""half_common_source_bias"": [6, 2, 8, 2], ""output_stage_params"": [5, 1, 16], ""output_stage_bias"": [6, 2, 4], ""half_pload"": [6, 1, 6], ""mim_cap_size"": [12, 12], ""mim_cap_rows"": 3, ""rmult"": 2, ""with_antenna_diode_on_diffinputs"": 0, ""add_output_stage"": false}" diff --git a/tests/run_ci_locally.py b/tests/run_ci_locally.py new file mode 100755 index 00000000..ae8d8ca2 --- /dev/null +++ b/tests/run_ci_locally.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Reproduce the CI workflows (Cell DRC + Cell LVS) locally inside the same +iic-osic-tools docker image GitHub Actions uses. + +Usage: + tests/run_ci_locally.py # DRC then LVS for sky130+gf180 + tests/run_ci_locally.py --skip-lvs # just DRC + tests/run_ci_locally.py --pdks sky130 # only sky130 (DRC + LVS) + tests/run_ci_locally.py --cells current_mirror_nfet,diff_pair # subset + +The first run takes ~5 min (apt + pip install gdsfactory). Subsequent runs +reuse the venv cached at ``.drc-cache/venv/`` and finish in ~1 min. + +Outputs (mirrors the CI artifact layout): + .drc-cache/reports// — DRC results (gds, netlists, lyrdb, junit) + .drc-cache/lvs// — LVS results (reports, junit) +""" +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CACHE = REPO_ROOT / ".drc-cache" +DRC_DIR = CACHE / "reports" +LVS_DIR = CACHE / "lvs" + +# Default sets — match the CI workflow matrices. +DEFAULT_DRC_PDKS = ("sky130", "gf180") +DEFAULT_LVS_PDKS = ("sky130", "gf180") # both PDKs supported; mirrors lvs.yml + +IMAGE = "hpretl/iic-osic-tools:latest" + + +# Bash that boots Python 3.10 + the cached venv inside the container. Mirrors +# what .github/workflows/{drc,lvs}.yml do: install Python 3.10 via uv (from +# python-build-standalone, hosted on GitHub releases) instead of via the +# deadsnakes PPA, which has been flaky. +BOOTSTRAP = r""" +set -uo pipefail +unset PYTHONPATH +export DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1 + +if [ ! -x "$HOME/.local/bin/uv" ]; then + curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1 +fi +export PATH="$HOME/.local/bin:$PATH" +uv python install 3.10 >/dev/null 2>&1 +PYTHON310="$(uv python find 3.10)" + +if [ ! -x /work/.drc-cache/venv/bin/python ]; then + "$PYTHON310" -m venv /work/.drc-cache/venv + . /work/.drc-cache/venv/bin/activate + uv pip install -e . >/dev/null +else + # Cache hit: glayout's .pth points to /work which is the same on every + # run, so no refresh is needed (matches the workflows). + . /work/.drc-cache/venv/bin/activate +fi +""" + + +def _run_parallel(label: str, jobs: list[tuple[str, list[str]]]) -> int: + """Launch jobs concurrently, stream each into a per-job log file, return + the worst exit code. ``jobs`` is a list of (name, command) tuples.""" + if not jobs: + return 0 + CACHE.mkdir(exist_ok=True) + log_dir = CACHE / "logs" + log_dir.mkdir(exist_ok=True) + procs: list[tuple[str, subprocess.Popen, Path]] = [] + for name, cmd in jobs: + log_path = log_dir / f"{label}_{name}.log" + log = log_path.open("w") + log.write(f"$ {' '.join(shlex.quote(c) for c in cmd)}\n") + log.flush() + print(f" → {label} {name}: streaming to {log_path}") + procs.append((name, subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT), log_path)) + overall = 0 + for name, proc, log_path in procs: + rc = proc.wait() + proc.stdout = None # close fd via gc + print(f" ← {label} {name}: exit {rc} (log: {log_path})") + overall = overall or rc + return overall + + +def _drc_command(pdk: str, cells: str | None, image: str) -> list[str]: + cells_arg = f" --cells {shlex.quote(cells)}" if cells else "" + script = BOOTSTRAP + ( + f"\npython tests/drc/run_cell_drc.py " + f"--pdk {shlex.quote(pdk)} " + f"--out-dir /work/.drc-cache/reports/{shlex.quote(pdk)}" + f"{cells_arg}\n" + ) + return [ + "docker", "run", "--rm", "--user", "root", + "--entrypoint", "/bin/bash", + "-v", f"{REPO_ROOT}:/work", + "-w", "/work", image, "-lc", script, + ] + + +def _lvs_command(pdk: str, cells: str | None, image: str) -> list[str]: + inputs = f"/work/.drc-cache/reports/{pdk}" + out = f"/work/.drc-cache/lvs/{pdk}" + cells_arg = f" --cells {shlex.quote(cells)}" if cells else "" + script = BOOTSTRAP + ( + f"\npython tests/lvs/run_cell_lvs.py " + f"--pdk {shlex.quote(pdk)} " + f"--inputs-dir {inputs} " + f"--out-dir {out}" + f"{cells_arg}\n" + ) + return [ + "docker", "run", "--rm", "--user", "root", + "--entrypoint", "/bin/bash", + "-v", f"{REPO_ROOT}:/work", + "-w", "/work", image, "-lc", script, + ] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--pdks", default=",".join(DEFAULT_DRC_PDKS), + help="Comma-separated PDKs to run DRC on (default: sky130,gf180).") + parser.add_argument("--cells", default=None, + help="Comma-separated cell names; default runs every cell in the CSV.") + parser.add_argument("--skip-drc", action="store_true", help="Skip DRC, run only LVS (requires existing DRC artifacts).") + parser.add_argument("--skip-lvs", action="store_true", help="Skip LVS.") + parser.add_argument("--lvs-pdks", default=",".join(DEFAULT_LVS_PDKS), + help="Comma-separated PDKs to run LVS on (default: sky130).") + parser.add_argument("--image", default=IMAGE, help=f"Docker image to use (default: {IMAGE}).") + parser.add_argument( + "--serial", action="store_true", + help="Run PDKs sequentially instead of in parallel containers.", + ) + args = parser.parse_args() + + pdks = [p.strip() for p in args.pdks.split(",") if p.strip()] + lvs_pdks = [p.strip() for p in args.lvs_pdks.split(",") if p.strip()] + + if subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) != 0: + print("error: docker is not available — start Docker Desktop / dockerd first.", file=sys.stderr) + return 2 + + CACHE.mkdir(exist_ok=True) + DRC_DIR.mkdir(exist_ok=True) + LVS_DIR.mkdir(exist_ok=True) + + overall = 0 + + if not args.skip_drc: + if args.serial or len(pdks) <= 1: + for pdk in pdks: + print(f"\n========== DRC: {pdk} ==========") + rc = subprocess.call(_drc_command(pdk, args.cells, args.image)) + print(f"---------- DRC: {pdk} → exit {rc} ----------") + overall = overall or rc + else: + print(f"\n========== DRC: {pdks} (parallel) ==========") + jobs = [(pdk, _drc_command(pdk, args.cells, args.image)) for pdk in pdks] + overall = overall or _run_parallel("drc", jobs) + + if not args.skip_lvs: + if args.serial or len(lvs_pdks) <= 1: + for pdk in lvs_pdks: + print(f"\n========== LVS: {pdk} ==========") + rc = subprocess.call(_lvs_command(pdk, args.cells, args.image)) + print(f"---------- LVS: {pdk} → exit {rc} ----------") + overall = overall or rc + else: + print(f"\n========== LVS: {lvs_pdks} (parallel) ==========") + jobs = [(pdk, _lvs_command(pdk, args.cells, args.image)) for pdk in lvs_pdks] + overall = overall or _run_parallel("lvs", jobs) + + print("\nResults:") + for pdk in pdks: + rpt = DRC_DIR / pdk / "summary.json" + print(f" DRC {pdk}: {'present' if rpt.exists() else 'MISSING'} ({rpt})") + for pdk in lvs_pdks: + rpt = LVS_DIR / pdk / "summary.json" + print(f" LVS {pdk}: {'present' if rpt.exists() else 'MISSING'} ({rpt})") + return overall + + +if __name__ == "__main__": + sys.exit(main())