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

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# 

24 

25from __future__ import annotations 

26 

27from collections import defaultdict 

28import glob 

29import os 

30import re 

31import tempfile 

32import unittest 

33 

34import allure 

35import google.protobuf.json_format 

36 

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 

40 

41 

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'))) 

46 

47 

48def tech_with_duplicates() -> tech_pb2.Technology: 

49 tech = tech_pb2.Technology(name='test') 

50 

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' 

59 

60 tech.layers.add(name='met1') 

61 tech.layers.add(name='met1') 

62 

63 for name in ('met1_con', 'met1_con'): 

64 tech.lvs_computed_layers.add().layer_info.name = name 

65 

66 for name in ('nmos', 'nmos'): 

67 tech.device_models.device_model_mappings.add(lvs_device_class_name=name) 

68 

69 return tech 

70 

71 

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))) 

83 

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") 

102 

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)) 

112 

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]) 

126 

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}) 

155 

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) 

183 

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) 

198 

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) 

224 

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) 

234 

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)] 

237 

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) 

249 

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]) 

260 

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) 

268 

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)