Coverage for tests/pex25d/resolver_test.py: 100%

163 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 19:08 +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 

27import allure 

28from typing import * 

29import unittest 

30 

31from klayout_pex.pex25d.diagnostics import DiagnosticsReport 

32from klayout_pex.pex25d.protobuf import pex25d_scene_pb2 

33from klayout_pex.pex25d.resolver import ResolveError, resolve 

34 

35from .pex25d_fixtures import MINIMAL, codes, read, replacing 

36 

37 

38def resolved(text: str = MINIMAL) -> Any: 

39 return resolve(read(text)) 

40 

41 

42def resolve_codes(text: str) -> list: 

43 report = DiagnosticsReport() 

44 try: 

45 resolve(read(text), report=report) 

46 except ResolveError: 

47 pass 

48 return codes(report) 

49 

50 

51def layer(scene: Any, name: str) -> Any: 

52 return next(l for l in scene.layers if l.name == name) 

53 

54 

55def dielectric(scene: Any, name: str) -> Any: 

56 return next(d for d in scene.dielectrics if d.name == name) 

57 

58 

59@allure.parent_suite("Unit Tests") 

60@allure.tag("PEX25D", "Resolver") 

61class Pex25DResolverTest(unittest.TestCase): 

62 # ---------------------------------------------------------------- z axis 

63 

64 def test_a_via_takes_its_extent_from_connects(self): 

65 scene = resolved() 

66 via = layer(scene, 'via1') 

67 assert (via.zlow, via.zhigh) == (14000, 20000) # met1 top → met2 bottom 

68 assert (via.connects_below, via.connects_above) == ('met1', 'met2') 

69 

70 def test_a_via_may_land_on_the_ground_plane(self): 

71 scene = resolved(replacing('VIA via1 CONNECTS met1 met2', 

72 'VIA via1 CONNECTS subs met1')) 

73 assert (layer(scene, 'via1').zlow, layer(scene, 'via1').zhigh) == (-1000, 10000) 

74 

75 def test_a_simple_band_spans_bottom_to_bottom(self): 

76 # BETWEEN met1 met2 is the bottom of met1 to the bottom of met2, so the 

77 # band joins the neighbouring levels with no seam. 

78 band = dielectric(resolved(), 'ild') 

79 assert (band.zlow, band.zhigh) == (10000, 20000) 

80 

81 def test_conformal_thicknesses_are_carried_in_grid_units(self): 

82 film = dielectric(resolved(), 'lint') 

83 assert film.thickness_over_wrapped == 1000 

84 assert film.thickness_beside_wrapped == 800 

85 assert film.thickness_on_field == 0 

86 assert (film.zlow, film.zhigh) == (10000, 15000) # met1, plus 0.1 over 

87 

88 # ----------------------------------------------------------- wrap chains 

89 

90 def test_wrap_depth_and_root(self): 

91 scene = resolved() 

92 assert (dielectric(scene, 'fox').wrap_depth, dielectric(scene, 'fox').root) \ 

93 == (1, 'subs') 

94 assert (dielectric(scene, 'lint').wrap_depth, dielectric(scene, 'lint').root) \ 

95 == (1, 'met1') 

96 assert (dielectric(scene, 'ild').wrap_depth, dielectric(scene, 'ild').root) \ 

97 == (2, 'met1') 

98 

99 def test_dielectrics_come_out_in_ascending_depth(self): 

100 depths = [d.wrap_depth for d in resolved().dielectrics] 

101 assert depths == sorted(depths) 

102 

103 def test_e0230_cyclic_wraps(self): 

104 assert 'PEX25D-E0230' in resolve_codes( 

105 replacing('DIELECTRIC_CONFORMAL lint WRAPS met1', 

106 'DIELECTRIC_CONFORMAL lint WRAPS ild')) 

107 

108 # -------------------------------------------------------- referential 

109 

110 def test_e0201_unknown_profile(self): 

111 assert 'PEX25D-E0201' in resolve_codes( 

112 replacing('VIA via1 CONNECTS met1 met2', 'VIA via1 CONNECTS met1 met9')) 

113 

114 def test_e0202_duplicate_profile_name(self): 

115 assert 'PEX25D-E0202' in resolve_codes( 

116 replacing('METAL met2 Z_OFFSETS 2.0 2.4', 

117 'METAL met2 Z_OFFSETS 2.0 2.4\nMETAL met2 Z_OFFSETS 3.0 3.4')) 

