Coverage for tests/rcx25/rcx25_R_ihp_sg13g2_test.py: 100%
120 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 23:32 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 23:32 +0000
1#
2# --------------------------------------------------------------------------------
3# SPDX-FileCopyrightText: 2024-2025 Martin Jan Köhler and Harald Pretl
4# Johannes Kepler University, Institute for Integrated Circuits.
5#
6# This file is part of KPEX
7# (see https://github.com/martinjankoehler/klayout-pex).
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21# SPDX-License-Identifier: GPL-3.0-or-later
22# --------------------------------------------------------------------------------
23#
25from __future__ import annotations
27import os
28import tempfile
29from typing import *
30from unittest import mock
32import allure
33import klayout.db as kdb
34import pytest
36from klayout_pex.klayout.lvsdb_extractor import KLayoutExtractionContext
37from klayout_pex.kpex_cli import KpexCLI
38from klayout_pex.pdk_config import PDK
39from klayout_pex.tech_info import TechInfo
41parent_suite = "kpex/2.5D Extraction Tests [PDK ihp-sg13g2 | mode R]"
42tags = ("PEX", "2.5D", "RF MOS")
44TEST_DESIGNS_DIR = os.path.realpath(os.path.join(__file__, '..', '..', '..', 'testdata', 'designs', 'ihp-sg13g2'))
47@allure.parent_suite(parent_suite)
48@allure.tag(*tags)
49@pytest.mark.slow
50def test_rf_mos_keeps_its_layout_geometry():
51 # The IHP LVS script replaced each extracted RF MOS by a newly created device
52 # of the base class (sg13_lv_nmos with rfmode=1), which has no layout geometry,
53 # so the resistance extraction could not connect it (#211)
54 cell_name = 'rfnmos_w1u_l0u72'
55 gds_path = os.path.join(TEST_DESIGNS_DIR, 'rfnmos', f"{cell_name}.gds.gz")
57 with tempfile.TemporaryDirectory() as out_dir:
58 cli = KpexCLI()
59 cli.main(['main',
60 '--pdk', PDK.IHP_SG13G2,
61 '--mode', 'R',
62 '--gds', gds_path,
63 '--out_dir', out_dir,
64 '--2.5D'])
65 assert cli.rcx25_extraction_results is not None
67 lvsdb = kdb.LayoutVsSchematic()
68 lvsdb.read(os.path.join(out_dir, f"{cell_name}__{cell_name}", f"{cell_name}.lvsdb.gz"))
70 assert [c.name for c in lvsdb.internal_layout().top_cells()] == [cell_name]
72 tech = TechInfo.from_json(PDK.IHP_SG13G2.config.tech_pb_json_path, dielectric_filter=None)
73 pex_context = KLayoutExtractionContext.prepare_extraction(lvsdb=lvsdb,
74 top_cell=cell_name,
75 tech=tech,
76 blackbox_devices=False)
77 devices = list(pex_context.devices_by_name.values())
78 assert [(d.device_class_name, d.device_abstract_name) for d in devices] == [('rfnmos', 'D$rfnmos')]
79 assert {t.name: [r.layer.canonical_layer_name for r in t.region_by_layer] for t in devices[0].terminals} \
80 == {'S': ['nSD'], 'G': ['GatPoly'], 'D': ['nSD'], 'B': ['PWell']}
83@allure.parent_suite(parent_suite)
84@allure.tag("PEX", "2.5D")
85@pytest.mark.slow
86def test_device_terminals_without_tech_layer_are_where_they_overlap_their_net():
87 # The terminals of the MOM capacitor are on its ports (cap_cmomi_m5_ports, the Metal5 pins within its marker),
88 # an LVS layer the tech info has no layer for (#217), so they are where they overlap the Metal5 of their nets
89 cell_name = 'cmomi_w5u_l5u_m1_m5'
90 gds_path = os.path.join(TEST_DESIGNS_DIR, cell_name, f"{cell_name}.gds.gz")
92 with tempfile.TemporaryDirectory() as out_dir, \
93 mock.patch('klayout_pex.klayout.lvsdb_extractor.warning') as warning_mock:
94 KpexCLI().main(['main',
95 '--pdk', PDK.IHP_SG13G2,
96 '--mode', 'R',
97 '--gds', gds_path,
98 '--out_dir', out_dir,
99 '--2.5D'])
101 lvsdb = kdb.LayoutVsSchematic()
102 lvsdb.read(os.path.join(out_dir, f"{cell_name}__{cell_name}", f"{cell_name}.lvsdb.gz"))
104 assert [c.args[0] for c in warning_mock.call_args_list if 'device terminals' in c.args[0]] == []
106 tech = TechInfo.from_json(PDK.IHP_SG13G2.config.tech_pb_json_path, dielectric_filter=None)
107 pex_context = KLayoutExtractionContext.prepare_extraction(lvsdb=lvsdb,
108 top_cell=cell_name,
109 tech=tech,
110 blackbox_devices=True)
111 mom_cap, = [d for d in pex_context.devices_by_name.values() if d.device_class_name == 'cap_cmomi']
112 assert {t.name: [r.layer.canonical_layer_name for r in t.region_by_layer] for t in mom_cap.terminals} \
113 == {'mim_top': ['Metal5'], 'mim_btm': ['Metal5']}
116@allure.parent_suite(parent_suite)
117@allure.tag("PEX", "2.5D")
118@pytest.mark.slow
119@pytest.mark.parametrize('design_dir, cell_name, substrate_net_name', [
120 # no label names the substrate, so it's the global net sub! of the PEX-LVS script
121 ('rfnmos', 'rfnmos_w1u_l0u72', 'sub!'),
122 # the taps connect the substrate to VSS, which names it, so it's found by pwell_sub
123 ('sg13g2_a21o_1', 'sg13g2_a21o_1', 'VSS'),
124])
125def test_substrate_net_is_found_by_its_name_or_its_layer(design_dir: str, cell_name: str, substrate_net_name: str):
126 gds_path = os.path.join(TEST_DESIGNS_DIR, design_dir, f"{cell_name}.gds.gz")
127 with tempfile.TemporaryDirectory() as out_dir:
128 KpexCLI().main(['main',
129 '--pdk', PDK.IHP_SG13G2,
130 '--mode', 'R',
131 '--gds', gds_path,
132 '--out_dir', out_dir,
133 '--2.5D'])
134 lvsdb = kdb.LayoutVsSchematic()
135 lvsdb.read(os.path.join(out_dir, f"{cell_name}__{cell_name}", f"{cell_name}.lvsdb.gz"))
137 tech = TechInfo.from_json(PDK.IHP_SG13G2.config.tech_pb_json_path, dielectric_filter=None)
138 pex_context = KLayoutExtractionContext.prepare_extraction(lvsdb=lvsdb,
139 top_cell=cell_name,
140 tech=tech,
141 blackbox_devices=False)
142 assert pex_context.substrate_net_name == substrate_net_name
145def extract_test_pattern(cell_name: str) -> Tuple[List[str], List[str]]:
146 """
147 :return: the lines of the CSV and of the SPICE netlist (with continuation lines joined)
148 """
149 gds_path = os.path.join(TEST_DESIGNS_DIR, 'test_patterns', f"{cell_name}.gds.gz")
150 with tempfile.TemporaryDirectory() as out_dir:
151 cli = KpexCLI()
152 cli.main(['main',
153 '--pdk', PDK.IHP_SG13G2,
154 '--mode', 'R',
155 '--gds', gds_path,
156 '--out_dir', out_dir,
157 '--2.5D'])
158 csv_path = cli.rcx25_extracted_csv_path
159 with open(csv_path) as f:
160 csv_lines = f.read().splitlines()
161 with open(csv_path[:-len('.csv')] + '.spice') as f:
162 spice_lines = f.read().replace('\n+', ' ').splitlines()
163 return csv_lines, spice_lines
166def subckt_ports(spice_lines: List[str]) -> List[str]:
167 subckt_line, = [line for line in spice_lines if line.startswith('.SUBCKT ')]
168 return [port.replace('\\', '') for port in subckt_line.split()[2:]]
171def ports_touching_nothing(spice_lines: List[str]) -> List[str]:
172 element_nodes: Set[str] = set()
173 for line in spice_lines:
174 tokens = line.split()
175 if not tokens or tokens[0][0] not in 'CRX':
176 continue
177 if tokens[0][0] == 'X': # the nodes, the subcircuit, then the parameters
178 nodes = tokens[1:next(i for i, t in enumerate(tokens) if '=' in t) - 1]
179 else:
180 nodes = tokens[1:3]
181 element_nodes.update(node.replace('\\', '') for node in nodes)
182 return [port for port in subckt_ports(spice_lines) if port not in element_nodes]
185@allure.parent_suite(parent_suite)
186@allure.tag("PEX", "2.5D")
187@pytest.mark.slow
188def test_nets_of_the_same_name_stay_apart():
189 # Two transistors, each with its own metal islands labelled D and S, which are not connected
190 # (like the islands of a supply, joined only in the parent). The IHP LVS script connects no nets implicitly,
191 # so they are distinct nets of the same name, which KLayout's SPICE writer calls D, D$1, S, S$1 (#211 §10)
192 csv_lines, _ = extract_test_pattern('nmos_metal1_redux_twice')
193 assert csv_lines == """Device;Net1;Net2;Capacitance [fF];Resistance [Ω]
194R1;$1;$1.$0.GatPoly;;33.654
195R2;$1.$0.GatPoly;$1.$1.Metal1;;15.0
196R3;$4;$4.$0.GatPoly;;33.654
197R4;$4.$0.GatPoly;$4.$1.Metal1;;15.0
198R5;D;D.$1.Metal1;;0.488
199R6;D$1;D$1.$1.Metal1;;0.488
200R7;D$1.$1.Metal1;D$1.P0.nSD;;17.0
201R8;D.$1.Metal1;D.P0.nSD;;17.0
202R9;S;S.$1.Metal1;;0.598
203R10;S$1;S$1.$1.Metal1;;0.598
204R11;S$1.$1.Metal1;S$1.P0.nSD;;17.0
205R12;S.$1.Metal1;S.P0.nSD;;17.0""".splitlines()
208@allure.parent_suite(parent_suite)
209@allure.tag("PEX", "2.5D")
210@pytest.mark.slow
211def test_a_net_of_several_labels_has_a_port_per_label():
212 # The drain wire of the transistor has the labels D and X at its ends, so LVS has one net D,X with one pin,
213 # which the netlist replaces by the ports D and X at the labels, where the parent connects (#211 §11)
214 csv_lines, spice_lines = extract_test_pattern('nmos_metal1_redux_two_drain_labels')
215 assert csv_lines == """Device;Net1;Net2;Capacitance [fF];Resistance [Ω]
216R1;$1;$1.$0.GatPoly;;33.654
217R2;$1.$0.GatPoly;$1.$1.Metal1;;15.0
218R3;D;D,X.$1.Metal1;;0.488
219R4;D,X;D,X.$1.Metal1;;17.0
220R5;D,X.$1.Metal1;X;;0.089
221R6;S;S.$1.Metal1;;0.598
222R7;S.$1.Metal1;S.P0.nSD;;17.0""".splitlines()
223 assert subckt_ports(spice_lines) == ['D', 'X', 'S', 'sub!'] # sub!: the substrate, the bulk of the transistor
224 assert ports_touching_nothing(spice_lines) == []
227@allure.parent_suite(parent_suite)
228@allure.tag("PEX", "2.5D")
229@pytest.mark.slow
230def test_labels_on_one_node_are_tied():
231 # The labels D and X are on the same pin of the drain wire, so both ports are on one node,
232 # tied by 1 mΩ, rather than one of them touching nothing (#211 §11)
233 csv_lines, spice_lines = extract_test_pattern('nmos_metal1_redux_two_labels_one_node')
234 assert csv_lines == """Device;Net1;Net2;Capacitance [fF];Resistance [Ω]
235R1;$1;$1.$0.GatPoly;;33.654
236R2;$1.$0.GatPoly;$1.$1.Metal1;;15.0
237R3;D;D,X.$1.Metal1;;0.488
238R4;D;X;;0.001
239R5;D,X;D,X.$1.Metal1;;17.0
240R6;S;S.$1.Metal1;;0.598
241R7;S.$1.Metal1;S.P0.nSD;;17.0""".splitlines()
242 assert subckt_ports(spice_lines) == ['D', 'X', 'S', 'sub!'] # sub!: the substrate, the bulk of the transistor
243 assert ports_touching_nothing(spice_lines) == []
246@allure.parent_suite(parent_suite)
247@allure.tag("PEX", "2.5D")
248@pytest.mark.slow
249def test_mim_cap__whiteboxed():
250 # The white-box extraction keeps the MIM cap layers, so the top plate (MIM, LVS layer cmim_top) is a conductor
251 # of its net, joined to TopMetal1 by the TopVia1 on it (mim_via), 5 cuts per row (2.2 Ω / 5).
252 # It stopped with "unhandled layer purpose PURPOSE_MIM_CAP".
253 # The bottom plate (Metal5) has no network, as nothing connects to it
254 gds_path = os.path.join(TEST_DESIGNS_DIR, 'sg13g2_pr__cmim', 'cmim.gds.gz')
255 with tempfile.TemporaryDirectory() as out_dir:
256 cli = KpexCLI()
257 cli.main(['main',
258 '--pdk', PDK.IHP_SG13G2,
259 '--mode', 'R',
260 '--gds', gds_path,
261 '--out_dir', out_dir,
262 '--2.5D'])
263 results, = cli.rcx25_extraction_results.cell_extraction_results.values()
265 # NOTE: the nets have no labels, so their names ($1, $2, ...) depend on the LVS run
266 network, = [n for n in results.r_extraction_result.networks if n.elements]
267 assert {node.layer_name for node in network.nodes} == {'MIM', 'TopMetal1'}
268 assert sorted(round(r, 3) for r in results.summarize().resistances.values()) == \
269 [0.004] * 4 + [0.44] * 5 + [2.408] * 4