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
« 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#
25from __future__ import annotations
27import allure
28from typing import *
29import unittest
31from klayout_pex.pex25d.diagnostics import DiagnosticsReport
32from klayout_pex.pex25d.protobuf import pex25d_scene_pb2
33from klayout_pex.pex25d.resolver import ResolveError, resolve
35from .pex25d_fixtures import MINIMAL, codes, read, replacing
38def resolved(text: str = MINIMAL) -> Any:
39 return resolve(read(text))
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)
51def layer(scene: Any, name: str) -> Any:
52 return next(l for l in scene.layers if l.name == name)
55def dielectric(scene: Any, name: str) -> Any:
56 return next(d for d in scene.dielectrics if d.name == name)
59@allure.parent_suite("Unit Tests")
60@allure.tag("PEX25D", "Resolver")
61class Pex25DResolverTest(unittest.TestCase):
62 # ---------------------------------------------------------------- z axis
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')
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)
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)
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
88 # ----------------------------------------------------------- wrap chains
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')
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)
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'))
108 # -------------------------------------------------------- referential
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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'))
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
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
167 # ------------------------------------------------------------- terminals
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)
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'))
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'))
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')) == []
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'))
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)
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)
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)]
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)
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)
247 # ---------------------------------------------------------------- domain
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
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
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
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
283 # ------------------------------------------------------------- structure
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'}
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
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')))
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]
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