Coverage for klayout_pex/pex25d/writer.py: 90%
242 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#
25"""PEX25D text format writer."""
27from __future__ import annotations
29from fractions import Fraction
30from typing import *
32from ..log import warning
34from .format_version import (
35 FORMAT_VERSION_MAJOR,
36 FORMAT_VERSION_MINOR,
37 FORMAT_VERSION_SUFFIX,
38)
39from .protobuf import pex25d_dielectric_pb2, pex25d_file_pb2, pex25d_terminal_pb2
42# Wrap a record onto continuation lines beyond this width, counting the
43# trailing continuation marker.
44WRAP_COLUMN = 96
45CONTINUATION = ' \\'
46WRAP_BUDGET = WRAP_COLUMN - len(CONTINUATION)
48LENGTH_UNIT_NAMES = {1: 'um', 2: 'nm', 3: 'm'}
51class WriteError(ValueError):
52 pass
55def format_exact(value: Fraction) -> str:
56 """
57 Render an exact rational as a decimal literal, without going through a float.
59 Every coordinate in PEX25D is an integer count of grid units, so its value in
60 the declared LENGTH unit is exactly ``value * grid`` — a rational whose
61 denominator divides the grid's. Formatting it via a float would reintroduce
62 the very representation error the integer grid exists to avoid.
63 """
64 numerator, denominator = value.numerator, value.denominator
65 sign = '-' if numerator < 0 else ''
66 numerator = abs(numerator)
68 twos = fives = 0
69 remainder = denominator
70 while remainder % 2 == 0:
71 remainder //= 2
72 twos += 1
73 while remainder % 5 == 0:
74 remainder //= 5
75 fives += 1
76 if remainder != 1:
77 # Not representable as a finite decimal. No PDK grid looks like this,
78 # but a file could ask for one, and silently rounding is worse than
79 # saying so.
80 raise WriteError(f"{value} has no finite decimal representation "
81 f"(grid denominator has a factor of {remainder})")
83 places = max(twos, fives)
84 scaled = numerator * 10 ** places // denominator
85 digits = str(scaled).rjust(places + 1, '0')
86 whole, fraction = digits[:len(digits) - places], digits[len(digits) - places:]
87 fraction = fraction.rstrip('0') or '0'
88 return f"{sign}{whole}.{fraction}"
91def format_double(value: float) -> str:
92 """Permittivities and resistances are physical values, not grid multiples."""
93 text = repr(float(value))
94 return text if ('.' in text or 'e' in text or 'E' in text) else f"{text}.0"
97def quote(value: str) -> str:
98 """Quote a META value that would otherwise tokenize as more than one token."""
99 if value and not any(c.isspace() for c in value):
100 return value
101 if '"' in value:
102 warning(f"META value contains a quote character, which PEX25D does not "
103 f"define an escape for: {value!r}")
104 return f'"{value}"'
107class Pex25DTextWriter:
108 """Renders a ``kpex.pex25d.PEX25DFile`` as PEX25D text."""
110 def __init__(self, pex25d_file: Any, comments: bool = False):
111 self.file = pex25d_file
112 self.comments = comments
113 self.lines: List[str] = []
115 units = pex25d_file.units
116 if not units.grid_denominator:
117 raise WriteError("The file declares no grid; UNITS cannot be written")
118 self.grid = Fraction(units.grid_numerator, units.grid_denominator)
120 # ---------------------------------------------------------------- helpers
122 def length(self, grid_units: int) -> str:
123 return format_exact(Fraction(grid_units) * self.grid)
125 def emit(self, line: str = '') -> None:
126 self.lines.append(line)
128 def section(self, title: str) -> None:
129 self.emit()
130 self.emit(f"#---------------- {title} ----------------")
132 def hint(self, *lines: str) -> None:
133 """Emit a syntax hint from the specification, under --comments."""
134 if not self.comments:
135 return
136 for line in lines:
137 self.emit(f"# {line}" if line else "#")
139 def emit_record(self, head: str, clauses: Sequence[str],
140 group: int = 1) -> None:
141 """
142 Emit one record, continuing onto further lines when it grows too wide.
144 Clause order within a record is significant, so clauses are never
145 reordered; only the line breaks between them are chosen here. A clause
146 that is still too wide on a line of its own — a polygon ring — is broken
147 further, on a boundary of ``group`` tokens so that coordinate pairs stay
148 together.
149 """
150 lines: List[str] = [head]
151 for clause in clauses:
152 if len(lines[-1]) + 1 + len(clause) <= WRAP_BUDGET:
153 lines[-1] += ' ' + clause
154 else:
155 lines += self.wrap_clause(clause, group)
157 for line in lines[:-1]:
158 self.emit(line + CONTINUATION)
159 self.emit(lines[-1])
161 @staticmethod
162 def wrap_clause(clause: str, group: int) -> List[str]:
163 """Break one clause across continuation lines, on `group`-token boundaries."""
164 indent = ' '
165 keyword, *tokens = clause.split(' ')
166 lines = [f"{indent}{keyword}"]
167 for start in range(0, len(tokens), group):
168 chunk = ' '.join(tokens[start:start + group])
169 if len(lines[-1]) + 1 + len(chunk) <= WRAP_BUDGET:
170 lines[-1] += ' ' + chunk
171 else:
172 lines.append(f"{indent}{' ' * len(keyword)} {chunk}")
173 return lines
175 # --------------------------------------------------------------- sections
177 def write(self) -> str:
178 self.write_header()
179 self.write_ground_plane()
180 self.write_layers()
181 self.write_dielectrics()
182 self.write_resistance()
183 self.write_conductors()
184 self.write_terminals()
185 self.write_domain()
186 return '\n'.join(self.lines).lstrip('\n') + '\n'
188 def write_header(self) -> None:
189 version = f"{self.file.format_version_major}.{self.file.format_version_minor}"
190 if self.file.format_version_suffix:
191 version += f"-{self.file.format_version_suffix}"
192 if (self.file.format_version_major, self.file.format_version_minor) != \
193 (FORMAT_VERSION_MAJOR, FORMAT_VERSION_MINOR):
194 warning(f"Writing a PEX25D {version} file from an implementation of "
195 f"{FORMAT_VERSION_MAJOR}.{FORMAT_VERSION_MINOR}")
197 self.section('HEADER')
198 self.hint("PEX25D <major>.<minor>[-<suffix>]")
199 self.emit(f"PEX25D {version}")
201 units = self.file.units
202 unit_name = LENGTH_UNIT_NAMES.get(units.length)
203 if unit_name is None:
204 raise WriteError(f"The file declares no LENGTH unit")
205 self.hint("UNITS LENGTH <um|nm|m> GRID <value>, in that unit.",
206 "Every coordinate, Z_OFFSETS, THICKNESS_* and margin is an integer",
207 "multiple of GRID.")
208 self.emit(f"UNITS LENGTH {unit_name} GRID {format_exact(self.grid)}")
210 keys = {meta.key for meta in self.file.meta}
211 if self.file.meta:
212 self.hint("",
213 "META <key> <value> is informational only, and never changes how",
214 "geometry or material is interpreted. A key may appear at most",
215 "once across the file and everything it INCLUDEs.")
216 for meta in self.file.meta:
217 self.emit(f"META {meta.key} {quote(meta.value)}")
219 # The text format has no UNITS clause for the source DBU; META carries it.
220 # Emitting it here keeps a message that has one from losing it.
221 if units.source_dbu_denominator and 'source_dbu' not in keys:
222 source_dbu = Fraction(units.source_dbu_numerator,
223 units.source_dbu_denominator)
224 self.emit(f"META source_dbu {format_exact(source_dbu)}")
226 def write_ground_plane(self) -> None:
227 if not self.file.HasField('ground_plane'):
228 warning("The file declares no GROUND_PLANE; PEX25D requires exactly one")
229 return
230 ground_plane = self.file.ground_plane
231 self.section('GROUND PLANE')
232 self.hint("GROUND_PLANE <name> Z_OFFSETS <zlow> <zhigh>",
233 "An infinite XY conductor at 0 V. There is exactly one.")
234 self.emit(f"GROUND_PLANE {ground_plane.name} "
235 f"Z_OFFSETS {self.length(ground_plane.zlow)} "
236 f"{self.length(ground_plane.zhigh)}")
238 def write_layers(self) -> None:
239 if not self.file.metals and not self.file.vias:
240 return
241 self.section('LAYERS (metal / via)')
242 self.hint("METAL <name> Z_OFFSETS <zlow> <zhigh>",
243 "GROUND_PLANE and METAL are the only records carrying absolute z.")
244 for metal in self.file.metals:
245 self.emit(f"METAL {metal.name} "
246 f"Z_OFFSETS {self.length(metal.zlow)} {self.length(metal.zhigh)}")
247 if self.file.vias:
248 self.hint("",
249 "VIA <name> CONNECTS <below> <above>",
250 "A via fills the gap between what it connects, so its extent",
251 "follows a change in metal position or thickness on its own.")
252 for via in self.file.vias:
253 self.emit(f"VIA {via.name} "
254 f"CONNECTS {via.connects_below} {via.connects_above}")
256 def write_dielectrics(self) -> None:
257 if not self.file.dielectrics and not self.file.HasField('background'):
258 return
259 kinds = pex25d_dielectric_pb2()
260 self.section('DIELECTRICS')
261 self.hint("DIELECTRIC_SIMPLE <name> WRAPS <name> PERMITTIVITY <k> \\",
262 " BETWEEN <below> <above>",
263 "DIELECTRIC_CONFORMAL <name> WRAPS <name> PERMITTIVITY <k> \\",
264 " THICKNESS_OVER_WRAPPED <v> THICKNESS_BESIDE_WRAPPED <v> \\",
265 " THICKNESS_ON_FIELD <v>",
266 "",
267 "Occupancy is decided by WRAPS depth, not by the order of these",
268 "records: at each point the dielectric with the SMALLEST depth whose",
269 "solid contains it wins, and conductors beat every dielectric.",
270 "",
271 "A conformal's thicknesses are measured from the surface of the",
272 "profile it wraps, so a chain of films composes. THICKNESS_ON_FIELD",
273 "is measured up from the BOTTOM face of the wrapped object.",
274 "",
275 "A simple dielectric spans the BOTTOM of <below> to the BOTTOM of",
276 "<above>, so the band always joins the neighbouring levels.")
278 for dielectric in self.file.dielectrics:
279 clauses = [f"WRAPS {dielectric.wraps}",
280 f"PERMITTIVITY {format_double(dielectric.permittivity)}"]
282 if dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE:
283 simple = dielectric.simple
284 clauses.append(f"BETWEEN {simple.between_below} {simple.between_above}")
285 head = f"DIELECTRIC_SIMPLE {dielectric.name}"
286 elif dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL:
287 conformal = dielectric.conformal
288 clauses += [
289 f"THICKNESS_OVER_WRAPPED {self.length(conformal.thickness_over_wrapped)}",
290 f"THICKNESS_BESIDE_WRAPPED {self.length(conformal.thickness_beside_wrapped)}",
291 f"THICKNESS_ON_FIELD {self.length(conformal.thickness_on_field)}",
292 ]
293 head = f"DIELECTRIC_CONFORMAL {dielectric.name}"
294 else:
295 raise WriteError(f"Dielectric '{dielectric.name}' has no kind")
297 self.emit_record(head, clauses)
299 if self.file.HasField('background'):
300 background = self.file.background
301 self.hint("",
302 "DIELECTRIC_BACKGROUND <name> PERMITTIVITY <k>",
303 "Fills every volume no conductor and no dielectric claims.")
304 self.emit(f"DIELECTRIC_BACKGROUND {background.name} "
305 f"PERMITTIVITY {format_double(background.permittivity)}")
306 else:
307 warning("The file declares no DIELECTRIC_BACKGROUND; PEX25D requires one")
309 def write_resistance(self) -> None:
310 has_resistance = (self.file.HasField('resistance_temperature')
311 or self.file.metal_resistances
312 or self.file.via_resistances)
313 if not has_resistance:
314 return
315 self.section('RESISTANCE')
316 self.hint("RESISTANCE TEMPERATURE <celsius>",
317 "RESISTANCE METAL <name> SHEET <ohm_per_square> [TC1 <v>] [TC2 <v>]",
318 "RESISTANCE VIA <name> PER_CUT <ohm> [TC1 <v>] [TC2 <v>]",
319 "",
320 "Ohm, and NOT grid-quantized — these are material values.")
322 if self.file.HasField('resistance_temperature'):
323 celsius = self.file.resistance_temperature.celsius
324 self.emit(f"RESISTANCE TEMPERATURE {format_double(celsius)}")
326 def temperature_coefficients(record: Any) -> List[str]:
327 if not record.HasField('tc'):
328 return []
329 return [f"TC1 {format_double(record.tc.tc1)}",
330 f"TC2 {format_double(record.tc.tc2)}"]
332 for record in self.file.metal_resistances:
333 if not record.HasField('sheet'):
334 warning(f"Metal resistance for '{record.metal}' has no SHEET value; "
335 f"skipping")
336 continue
337 self.emit_record(f"RESISTANCE METAL {record.metal}",
338 [f"SHEET {format_double(record.sheet)}"]
339 + temperature_coefficients(record))
341 for record in self.file.via_resistances:
342 if not record.HasField('per_cut'):
343 warning(f"Via resistance for '{record.via}' has no PER_CUT value; "
344 f"skipping")
345 continue
346 self.emit_record(f"RESISTANCE VIA {record.via}",
347 [f"PER_CUT {format_double(record.per_cut)}"]
348 + temperature_coefficients(record))
350 def write_conductors(self) -> None:
351 if not self.file.conductors and not self.file.shapes:
352 return
353 self.section('NETS and SHAPES')
354 self.hint("CONDUCTOR <shortname> <net>",
355 "BOX CONDUCTOR <c> LAYER <l> LL <x> <y> UR <x> <y>",
356 "POLYGON CONDUCTOR <c> LAYER <l> OUTER <x> <y> ... [HOLE <x> <y> ...]",
357 "",
358 "A conductor is one equipotential body; all its shapes are unioned.",
359 "Two conductors may share a net and stay separate bodies. The",
360 "reserved net name FLOATING marks a body belonging to no net.",
361 "",
362 "LAYER names a METAL or VIA profile. Rings are implicitly closed.",
363 "There is no via-array construct: every cut is its own record, so",
364 "that the dielectric between cuts is present in the scene.")
366 for conductor in self.file.conductors:
367 self.emit(f"CONDUCTOR {conductor.name} {conductor.net}")
369 # Grouped per conductor so the file reads in the same order it declares
370 # them; within a conductor the original order is kept.
371 order = {conductor.name: index
372 for index, conductor in enumerate(self.file.conductors)}
373 undeclared = sorted({shape.conductor for shape in self.file.shapes
374 if shape.conductor not in order})
375 if undeclared:
376 warning(f"Shapes reference conductors that are not declared: "
377 f"{', '.join(undeclared)}")
378 for name in undeclared:
379 order[name] = len(order)
381 for name in sorted(order, key=lambda n: order[n]):
382 shapes = [s for s in self.file.shapes if s.conductor == name]
383 if not shapes:
384 continue
385 self.emit()
386 for shape in shapes:
387 self.write_shape(shape)
389 def write_shape(self, shape: Any) -> None:
390 kinds = pex25d_file_pb2().ShapeRecord
392 if shape.kind == kinds.SHAPE_KIND_BOX:
393 box = shape.box
394 self.emit(f"BOX CONDUCTOR {shape.conductor} LAYER {shape.layer} "
395 f"LL {self.length(box.lower_left.x)} {self.length(box.lower_left.y)} "
396 f"UR {self.length(box.upper_right.x)} {self.length(box.upper_right.y)}")
397 return
399 if shape.kind != kinds.SHAPE_KIND_POLYGON:
400 raise WriteError(f"Shape on conductor '{shape.conductor}', layer "
401 f"'{shape.layer}' has no kind")
403 def ring(keyword: str, points: Any) -> str:
404 coordinates = ' '.join(f"{self.length(p.x)} {self.length(p.y)}"
405 for p in points)
406 return f"{keyword} {coordinates}"
408 polygon = shape.polygon
409 # Rings are implicitly closed, so the first vertex is not repeated. Each
410 # ring goes on its own continuation line — they are long, and a reader
411 # diffing two files wants them to line up.
412 clauses = [ring('OUTER', polygon.outer.points)]
413 clauses += [ring('HOLE', hole.points) for hole in polygon.holes]
414 self.emit_record(f"POLYGON CONDUCTOR {shape.conductor} LAYER {shape.layer}",
415 clauses, group=2)
417 def write_terminals(self) -> None:
418 if not self.file.terminals:
419 return
420 kind_names = {
421 pex25d_terminal_pb2().TERMINAL_KIND_PIN: 'PIN',
422 pex25d_terminal_pb2().TERMINAL_KIND_DEVICE_TERMINAL: 'DEVICE_TERMINAL',
423 }
424 self.section('TERMINALS')
425 self.hint("TERMINAL <name> CONDUCTOR <c> LAYER <l> [KIND <k>] \\",
426 " LL <x> <y> UR <x> <y>",
427 "",
428 "The terminal is the INTERSECTION of the region with the",
429 "conductor's geometry on that layer, and is an equipotential node.")
431 for terminal in self.file.terminals:
432 clauses = [f"CONDUCTOR {terminal.conductor}", f"LAYER {terminal.layer}"]
433 kind_name = kind_names.get(terminal.kind)
434 if kind_name:
435 clauses.append(f"KIND {kind_name}")
436 region = terminal.region
437 clauses.append(
438 f"LL {self.length(region.lower_left.x)} "
439 f"{self.length(region.lower_left.y)} "
440 f"UR {self.length(region.upper_right.x)} "
441 f"{self.length(region.upper_right.y)}")
442 self.emit_record(f"TERMINAL {terminal.name}", clauses)
444 def write_domain(self) -> None:
445 which = self.file.WhichOneof('domain')
446 if which is None:
447 return
448 self.section('COMPUTATIONAL DOMAIN')
449 self.hint("DOMAIN_MARGIN X <xmargin> Y <ymargin> Z <zmargin>",
450 "DOMAIN_BOX LL <x> <y> <z> UR <x> <y> <z>",
451 "",
452 "Optional solver-adapter hints. DOMAIN_BOX wins if both are given.",
453 "No lower Z margin is needed: the ground plane is the lower bound.")
455 if which == 'domain_margin':
456 margin = self.file.domain_margin
457 self.emit(f"DOMAIN_MARGIN X {self.length(margin.x)} "
458 f"Y {self.length(margin.y)} Z {self.length(margin.z)}")
459 else:
460 box = self.file.domain_box.box
461 self.emit(f"DOMAIN_BOX "
462 f"LL {self.length(box.lower_left.x)} "
463 f"{self.length(box.lower_left.y)} "
464 f"{self.length(box.lower_left.z)} "
465 f"UR {self.length(box.upper_right.x)} "
466 f"{self.length(box.upper_right.y)} "
467 f"{self.length(box.upper_right.z)}")
470def write_pex25d_text(message: Any, comments: bool = False) -> bytes:
471 """
472 Render a ``kpex.pex25d.PEX25DFile`` as PEX25D text, UTF-8 encoded.
474 :param comments: also emit the syntax hints from the specification.
475 """
476 return Pex25DTextWriter(message, comments=comments).write().encode('utf-8')