118 

119 def test_a_dielectric_shares_the_profile_namespace_with_the_metals(self): 

120 assert 'PEX25D-E0202' in resolve_codes( 

121 replacing('METAL met2 Z_OFFSETS 2.0 2.4', 

122 'METAL lint Z_OFFSETS 2.0 2.4')) 

123 

124 def test_e0202_a_dielectric_declared_twice(self): 

125 # Silent before: the later declaration replaced the earlier one in the 

126 # lookup and the damage showed up as some unrelated film wrapping the 

127 # wrong object. This is the sky130A 'capild' shape. 

128 assert 'PEX25D-E0202' in resolve_codes( 

129 replacing('DIELECTRIC_SIMPLE ild WRAPS lint PERMITTIVITY 4.1 BETWEEN met1 met2', 

130 'DIELECTRIC_SIMPLE ild WRAPS lint PERMITTIVITY 4.1 BETWEEN met1 met2\n' 

131 'DIELECTRIC_SIMPLE ild WRAPS met2 PERMITTIVITY 4.2 BETWEEN met1 met2')) 

132 

133 def test_e0202_the_background_is_in_the_namespace_too(self): 

134 assert 'PEX25D-E0202' in resolve_codes( 

135 replacing('DIELECTRIC_BACKGROUND air PERMITTIVITY 1.0', 

136 'DIELECTRIC_BACKGROUND met1 PERMITTIVITY 1.0')) 

137 

138 def test_e0211_metal_with_inverted_z(self): 

139 assert 'PEX25D-E0211' in resolve_codes( 

140 replacing('METAL met1 Z_OFFSETS 1.0 1.4', 'METAL met1 Z_OFFSETS 1.4 1.0')) 

141 

142 def test_e0212_connects_endpoints_out_of_order(self): 

143 assert 'PEX25D-E0212' in resolve_codes( 

144 replacing('VIA via1 CONNECTS met1 met2', 'VIA via1 CONNECTS met2 met1')) 

145 

146 def test_e0220_resistance_metal_on_a_via(self): 

147 assert 'PEX25D-E0220' in resolve_codes( 

148 replacing('RESISTANCE METAL met2 SHEET 0.125', 

149 'RESISTANCE METAL via1 SHEET 0.125')) 

150 

151 def test_e0221_resistance_via_on_a_metal(self): 

152 assert 'PEX25D-E0221' in resolve_codes( 

153 replacing('RESISTANCE VIA via1 PER_CUT 4.5', 

154 'RESISTANCE VIA met1 PER_CUT 4.5')) 

155 

156 def test_resistance_is_carried_onto_the_layer(self): 

157 scene = resolved() 

158 assert layer(scene, 'met1').WhichOneof('resistance') == 'metal_resistance' 

159 assert layer(scene, 'met1').metal_resistance.sheet == 0.125 

160 assert layer(scene, 'via1').via_resistance.per_cut == 4.5 

161 assert scene.resistance_temperature.celsius == 25.0 

162 

163 def test_a_profile_without_resistance_carries_none(self): 

164 scene = resolved(replacing('RESISTANCE VIA via1 PER_CUT 4.5\n', '')) 

165 assert layer(scene, 'via1').WhichOneof('resistance') is None 

166 

167 # ------------------------------------------------------------- terminals 

168 

169 def test_a_terminal_resolves_to_its_intersection(self): 

170 scene = resolved() 

171 terminal = scene.conductors[0].terminals[0] 

172 assert terminal.layer == 'met1' 

173 assert (terminal.zlow, terminal.zhigh) == (10000, 14000) 

174 assert len(terminal.boxes) == 1 

175 box = terminal.boxes[0] 

176 assert (box.lower_left.x, box.lower_left.y) == (0, 0) 

177 assert (box.upper_right.x, box.upper_right.y) == (2000, 5000) 

178 

179 def test_e0242_a_terminal_that_selects_nothing(self): 

180 assert 'PEX25D-E0242' in resolve_codes( 

181 replacing('TERMINAL t1 CONDUCTOR A LAYER met1 KIND PIN LL 0.0 0.0 UR 0.2 0.5', 

182 'TERMINAL t1 CONDUCTOR A LAYER met1 KIND PIN LL 9.0 9.0 UR 9.2 9.5')) 

183 

184 def test_e0240_a_terminal_that_clips_a_via_cut(self): 

185 # A cut is atomic: one lumped resistor with one face at each end. 

