Coverage for tests/tech_info_test.py: 98%
170 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-2026 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/iic-jku/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
27from collections import defaultdict
28import glob
29import os
30import re
31import tempfile
32import unittest
34import allure
35import google.protobuf.json_format
37from klayout_pex.tech_info import TechDefError, TechInfo
38import klayout_pex_protobuf.kpex.tech.tech_pb2 as tech_pb2
39import klayout_pex_protobuf.kpex.tech.process_stack_pb2 as stack_pb2
42def tech_pbjson_paths() -> list[str]:
43 protobuf_dir = os.path.realpath(os.path.join(__file__, '..', '..',
44 'klayout_pex_protobuf'))
45 return sorted(glob.glob(os.path.join(protobuf_dir, '*_tech.pb.json')))
48def tech_with_duplicates() -> tech_pb2.Technology:
49 tech = tech_pb2.Technology(name='test')
51 stack = tech.process_stack
52 for name in ('met1', 'met2', 'met1'):
53 layer = stack.layers.add(name=name,
54 layer_type=stack_pb2.ProcessStackInfo.LAYER_TYPE_METAL)
55 layer.metal_layer.z = 1.0
56 layer.metal_layer.thickness = 0.1
57 # A contact shares the namespace with the layers, so this collides too.
58 stack.layers[1].metal_layer.contact_above.name = 'met2'
60 tech.layers.add(name='met1')
61 tech.layers.add(name='met1')
63 for name in ('met1_con', 'met1_con'):
64 tech.lvs_computed_layers.add().layer_info.name = name
66 for name in ('nmos', 'nmos'):
67 tech.device_models.device_model_mappings.add(lvs_device_class_name=name)
69 return tech
72@allure.parent_suite('Unit Tests')
73@allure.tag('TechInfo', 'Technology')
74class Test(unittest.TestCase):
75 def test_shipped_tech_definitions_have_unique_names(self):
76 paths = tech_pbjson_paths()
77 self.assertNotEqual([], paths, "No generated tech definition to check, "
78 "run the build first")
79 for path in paths:
80 with self.subTest(tech=os.path.basename(path)):
81 self.assertEqual([], TechInfo.duplicate_names(
82 TechInfo.parse_tech_def(path)))
84 def test_shipped_tech_definitions_have_no_unnamed_contacts(self):
85 # An unnamed contact_above still passes HasField(), and hides the via it
86 # should describe: ihp-sg13cmos5l declared one over Metal4, so TopVia1
87 # was missing from R extraction, PEX25D and FasterCap.
88 paths = tech_pbjson_paths()
89 self.assertNotEqual([], paths, "No generated tech definition to check, "
90 "run the build first")
91 for path in paths:
92 tech = TechInfo.parse_tech_def(path)
93 for lyr in tech.process_stack.layers:
94 parameters = lyr.WhichOneof('parameters')
95 layer = getattr(lyr, parameters) if parameters else None
96 # NOTE: nwell, diffusion and metal layers can carry a contact
97 if not hasattr(layer, 'contact_above') or not layer.HasField('contact_above'):
98 continue
99 with self.subTest(tech=os.path.basename(path), layer=lyr.name):
100 self.assertNotEqual('', layer.contact_above.name,
101 "contact_above is set, but has no name")
103 def test_shipped_tech_definitions_name_the_substrate(self):
104 paths = tech_pbjson_paths()
105 self.assertNotEqual([], paths, "No generated tech definition to check, "
106 "run the build first")
107 for path in paths:
108 substrate = TechInfo.parse_tech_def(path).substrate
109 with self.subTest(tech=os.path.basename(path)):
110 self.assertNotEqual([], list(substrate.net_names))
111 self.assertNotEqual([], list(substrate.lvs_layer_names))
113 def test_shipped_tech_definitions_have_wells_of_lvs_computed_layers(self):
114 # The capacitances above a well go to its net, which the extraction has on the shapes
115 # of the LVS computed layers, so the substrate would cover a well of another layer
116 paths = tech_pbjson_paths()
117 self.assertNotEqual([], paths, "No generated tech definition to check, "
118 "run the build first")
119 for path in paths:
120 tech_info = TechInfo(TechInfo.parse_tech_def(path), dielectric_filter=None)
121 well_layer_names = list(tech_info.tech.substrate.well_lvs_layer_names)
122 with self.subTest(tech=os.path.basename(path)):
123 self.assertNotEqual([], well_layer_names)
124 self.assertEqual([], [name for name in well_layer_names
125 if name not in tech_info.computed_layer_info_by_name])
127 def test_shipped_tech_definitions_declare_each_capacitance_once(self):
128 # The capacitances are looked up by layer (pair), so one declared twice
129 # keeps only the last value: ihp-sg13g2 and ihp-sg13cmos5l declared
130 # both the LV and the HV diffusion values for their single Activ layer.
131 # A repeated value is harmless, until one of the two copies gets edited.
132 paths = tech_pbjson_paths()
133 self.assertNotEqual([], paths, "No generated tech definition to check, "
134 "run the build first")
135 for path in paths:
136 cap = TechInfo.parse_tech_def(path).process_parasitics.capacitance
137 tables = {
138 'substrate': [((c.layer_name,), (c.area_capacitance, c.perimeter_capacitance))
139 for c in cap.substrates],
140 'overlap': [((c.top_layer_name, c.bottom_layer_name), c.capacitance)
141 for c in cap.overlaps],
142 'sidewall': [((c.layer_name,), (c.capacitance, c.offset))
143 for c in cap.sidewalls],
144 'side overlap': [((c.in_layer_name, c.out_layer_name), c.capacitance)
145 for c in cap.sideoverlaps],
146 }
147 for table, entries in tables.items():
148 values_by_layers = defaultdict(list)
149 for layers, value in entries:
150 values_by_layers[layers].append(value)
151 with self.subTest(tech=os.path.basename(path), table=table):
152 self.assertEqual({}, {layers: values
153 for layers, values in values_by_layers.items()
154 if len(values) > 1})
156 def test_shipped_tech_definitions_have_the_resistances_of_their_conductors_and_vias(self):
157 # The resistance extraction looks them up by their canonical layer name,
158 # and stops at a layer without one (e.g. at the top plates of the MIM caps)
159 paths = tech_pbjson_paths()
160 self.assertNotEqual([], paths, "No generated tech definition to check, "
161 "run the build first")
162 LP = tech_pb2.LayerInfo
163 LK = tech_pb2.ComputedLayerInfo
164 for path in paths:
165 tech_info = TechInfo(TechInfo.parse_tech_def(path), dielectric_filter=None)
166 missing = []
167 for lyr in tech_info.tech.lvs_computed_layers:
168 if lyr.kind in (LK.KIND_PIN, LK.KIND_LABEL):
169 continue
170 gds_pair = (lyr.layer_info.drw_gds_pair.layer, lyr.layer_info.drw_gds_pair.datatype)
171 canonical_layer_name = tech_info.canonical_layer_name_by_gds_pair[gds_pair]
172 match lyr.layer_info.purpose:
173 case LP.PURPOSE_METAL | LP.PURPOSE_MIM_CAP:
174 resistances = tech_info.layer_resistance_by_layer_name
175 case LP.PURPOSE_VIA:
176 resistances = tech_info.via_resistance_by_layer_name
177 case _:
178 continue
179 if canonical_layer_name not in resistances:
180 missing.append(f"{canonical_layer_name} (LVS {lyr.layer_info.name})")
181 with self.subTest(tech=os.path.basename(path)):
182 self.assertEqual([], missing)
184 def test_shipped_tech_definitions_whitebox_the_metal_capacitors_only(self):
185 # White-box mode removes the devices whose capacitance the extraction has from their geometry:
186 # the MIM and MOM caps (whatever their LVS device class, e.g. sky130A VPP caps with 4 terminals are MOS4),
187 # but not the MOS caps and varactors, whose capacitance (of the gate oxide) the extraction doesn't have
188 paths = tech_pbjson_paths()
189 self.assertNotEqual([], paths, "No generated tech definition to check, "
190 "run the build first")
191 for path in paths:
192 tech_info = TechInfo.from_json(path, dielectric_filter=None)
193 mim_and_mom_caps = {name for name in tech_info.device_models.mapping_by_lvs_device_class_name
194 if re.search(r'cap_(mim|cmim|cmom|vpp)|rfcmim', name)}
195 with self.subTest(tech=os.path.basename(path)):
196 self.assertNotEqual(set(), mim_and_mom_caps)
197 self.assertEqual(mim_and_mom_caps, tech_info.device_models.metal_capacitor_class_names)
199 def test_shipped_tech_definitions_have_the_resistances_of_the_wells_their_contacts_land_on(self):
200 # The contacts over the taps land on the well below (e.g. sky130A licon_ntap_con on the nwell),
201 # which the resistance extraction leaves out without a sheet resistance,
202 # so that they'd join nothing there
203 paths = tech_pbjson_paths()
204 self.assertNotEqual([], paths, "No generated tech definition to check, "
205 "run the build first")
206 LP = tech_pb2.LayerInfo
207 for path in paths:
208 tech_info = TechInfo(TechInfo.parse_tech_def(path), dielectric_filter=None)
209 missing = []
210 for contact in tech_info.contact_by_contact_lvs_layer_name.values():
211 gds_pair = tech_info.gds_pair_for_computed_layer_name.get(contact.layer_below, None) or \
212 tech_info.gds_pair_for_layer_name.get(contact.layer_below, None)
213 computed_layer_info = tech_info.computed_layer_info_by_gds_pair.get(gds_pair, None)
214 if computed_layer_info is None or \
215 computed_layer_info.layer_info.purpose not in (LP.PURPOSE_NWELL, LP.PURPOSE_PWELL):
216 continue
217 canonical_layer_name = tech_info.canonical_layer_name_by_gds_pair[gds_pair]
218 if canonical_layer_name not in tech_info.layer_resistance_by_layer_name:
219 missing.append(f"{canonical_layer_name}: sheet resistance, below {contact.name}")
220 if contact.layer_below not in tech_info.contact_resistance_by_device_layer_name:
221 missing.append(f"{contact.name}: contact resistance")
222 with self.subTest(tech=os.path.basename(path)):
223 self.assertEqual([], missing)
225 def test_shipped_tech_definitions_have_the_capacitances_to_their_diffusion(self):
226 # The capacitances are looked up by the canonical names of the extracted layers,
227 # and the diffusion is extracted as its source/drain implants (e.g. nsdm and psdm),
228 # while the tables of the magic techs have it as the drawn diffusion (e.g. diff)
229 paths = tech_pbjson_paths()
230 self.assertNotEqual([], paths, "No generated tech definition to check, "
231 "run the build first")
232 for path in paths:
233 tech_info = TechInfo(TechInfo.parse_tech_def(path), dielectric_filter=None)
235 def canonical_layer_name(layer_name: str) -> str:
236 return tech_info.canonical_layer_name_by_gds_pair[tech_info.gds_pair(layer_name)]
238 diffusion_layer_names = [canonical_layer_name(lyr.name) for lyr in tech_info.process_diffusion_layers]
239 # NOTE: but the gate poly, whose capacitances to the source/drain are the transistor's
240 metal_layer_names = [canonical_layer_name(lyr.name) for lyr in tech_info.process_metal_layers[1:]]
241 missing = [f"{metal} over {diffusion}"
242 for metal in metal_layer_names
243 for diffusion in diffusion_layer_names
244 if diffusion not in tech_info.overlap_cap_by_layer_names.get(metal, {})
245 or diffusion not in tech_info.side_overlap_cap_by_layer_names.get(metal, {})]
246 with self.subTest(tech=os.path.basename(path)):
247 self.assertNotEqual([], diffusion_layer_names)
248 self.assertEqual([], missing)
250 def test_duplicate_names_are_reported_per_namespace(self):
251 problems = TechInfo.duplicate_names(tech_with_duplicates())
252 self.assertEqual(5, len(problems), problems)
253 self.assertIn("the process stack namespace declares 'met1' 2 times", problems[0])
254 self.assertIn("the process stack namespace declares 'met2' 2 times", problems[1])
255 self.assertIn('as contact, layer', problems[1])
256 self.assertIn("the layer namespace declares 'met1' 2 times", problems[2])
257 self.assertIn("the LVS computed layer namespace declares 'met1_con' 2 times",
258 problems[3])
259 self.assertIn("the device model mapping namespace declares 'nmos' 2 times", problems[4])
261 def test_the_reader_refuses_a_duplicate(self):
262 with tempfile.TemporaryDirectory() as tmp_dir:
263 path = os.path.join(tmp_dir, 'duplicates_tech.pb.json')
264 with open(path, 'w') as f:
265 f.write(google.protobuf.json_format.MessageToJson(tech_with_duplicates()))
266 with self.assertRaises(TechDefError):
267 TechInfo.parse_tech_def(path)
269 def test_process_conductor_gds_pairs_are_from_the_bottom_up(self):
270 tech = tech_pb2.Technology(name='test')
271 stack = tech.process_stack
272 for name, layer_type in (('subs', stack_pb2.ProcessStackInfo.LAYER_TYPE_SUBSTRATE),
273 ('nsd', stack_pb2.ProcessStackInfo.LAYER_TYPE_DIFFUSION),
274 ('fox', stack_pb2.ProcessStackInfo.LAYER_TYPE_FIELD_OXIDE),
275 ('poly', stack_pb2.ProcessStackInfo.LAYER_TYPE_METAL),
276 ('met1', stack_pb2.ProcessStackInfo.LAYER_TYPE_METAL),
277 ('met1_cap', stack_pb2.ProcessStackInfo.LAYER_TYPE_METAL)):
278 stack.layers.add(name=name, layer_type=layer_type)
279 for name, gds_pair in (('nsd', (7, 0)), ('poly', (5, 0)), ('met1', (8, 0))):
280 tech.layers.add(name=name, drw_gds_pair=tech_pb2.GDSPair(layer=gds_pair[0], datatype=gds_pair[1]))
281 # NOTE: a layer of the stack can be a computed layer, and share the GDS pair of another one
282 tech.lvs_computed_layers.add(layer_info=tech_pb2.LayerInfo(name='met1_cap',
283 drw_gds_pair=tech_pb2.GDSPair(layer=8, datatype=0)))
284 self.assertEqual([(7, 0), (5, 0), (8, 0)], TechInfo(tech, dielectric_filter=None).process_conductor_gds_pairs)