Coverage for klayout_pex/fastercap/pex25d_model_builder.py: 88%
146 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +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#
25"""
26Build FasterCap geometry from a resolved PEX25D scene.
28The occupancy rule maps onto the model generator almost for free.
29:meth:`FasterCapModelBuilder.generate` legalizes overlapping regions by the
30order materials were added, conductors ahead of every dielectric — which is
31exactly the PEX25D rule once the dielectrics are added in ascending wrap depth.
32A resolved scene already lists them in that order, so the innermost film wins
33and nothing has to be subtracted by hand.
34"""
36from __future__ import annotations
38from fractions import Fraction
39from typing import *
41import klayout.db as kdb
43from ..log import debug, info, warning
44from ..pex25d.protobuf import pex25d_dielectric_pb2, pex25d_scene_pb2
45from ..plugin_api.v1 import ExportError
46from .fastercap_model_generator import FasterCapModelBuilder, FasterCapModelGenerator
48if TYPE_CHECKING:
49 from .pex25d_exporter import FasterCapModelOptions
51 from klayout_pex_protobuf.kpex.pex25d.pex25d_scene_pb2 import PEX25DScene
54class PEX25DFasterCapModelBuilder:
55 """Convert one scene and its export settings into solver geometry."""
57 def __init__(self, scene: PEX25DScene, options: FasterCapModelOptions):
58 self.scene = scene
59 self.options = options
61 units = scene.units
62 if not units.grid_denominator:
63 raise ExportError("The scene declares no grid")
64 # Coordinates stay in grid units and the model's DBU *is* the grid, so
65 # nothing is rescaled and no precision is lost on the way out.
66 self.grid = Fraction(units.grid_numerator, units.grid_denominator)
67 self.dbu = float(self.grid)
69 self.layers = {layer.name: layer for layer in scene.layers}
70 self.dielectrics = {d.name: d for d in scene.dielectrics}
72 # ------------------------------------------------------------- geometry
74 def um(self, grid_units: int) -> float:
75 return float(Fraction(grid_units) * self.grid)
77 def shapes_of_layer(self, layer_name: str) -> kdb.Region:
78 """Every conductor's geometry on one layer, unioned across nets."""
79 region = kdb.Region()
80 for conductor in self.scene.conductors:
81 for conductor_region in conductor.regions:
82 if conductor_region.layer != layer_name:
83 continue
84 region += region_of(conductor_region)
85 return region.merged()
87 @property
88 def field(self) -> kdb.Region:
89 """
90 The lateral extent of everything unbounded in XY.
92 A DOMAIN_BOX states it; otherwise the finite geometry grown by the
93 margin, which is the same shape the FasterCap input builder has always
94 used, just derived from the scene rather than from the layout.
95 """
96 if not hasattr(self, '_field'):
97 domain = self.scene.domain
98 origins = pex25d_scene_pb2().ResolvedDomain
99 if self.scene.HasField('domain') \
100 and domain.origin != origins.ORIGIN_UNSPECIFIED:
101 box = domain.box
102 else:
103 box = domain.geometry_bounds if self.scene.HasField('domain') else None
104 if box is None or (box.lower_left.x == 0 and box.upper_right.x == 0):
105 raise ExportError(
106 "The scene has neither a domain nor finite geometry, so "
107 "there is nothing to bound the unbounded materials with")
108 margin = int(round(self.options.field_margin_um / float(self.grid)))
109 box = grow(box, margin)
110 self._field = kdb.Region(kdb.Box(box.lower_left.x, box.lower_left.y,
111 box.upper_right.x, box.upper_right.y))
112 return self._field
114 # ---------------------------------------------------------------- build
116 def build(self) -> FasterCapModelGenerator:
117 background = self.scene.background
118 if not self.scene.HasField('background'):
119 raise ExportError("The scene declares no background dielectric")
121 builder = FasterCapModelBuilder(dbu=self.dbu,
122 k_void=background.permittivity,
123 delaunay_amax=self.options.delaunay_amax,
124 delaunay_b=self.options.delaunay_b)
126 # Ascending wrap depth, which the resolver already established, so that
127 # the generator's own precedence matches the occupancy rule.
128 for dielectric in self.scene.dielectrics:
129 builder.add_material(name=dielectric.name, k=dielectric.permittivity)
131 self.add_ground_plane(builder)
132 self.add_conductors(builder)
133 self.add_dielectrics(builder)
135 generator = builder.generate()
136 if generator is None:
137 raise ExportError("The scene produced no geometry")
138 return generator
140 def add_ground_plane(self, builder: FasterCapModelBuilder):
141 if not self.scene.HasField('ground_plane'):
142 warning("The scene declares no ground plane")
143 return
144 ground_plane = self.scene.ground_plane
145 info(f"Ground plane {ground_plane.name}: "
146 f"z={self.um(ground_plane.zlow)}, "
147 f"height={self.um(ground_plane.zhigh - ground_plane.zlow)}")
148 builder.add_conductor(net_name=ground_plane.name,
149 layer=self.field,
150 z=self.um(ground_plane.zlow),
151 height=self.um(ground_plane.zhigh - ground_plane.zlow))
153 def add_conductors(self, builder: FasterCapModelBuilder):
154 for conductor in self.scene.conductors:
155 # A FLOATING conductor belongs to no net and is solved under its own
156 # shortname; everything else is reported per net.
157 name = conductor.name if conductor.floating else conductor.net
158 for conductor_region in conductor.regions:
159 layer = self.layers.get(conductor_region.layer)
160 if layer is None:
161 raise ExportError(f"Conductor '{conductor.name}' has geometry on "
162 f"'{conductor_region.layer}', which the scene "
163 f"does not declare")
164 debug(f"Conductor {name}, layer {conductor_region.layer}: "
165 f"z={self.um(layer.zlow)}, "
166 f"height={self.um(layer.zhigh - layer.zlow)}")
167 builder.add_conductor(net_name=name,
168 layer=region_of(conductor_region),
169 z=self.um(layer.zlow),
170 height=self.um(layer.zhigh - layer.zlow))
172 def add_dielectrics(self, builder: FasterCapModelBuilder):
173 kinds = pex25d_dielectric_pb2()
175 for dielectric in self.scene.dielectrics:
176 if dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE:
177 # A band spans the whole plane. The films it encloses sit at a
178 # smaller wrap depth and were added first, so the generator
179 # carves them out of it.
180 height = dielectric.zhigh - dielectric.zlow
181 if height <= 0:
182 warning(f"Simple dielectric '{dielectric.name}' has no height; "
183 f"skipping")
184 continue
185 info(f"Simple dielectric {dielectric.name}: "
186 f"z={self.um(dielectric.zlow)}, height={self.um(height)}")
187 builder.add_dielectric(material_name=dielectric.name,
188 layer=self.field,
189 z=self.um(dielectric.zlow),
190 height=self.um(height))
191 elif dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL:
192 self.add_conformal(builder, dielectric)
193 else:
194 raise ExportError(f"Dielectric '{dielectric.name}' has no kind")
196 def add_conformal(self, builder: FasterCapModelBuilder, dielectric: Any):
197 """
198 A film has up to two solids: the one grown around what it wraps, and —
199 when it covers the field — a slab over everything the root is absent
200 from. Both are measured from the bottom face of the root, which is what
201 makes a chain of films compose.
202 """
203 root = self.root_of(dielectric)
204 if root is None:
205 warning(f"Dielectric '{dielectric.name}' is rooted on "
206 f"'{dielectric.root}', which the scene does not declare; skipping")
207 return
209 footprint, zlow, zhigh = root
210 lateral, top = self.chain_extent(dielectric, zhigh)
212 if not footprint.is_empty():
213 # Like every unbounded material, a film on the ground plane ends at
214 # the field; a film on a conductor lies within it anyway.
215 grown = (footprint.sized(lateral) & self.field) if lateral else footprint
216 height = top - zlow
217 info(f"Conformal dielectric {dielectric.name}: "
218 f"z={self.um(zlow)}, height={self.um(height)}")
219 builder.add_dielectric(material_name=dielectric.name,
220 layer=grown,
221 z=self.um(zlow),
222 height=self.um(height))
223 else:
224 grown = kdb.Region()
226 if dielectric.thickness_on_field > 0:
227 info(f"Conformal dielectric {dielectric.name} (on the field): "
228 f"z={self.um(zlow)}, "
229 f"height={self.um(dielectric.thickness_on_field)}")
230 builder.add_dielectric(material_name=dielectric.name,
231 layer=self.field - grown,
232 z=self.um(zlow),
233 height=self.um(dielectric.thickness_on_field))
235 def root_of(self, dielectric: Any) -> Optional[Tuple[kdb.Region, int, int]]:
236 """
237 The footprint and z extent of the conductor layer or ground plane at the
238 root of a film's WRAPS chain. The ground plane covers the whole field.
239 """
240 layer = self.layers.get(dielectric.root)
241 if layer is not None:
242 return self.shapes_of_layer(layer.name), layer.zlow, layer.zhigh
244 ground_plane = self.scene.ground_plane
245 if self.scene.HasField('ground_plane') and dielectric.root == ground_plane.name:
246 return self.field, ground_plane.zlow, ground_plane.zhigh
247 return None
249 def chain_extent(self, dielectric: Any, root_zhigh: int) -> Tuple[int, int]:
250 """
251 Walk a film's WRAPS chain down to its root, accumulating the offsets.
253 Each film is measured from the surface of the one it wraps, so the total
254 lateral offset from the conductor is the sum along the chain, and the
255 top is the root's top plus the sum of the over-thicknesses.
256 """
257 kinds = pex25d_dielectric_pb2()
258 lateral = 0
259 over = 0
260 name: Optional[str] = dielectric.name
261 seen: Set[str] = set()
263 while name is not None and name in self.dielectrics:
264 if name in seen:
265 raise ExportError(f"WRAPS chain through '{name}' is cyclic")
266 seen.add(name)
267 film = self.dielectrics[name]
268 if film.kind == kinds.DIELECTRIC_KIND_CONFORMAL:
269 lateral += film.thickness_beside_wrapped
270 over += film.thickness_over_wrapped
271 name = film.wraps
273 return lateral, root_zhigh + over
276def region_of(conductor_region: Any) -> kdb.Region:
277 """Build a region from one conductor's shapes on one layer, in grid units."""
278 region = kdb.Region()
279 for box in conductor_region.boxes:
280 region.insert(kdb.Box(box.lower_left.x, box.lower_left.y,
281 box.upper_right.x, box.upper_right.y))
282 for polygon in conductor_region.polygons:
283 shape = kdb.Polygon([kdb.Point(p.x, p.y) for p in polygon.outer.points])
284 for hole in polygon.holes:
285 shape.insert_hole([kdb.Point(p.x, p.y) for p in hole.points])
286 region.insert(shape)
287 return region
290def grow(box: Any, margin: int) -> Any:
291 from ..pex25d.protobuf import pex25d_geometry_pb2
292 grown = pex25d_geometry_pb2().Box3D()
293 grown.lower_left.x = box.lower_left.x - margin
294 grown.lower_left.y = box.lower_left.y - margin
295 grown.lower_left.z = box.lower_left.z
296 grown.upper_right.x = box.upper_right.x + margin
297 grown.upper_right.y = box.upper_right.y + margin
298 grown.upper_right.z = box.upper_right.z
299 return grown