186 assert 'PEX25D-E0240' in resolve_codes( 

187 replacing('TERMINAL t1 CONDUCTOR A LAYER met1 KIND PIN LL 0.0 0.0 UR 0.2 0.5', 

188 'TERMINAL t1 CONDUCTOR A LAYER via1 KIND PIN LL 0.2 0.1 UR 0.3 0.2')) 

189 

190 def test_a_terminal_may_take_a_whole_cut(self): 

191 assert resolve_codes( 

192 replacing('TERMINAL t1 CONDUCTOR A LAYER met1 KIND PIN LL 0.0 0.0 UR 0.2 0.5', 

193 'TERMINAL t1 CONDUCTOR A LAYER via1 KIND PIN LL 0.1 0.0 UR 0.5 0.4')) == [] 

194 

195 def test_e0201_terminal_on_a_dielectric(self): 

196 assert 'PEX25D-E0201' in resolve_codes(replacing( 

197 'TERMINAL t1 CONDUCTOR A LAYER met1 KIND PIN LL 0.0 0.0 UR 0.2 0.5', 

198 'TERMINAL t1 CONDUCTOR A LAYER lint KIND PIN LL 0.0 0.0 UR 0.2 0.5')) 

199 

200 def test_terminal_clipping_preserves_a_polygon_hole(self): 

201 text = replacing( 

202 'BOX CONDUCTOR A LAYER met1 LL 0.0 0.0 UR 1.0 0.5', 

203 'POLYGON CONDUCTOR A LAYER met1 OUTER 0 0 1 0 1 1 0 1 ' 

204 'HOLE 0.2 0.2 0.8 0.2 0.8 0.8 0.2 0.8') 

205 text = replacing('LL 0.0 0.0 UR 0.2 0.5', 'LL 0.1 0.1 UR 0.9 0.9', text) 

206 terminal = resolved(text).conductors[0].terminals[0] 

207 area = sum((b.upper_right.x - b.lower_left.x) * 

208 (b.upper_right.y - b.lower_left.y) for b in terminal.boxes) 

209 assert area == 8000 ** 2 - 6000 ** 2 

210 assert all(b.upper_right.x <= 2000 or b.lower_left.x >= 8000 or 

211 b.upper_right.y <= 2000 or b.lower_left.y >= 8000 

212 for b in terminal.boxes) 

213 

214 def test_a_terminal_in_a_polygon_hole_selects_nothing(self): 

215 text = replacing( 

216 'BOX CONDUCTOR A LAYER met1 LL 0.0 0.0 UR 1.0 0.5', 

217 'POLYGON CONDUCTOR A LAYER met1 OUTER 0 0 1 0 1 1 0 1 ' 

218 'HOLE 0.2 0.2 0.8 0.2 0.8 0.8 0.2 0.8') 

219 text = replacing('LL 0.0 0.0 UR 0.2 0.5', 'LL 0.3 0.3 UR 0.7 0.7', text) 

220 assert 'PEX25D-E0242' in resolve_codes(text) 

221 

222 def test_clipping_can_split_a_terminal_into_disjoint_boxes(self): 

223 text = replacing( 

224 'BOX CONDUCTOR A LAYER met1 LL 0.0 0.0 UR 1.0 0.5', 

225 'POLYGON CONDUCTOR A LAYER met1 OUTER ' 

226 '0 0 1 0 1 1 0.8 1 0.8 0.2 0.2 0.2 0.2 1 0 1') 

227 text = replacing('LL 0.0 0.0 UR 0.2 0.5', 'LL 0 0.4 UR 1 0.6', text) 

228 terminal = resolved(text).conductors[0].terminals[0] 

229 assert len(terminal.boxes) == 2 

230 assert [(b.lower_left.x, b.upper_right.x) for b in terminal.boxes] \ 

231 == [(0, 2000), (8000, 10000)] 

232 

233 def test_clipping_a_polygon_via_cut_is_rejected(self): 

234 text = replacing( 

235 'BOX CONDUCTOR A LAYER via1 LL 0.2 0.1 UR 0.4 0.3', 

236 'POLYGON CONDUCTOR A LAYER via1 OUTER 0.2 0.1 0.4 0.1 0.4 0.3 0.2 0.3') 

237 text = replacing('TERMINAL t1 CONDUCTOR A LAYER met1 KIND PIN LL 0.0 0.0 UR 0.2 0.5', 

238 'TERMINAL t1 CONDUCTOR A LAYER via1 KIND PIN LL 0.2 0.1 UR 0.3 0.3', text) 

