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

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 

25"""PEX25D text format writer.""" 

26 

27from __future__ import annotations 

28 

29from fractions import Fraction 

30from typing import * 

31 

32from ..log import warning 

33 

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 

40 

41 

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) 

47 

48LENGTH_UNIT_NAMES = {1: 'um', 2: 'nm', 3: 'm'} 

49 

50 

51class WriteError(ValueError): 

52 pass 

53 

54 

55def format_exact(value: Fraction) -> str: 

56 """ 

57 Render an exact rational as a decimal literal, without going through a float. 

58 

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) 

67 

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

82 

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

89 

90 

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" 

95 

96 

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}"' 

105 

106 

107class Pex25DTextWriter: 

108 """Renders a ``kpex.pex25d.PEX25DFile`` as PEX25D text.""" 

109 

110 def __init__(self, pex25d_file: Any, comments: bool = False): 

111 self.file = pex25d_file 

112 self.comments = comments 

113 self.lines: List[str] = [] 

114 

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) 

119 

120 # ---------------------------------------------------------------- helpers 

121 

122 def length(self, grid_units: int) -> str: 

123 return format_exact(Fraction(grid_units) * self.grid) 

124 

125 def emit(self, line: str = '') -> None: 

126 self.lines.append(line) 

127 

128 def section(self, title: str) -> None: 

129 self.emit() 

130 self.emit(f"#---------------- {title} ----------------") 

131 

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

138 

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. 

143 

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) 

156 

157 for line in lines[:-1]: 

158 self.emit(line + CONTINUATION) 

159 self.emit(lines[-1]) 

160 

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 

174 

175 # --------------------------------------------------------------- sections 

176 

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' 

187 

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

196 

197 self.section('HEADER') 

198 self.hint("PEX25D <major>.<minor>[-<suffix>]") 

199 self.emit(f"PEX25D {version}") 

200 

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

209 

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

218 

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

225 

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

237 

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

255 

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

277 

278 for dielectric in self.file.dielectrics: 

279 clauses = [f"WRAPS {dielectric.wraps}", 

280 f"PERMITTIVITY {format_double(dielectric.permittivity)}"] 

281 

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

296 

297 self.emit_record(head, clauses) 

298 

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

308 

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

321 

322 if self.file.HasField('resistance_temperature'): 

323 celsius = self.file.resistance_temperature.celsius 

324 self.emit(f"RESISTANCE TEMPERATURE {format_double(celsius)}") 

325 

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

331 

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

340 

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

349 

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

365 

366 for conductor in self.file.conductors: 

367 self.emit(f"CONDUCTOR {conductor.name} {conductor.net}") 

368 

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) 

380 

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) 

388 

389 def write_shape(self, shape: Any) -> None: 

390 kinds = pex25d_file_pb2().ShapeRecord 

391 

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 

398 

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

402 

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

407 

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) 

416 

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

430 

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) 

443 

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

454 

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

468 

469 

470def write_pex25d_text(message: Any, comments: bool = False) -> bytes: 

471 """ 

472 Render a ``kpex.pex25d.PEX25DFile`` as PEX25D text, UTF-8 encoded. 

473 

474 :param comments: also emit the syntax hints from the specification. 

475 """ 

476 return Pex25DTextWriter(message, comments=comments).write().encode('utf-8')