239 assert 'PEX25D-E0240' in resolve_codes(text) 

240 

241 def test_non_manhattan_clipping_is_not_silently_rounded(self): 

242 text = replacing( 

243 'BOX CONDUCTOR A LAYER met1 LL 0.0 0.0 UR 1.0 0.5', 

244 'POLYGON CONDUCTOR A LAYER met1 OUTER 0 0 1 0 0 1') 

245 assert 'PEX25D-E0241' in resolve_codes(text) 

246 

247 # ---------------------------------------------------------------- domain 

248 

249 def test_domain_margin_expands_the_geometry_bounds(self): 

250 scene = resolved() 

251 origins = pex25d_scene_pb2().ResolvedDomain 

252 assert scene.domain.origin == origins.ORIGIN_DOMAIN_MARGIN 

253 bounds = scene.domain.geometry_bounds 

254 box = scene.domain.box 

255 assert box.lower_left.x == bounds.lower_left.x - 40000 

256 assert box.upper_right.z == bounds.upper_right.z + 20000 

257 

258 def test_geometry_bounds_ignore_laterally_unbounded_materials(self): 

259 # Only conductor shapes and conformal films with THICKNESS_ON_FIELD 0 

260 # are finite in XY. A simple band is not, and must not inflate this. 

261 bounds = resolved().domain.geometry_bounds 

262 assert bounds.lower_left.x == -800 # met1 at x=0, grown by lint 

263 assert bounds.upper_right.x == 30800 # conductor B at x=3.0, plus lint 

264 assert bounds.upper_right.z == 24000 # met2 top, not the ild band top 

265 

266 def test_no_domain_record_leaves_the_domain_unspecified(self): 

267 # The message is still present — it carries geometry_bounds for an 

268 # adapter placing its own boundary — but nothing in it is a domain. 

269 origins = pex25d_scene_pb2().ResolvedDomain 

270 scene = resolved(replacing('DOMAIN_MARGIN X 4.0 Y 4.0 Z 2.0\n', '')) 

271 assert scene.domain.origin == origins.ORIGIN_UNSPECIFIED 

272 assert not scene.domain.HasField('box') 

273 assert not scene.domain.HasField('applied_margin') 

274 assert scene.domain.geometry_bounds.upper_right.z == 24000 

275 

276 def test_domain_box_is_copied_verbatim(self): 

277 scene = resolved(replacing('DOMAIN_MARGIN X 4.0 Y 4.0 Z 2.0', 

278 'DOMAIN_BOX LL -1.0 -1.0 -1.0 UR 5.0 5.0 5.0')) 

279 origins = pex25d_scene_pb2().ResolvedDomain 

280 assert scene.domain.origin == origins.ORIGIN_DOMAIN_BOX 

281 assert scene.domain.box.upper_right.x == 50000 

282 

283 # ------------------------------------------------------------- structure 

284 

285 def test_conductors_carry_their_geometry_grouped_by_layer(self): 

286 scene = resolved() 

287 conductor = scene.conductors[0] 

288 assert conductor.name == 'A' 

289 assert not conductor.floating 

290 assert {r.layer for r in conductor.regions} == {'met1', 'via1', 'met2'} 

291 

292 def test_the_reserved_net_name_marks_a_floating_body(self): 

293 scene = resolved(replacing('CONDUCTOR B netb', 'CONDUCTOR B FLOATING')) 

294 assert scene.conductors[1].floating 

295 

296 def test_resolve_error_stops_before_a_half_built_scene(self): 

297 with self.assertRaises(ResolveError): 

298 resolve(read(replacing('VIA via1 CONNECTS met1 met2', 

299 'VIA via1 CONNECTS met1 met9'))) 

300 

301 def test_the_background_never_competes_for_occupancy(self): 

302 scene = resolved() 

303 assert scene.background.name == 'air' 

304 assert 'air' not in [d.name for d in scene.dielectrics] 

305 

306 def test_an_anchor_metal_without_shapes_is_legal(self): 

307 scene = resolved(replacing('METAL met2 Z_OFFSETS 2.0 2.4', 

308 'METAL met2 Z_OFFSETS 2.0 2.4\n' 

309 'METAL diff Z_OFFSETS 0.0 0.1')) 

310 assert layer(scene, 'diff').zlow == 0