Coverage for klayout_pex/pex25d/reader.py: 88%
572 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 reader."""
27from __future__ import annotations
29import os
30import re
31from dataclasses import dataclass, field
32from fractions import Fraction
33from typing import *
35from .diagnostics import Diagnostic, DiagnosticsReport, Severity, SourceRef, Tier
36from .format_version import FORMAT_VERSION_MAJOR
37from .protobuf import (
38 pex25d_dielectric_pb2,
39 pex25d_file_pb2,
40 pex25d_terminal_pb2,
41)
43STDIO_NAME = '-'
45# Decimal or scientific notation, optional leading sign, '.5' and '5.' both
46# legal. Deliberately stricter than Fraction(str), which would also accept
47# ratios like '1/2' and digit separators like '1_0'.
48NUMBER = re.compile(r'[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\Z')
50VERSION = re.compile(r'(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\Z')
52LENGTH_UNITS = {'um': 1, 'nm': 2, 'm': 3}
54# A clause keyword appearing where a top-level record is expected nearly always
55# means a dropped '\' continuation. Skipping it would silently build a different
56# scene, so it is an error rather than an unknown record.
57CLAUSE_KEYWORDS = {
58 'WRAPS', 'PERMITTIVITY', 'BETWEEN', 'CONNECTS', 'Z_OFFSETS', 'LAYER',
59 'LL', 'UR', 'OUTER', 'HOLE', 'X', 'Y', 'Z', 'SHEET', 'PER_CUT',
60 'TC1', 'TC2', 'KIND', 'TEMPERATURE',
61 'THICKNESS_OVER_WRAPPED', 'THICKNESS_BESIDE_WRAPPED', 'THICKNESS_ON_FIELD',
62}
64TERMINAL_KINDS = {'PIN': 1, 'DEVICE_TERMINAL': 2}
67class ReadError(Exception):
68 """The file could not be read; the report carries the reasons."""
71@dataclass
72class Record:
73 """One logical record: a physical line, plus whatever '\\' continued it."""
75 tokens: List[str]
76 file: str
77 line: int
79 @property
80 def head(self) -> str:
81 return self.tokens[0]
83 def ref(self) -> SourceRef:
84 return SourceRef(file=self.file, line=self.line)
87class Cursor:
88 """Walks a record's tokens, reporting what it expected when they run out."""
90 def __init__(self, record: Record, reader: 'Pex25DTextReader'):
91 self.record = record
92 self.reader = reader
93 self.position = 1 # token 0 is the record keyword
95 @property
96 def exhausted(self) -> bool:
97 return self.position >= len(self.record.tokens)
99 def peek(self) -> Optional[str]:
100 return None if self.exhausted else self.record.tokens[self.position]
102 def take(self, expected: str) -> Optional[str]:
103 if self.exhausted:
104 self.reader.error('PEX25D-E0101',
105 f"{self.record.head}: expected {expected}, but the "
106 f"record ends", self.record)
107 return None
108 value = self.record.tokens[self.position]
109 self.position += 1
110 return value
112 def keyword(self, *allowed: str) -> Optional[str]:
113 value = self.take(' or '.join(allowed))
114 if value is None:
115 return None
116 if value not in allowed:
117 self.reader.error('PEX25D-E0102',
118 f"{self.record.head}: expected {' or '.join(allowed)}, "
119 f"found '{value}'", self.record)
120 return None
121 return value
123 def grid(self, what: str) -> Optional[int]:
124 value = self.take(what)
125 return None if value is None else self.reader.to_grid(value, self.record, what)
127 def number(self, what: str) -> Optional[float]:
128 value = self.take(what)
129 return None if value is None else self.reader.to_double(value, self.record, what)
131 def end(self) -> None:
132 if not self.exhausted:
133 extra = ' '.join(self.record.tokens[self.position:])
134 self.reader.error('PEX25D-E0103',
135 f"{self.record.head}: unexpected trailing tokens "
136 f"'{extra}'", self.record)
139def split_records(text: str, filename: str) -> Iterator[Record]:
140 """
141 Turn UTF-8 text into logical records.
143 Comments run from an unquoted '#' to the end of the line; a trailing '\\'
144 continues a record onto the next line. A record's reported line is the one
145 its first token is on, which is what a diagnostic should point at.
146 """
147 pending: List[str] = []
148 start_line = 0
150 for number, raw in enumerate(text.splitlines(), start=1):
151 line, continued = strip_comment(raw)
152 if not pending:
153 start_line = number
154 pending.append(line)
155 if continued:
156 continue
158 tokens = tokenize(' '.join(pending))
159 pending = []
160 if tokens:
161 yield Record(tokens=tokens, file=filename, line=start_line)
163 if pending:
164 tokens = tokenize(' '.join(pending))
165 if tokens:
166 yield Record(tokens=tokens, file=filename, line=start_line)
169def strip_comment(line: str) -> Tuple[str, bool]:
170 """Remove an unquoted comment; report whether the record continues."""
171 out: List[str] = []
172 quoted = False
173 for char in line:
174 if char == '"':
175 quoted = not quoted
176 elif char == '#' and not quoted:
177 break
178 out.append(char)
179 text = ''.join(out).rstrip()
180 if text.endswith('\\'):
181 return text[:-1], True
182 return text, False
185def tokenize(line: str) -> List[str]:
186 """Whitespace-separated tokens, with a double-quoted run kept as one."""
187 tokens: List[str] = []
188 current: List[str] = []
189 quoted = False
190 has_token = False
192 for char in line:
193 if char == '"':
194 quoted = not quoted
195 has_token = True
196 elif char.isspace() and not quoted:
197 if has_token:
198 tokens.append(''.join(current))
199 current, has_token = [], False
200 else:
201 current.append(char)
202 has_token = True
204 if has_token:
205 tokens.append(''.join(current))
206 return tokens
209class Pex25DTextReader:
210 def __init__(self,
211 report: Optional[DiagnosticsReport] = None,
212 with_source_refs: bool = False):
213 self.report = report if report is not None else DiagnosticsReport()
214 self.with_source_refs = with_source_refs
215 self.errors = 0
217 self.file = pex25d_file_pb2().PEX25DFile()
218 self.grid: Optional[Fraction] = None
219 self.seen_header = False
220 self.meta_keys: Dict[str, str] = {}
221 self.include_stack: List[str] = []
223 # ---------------------------------------------------------- diagnostics
225 def diagnose(self, code: str, message: str, record: Optional[Record],
226 tier: Tier, severity: Severity) -> None:
227 if severity == Severity.ERROR:
228 self.errors += 1
229 self.report.add(Diagnostic(code=code, severity=severity, tier=tier,
230 message=message,
231 source=record.ref() if record else None))
233 def error(self, code: str, message: str, record: Optional[Record] = None,
234 tier: Tier = Tier.SYNTAX) -> None:
235 self.diagnose(code, message, record, tier, Severity.ERROR)
237 def warn(self, code: str, message: str, record: Optional[Record] = None,
238 tier: Tier = Tier.SYNTAX) -> None:
239 self.diagnose(code, message, record, tier, Severity.WARNING)
241 def source_of(self, record: Record) -> Optional[Any]:
242 if not self.with_source_refs:
243 return None
244 from .protobuf import pex25d_source_ref_pb2
245 ref = pex25d_source_ref_pb2().SourceRef()
246 ref.file = record.file
247 ref.line = record.line
248 return ref
250 def attach_source(self, message: Any, record: Record) -> None:
251 source = self.source_of(record)
252 if source is not None:
253 message.source.CopyFrom(source)
255 # -------------------------------------------------------------- numbers
257 def to_fraction(self, text: str, record: Record, what: str) -> Optional[Fraction]:
258 if not NUMBER.match(text):
259 self.error('PEX25D-E0104',
260 f"{what}: '{text}' is not a numeric literal", record)
261 return None
262 return Fraction(text)
264 def to_grid(self, text: str, record: Record, what: str) -> Optional[int]:
265 """
266 Convert a literal into an integer count of grid units.
268 Divide, round, compare — never a modulo. In binary floating point
269 `0.3262 % 0.0001` is `9.9999999999e-05`, and an exact-modulo check would
270 reject legal files. Here the arithmetic is exact rational anyway, and
271 the 1e-6 window is what the specification asks for.
272 """
273 if self.grid is None:
274 self.error('PEX25D-E0106',
275 f"{what}: UNITS must precede any record containing a number",
276 record)
277 return None
278 value = self.to_fraction(text, record, what)
279 if value is None:
280 return None
281 quotient = value / self.grid
282 rounded = round(quotient)
283 if abs(quotient - rounded) > Fraction(1, 10 ** 6):
284 self.error('PEX25D-E0105',
285 f"{what}: {text} is not an integer multiple of the grid",
286 record)
287 return None
288 return int(rounded)
290 def to_double(self, text: str, record: Record, what: str) -> Optional[float]:
291 """Permittivities, resistances and temperatures are not grid-quantized."""
292 value = self.to_fraction(text, record, what)
293 return None if value is None else float(value)
295 # --------------------------------------------------------------- driving
297 def read(self, data: bytes, source_name: Optional[str]) -> Any:
298 filename = source_name or STDIO_NAME
299 # The top-level file goes on the include stack too, so that a file
300 # including its way back to it is caught there rather than one level on.
301 if filename != STDIO_NAME:
302 self.include_stack.append(os.path.normpath(os.path.abspath(filename)))
303 self.consume(data.decode('utf-8'), filename, top_level=True)
305 if not self.seen_header:
306 self.error('PEX25D-E0107', "The file does not start with a PEX25D record")
307 if self.errors:
308 raise ReadError(f"{self.errors} error(s) while reading the file")
309 return self.file
311 def consume(self, text: str, filename: str, top_level: bool = False) -> None:
312 for record in split_records(text, filename):
313 if top_level and not self.seen_header and record.head != 'PEX25D':
314 self.error('PEX25D-E0107',
315 f"PEX25D must be the first record; found "
316 f"'{record.head}'", record)
317 self.seen_header = True # report it once
319 handler = self.HANDLERS.get(record.head)
320 if handler is not None:
321 handler(self, record)
322 elif record.head in CLAUSE_KEYWORDS:
323 # Almost always a dropped '\' continuation. Skipping it the way
324 # an unknown record is skipped would build a different scene.
325 self.error('PEX25D-E0103',
326 f"'{record.head}' is a clause keyword, not a record — "
327 f"a continuation '\\' was probably dropped", record)
328 else:
329 self.warn('PEX25D-W0110',
330 f"Skipping unrecognized top-level record "
331 f"'{record.head}'", record)
333 def read_include(self, record: Record) -> None:
334 cursor = Cursor(record, self)
335 path = cursor.take('a path')
336 cursor.end()
337 if path is None:
338 return
340 # Relative paths resolve against the directory of the including file;
341 # for stdin there is no such directory, so the current one is used.
342 base = os.path.dirname(os.path.abspath(record.file)) \
343 if record.file != STDIO_NAME else os.getcwd()
344 resolved = os.path.normpath(os.path.join(base, path))
346 if resolved in self.include_stack:
347 chain = ' -> '.join(self.include_stack + [resolved])
348 self.error('PEX25D-E0109', f"INCLUDE cycle: {chain}", record)
349 return
351 try:
352 with open(resolved, 'rb') as f:
353 text = f.read().decode('utf-8')
354 except OSError as e:
355 self.error('PEX25D-E0109', f"Can't read INCLUDE '{path}': {e}", record)
356 return
358 # INCLUDE is textual: the included records are treated as if they
359 # appeared in its place, which is why one PEX25DFile is a whole include
360 # tree flattened, and why SourceRef.file is load-bearing.
361 self.include_stack.append(resolved)
362 self.consume(text, resolved)
363 self.include_stack.pop()
365 # -------------------------------------------------------------- records
367 def read_header(self, record: Record) -> None:
368 if self.seen_header:
369 self.error('PEX25D-E0107', "Duplicate PEX25D record", record)
370 return
371 self.seen_header = True
373 cursor = Cursor(record, self)
374 version = cursor.take('a version')
375 cursor.end()
376 if version is None:
377 return
379 match = VERSION.match(version)
380 if match is None:
381 self.error('PEX25D-E0107',
382 f"'{version}' is not a MAJOR.MINOR[-SUFFIX] version", record)
383 return
385 major, minor, suffix = int(match.group(1)), int(match.group(2)), match.group(3)
386 if major != FORMAT_VERSION_MAJOR:
387 # A reader must accept any minor version of the major it supports,
388 # and refuse a major it does not: a major bump can change how
389 # existing keywords are interpreted.
390 self.error('PEX25D-E0107',
391 f"This implementation reads PEX25D {FORMAT_VERSION_MAJOR}.x, "
392 f"the file declares {major}.{minor}", record)
393 return
395 self.file.format_version_major = major
396 self.file.format_version_minor = minor
397 if suffix:
398 self.file.format_version_suffix = suffix
400 def read_units(self, record: Record) -> None:
401 cursor = Cursor(record, self)
402 if cursor.keyword('LENGTH') is None:
403 return
404 unit = cursor.take('um, nm or m')
405 if unit is None:
406 return
407 if unit not in LENGTH_UNITS:
408 self.error('PEX25D-E0102',
409 f"UNITS LENGTH: '{unit}' is not one of "
410 f"{', '.join(LENGTH_UNITS)}", record)
411 return
412 if cursor.keyword('GRID') is None:
413 return
414 grid_text = cursor.take('the grid')
415 cursor.end()
416 if grid_text is None:
417 return
419 grid = self.to_fraction(grid_text, record, 'UNITS GRID')
420 if grid is None:
421 return
422 if grid <= 0:
423 self.error('PEX25D-E0104', "UNITS GRID must be positive", record)
424 return
426 self.grid = grid
427 units = self.file.units
428 units.length = LENGTH_UNITS[unit]
429 units.grid_numerator = grid.numerator
430 units.grid_denominator = grid.denominator
432 def read_meta(self, record: Record) -> None:
433 cursor = Cursor(record, self)
434 key = cursor.take('a key')
435 value = cursor.take('a value')
436 cursor.end()
437 if key is None or value is None:
438 return
440 # A key may appear at most once across the file and everything it
441 # includes. Because record order is not significant, "the last one wins"
442 # would have no meaning.
443 if key in self.meta_keys:
444 self.error('PEX25D-E0108',
445 f"META key '{key}' is already set in {self.meta_keys[key]}",
446 record, tier=Tier.SEMANTIC)
447 return
448 self.meta_keys[key] = f"{record.file}:{record.line}"
450 meta = self.file.meta.add()
451 meta.key, meta.value = key, value
452 self.attach_source(meta, record)
454 # The text format has no UNITS clause for the source DBU; META carries
455 # it, and a reader fills the Units field from it.
456 if key == 'source_dbu':
457 dbu = self.to_fraction(value, record, 'META source_dbu')
458 if dbu is not None and dbu > 0:
459 self.file.units.source_dbu_numerator = dbu.numerator
460 self.file.units.source_dbu_denominator = dbu.denominator
462 def read_ground_plane(self, record: Record) -> None:
463 cursor = Cursor(record, self)
464 name = cursor.take('a name')
465 if name is None or cursor.keyword('Z_OFFSETS') is None:
466 return
467 zlow = cursor.grid('GROUND_PLANE zlow')
468 zhigh = cursor.grid('GROUND_PLANE zhigh')
469 cursor.end()
470 if zlow is None or zhigh is None:
471 return
472 if self.file.HasField('ground_plane'):
473 self.error('PEX25D-E0210', "More than one GROUND_PLANE", record,
474 tier=Tier.SEMANTIC)
475 return
476 self.file.ground_plane.name = name
477 self.file.ground_plane.zlow, self.file.ground_plane.zhigh = zlow, zhigh
478 self.attach_source(self.file.ground_plane, record)
480 def read_metal(self, record: Record) -> None:
481 cursor = Cursor(record, self)
482 name = cursor.take('a name')
483 if name is None or cursor.keyword('Z_OFFSETS') is None:
484 return
485 zlow = cursor.grid('METAL zlow')
486 zhigh = cursor.grid('METAL zhigh')
487 cursor.end()
488 if zlow is None or zhigh is None:
489 return
490 metal = self.file.metals.add()
491 metal.name, metal.zlow, metal.zhigh = name, zlow, zhigh
492 self.attach_source(metal, record)
494 def read_via(self, record: Record) -> None:
495 cursor = Cursor(record, self)
496 name = cursor.take('a name')
497 if name is None or cursor.keyword('CONNECTS') is None:
498 return
499 below = cursor.take('the layer below')
500 above = cursor.take('the layer above')
501 cursor.end()
502 if below is None or above is None:
503 return
504 via = self.file.vias.add()
505 via.name, via.connects_below, via.connects_above = name, below, above
506 self.attach_source(via, record)
508 def read_dielectric_simple(self, record: Record) -> None:
509 kinds = pex25d_dielectric_pb2()
510 cursor = Cursor(record, self)
511 name = cursor.take('a name')
512 if name is None or cursor.keyword('WRAPS') is None:
513 return
514 wraps = cursor.take('the wrapped profile')
515 if wraps is None or cursor.keyword('PERMITTIVITY') is None:
516 return
517 permittivity = cursor.number('PERMITTIVITY')
518 if permittivity is None or cursor.keyword('BETWEEN') is None:
519 return
520 below = cursor.take('the object below')
521 above = cursor.take('the object above')
522 cursor.end()
523 if below is None or above is None:
524 return
526 dielectric = self.file.dielectrics.add()
527 dielectric.name, dielectric.wraps = name, wraps
528 dielectric.kind = kinds.DIELECTRIC_KIND_SIMPLE
529 dielectric.permittivity = permittivity
530 dielectric.simple.between_below, dielectric.simple.between_above = below, above
531 self.attach_source(dielectric, record)
533 def read_dielectric_conformal(self, record: Record) -> None:
534 kinds = pex25d_dielectric_pb2()
535 cursor = Cursor(record, self)
536 name = cursor.take('a name')
537 if name is None or cursor.keyword('WRAPS') is None:
538 return
539 wraps = cursor.take('the wrapped profile')
540 if wraps is None or cursor.keyword('PERMITTIVITY') is None:
541 return
542 permittivity = cursor.number('PERMITTIVITY')
543 if permittivity is None:
544 return
546 thicknesses: Dict[str, int] = {}
547 for keyword in ('THICKNESS_OVER_WRAPPED', 'THICKNESS_BESIDE_WRAPPED',
548 'THICKNESS_ON_FIELD'):
549 if cursor.keyword(keyword) is None:
550 return
551 value = cursor.grid(keyword)
552 if value is None:
553 return
554 thicknesses[keyword] = value
555 cursor.end()
557 dielectric = self.file.dielectrics.add()
558 dielectric.name, dielectric.wraps = name, wraps
559 dielectric.kind = kinds.DIELECTRIC_KIND_CONFORMAL
560 dielectric.permittivity = permittivity
561 conformal = dielectric.conformal
562 conformal.thickness_over_wrapped = thicknesses['THICKNESS_OVER_WRAPPED']
563 conformal.thickness_beside_wrapped = thicknesses['THICKNESS_BESIDE_WRAPPED']
564 conformal.thickness_on_field = thicknesses['THICKNESS_ON_FIELD']
565 self.attach_source(dielectric, record)
567 def read_dielectric_background(self, record: Record) -> None:
568 cursor = Cursor(record, self)
569 name = cursor.take('a name')
570 if name is None or cursor.keyword('PERMITTIVITY') is None:
571 return
572 permittivity = cursor.number('PERMITTIVITY')
573 cursor.end()
574 if permittivity is None:
575 return
576 if self.file.HasField('background'):
577 self.error('PEX25D-E0213', "More than one DIELECTRIC_BACKGROUND", record,
578 tier=Tier.SEMANTIC)
579 return
580 self.file.background.name = name
581 self.file.background.permittivity = permittivity
582 self.attach_source(self.file.background, record)
584 def read_conductor(self, record: Record) -> None:
585 cursor = Cursor(record, self)
586 name = cursor.take('a shortname')
587 net = cursor.take('a net name')
588 cursor.end()
589 if name is None or net is None:
590 return
591 conductor = self.file.conductors.add()
592 conductor.name, conductor.net = name, net
593 self.attach_source(conductor, record)
595 def read_box(self, record: Record) -> None:
596 kinds = pex25d_file_pb2().ShapeRecord
597 cursor = Cursor(record, self)
598 if cursor.keyword('CONDUCTOR') is None:
599 return
600 conductor = cursor.take('a conductor shortname')
601 if conductor is None or cursor.keyword('LAYER') is None:
602 return
603 layer = cursor.take('a layer name')
604 if layer is None or cursor.keyword('LL') is None:
605 return
606 x0, y0 = cursor.grid('BOX LL x'), cursor.grid('BOX LL y')
607 if cursor.keyword('UR') is None:
608 return
609 x1, y1 = cursor.grid('BOX UR x'), cursor.grid('BOX UR y')
610 cursor.end()
611 if None in (x0, y0, x1, y1):
612 return
614 shape = self.file.shapes.add()
615 shape.conductor, shape.layer = conductor, layer
616 shape.kind = kinds.SHAPE_KIND_BOX
617 shape.box.lower_left.x, shape.box.lower_left.y = x0, y0
618 shape.box.upper_right.x, shape.box.upper_right.y = x1, y1
619 self.attach_source(shape, record)
621 def read_polygon(self, record: Record) -> None:
622 kinds = pex25d_file_pb2().ShapeRecord
623 cursor = Cursor(record, self)
624 if cursor.keyword('CONDUCTOR') is None:
625 return
626 conductor = cursor.take('a conductor shortname')
627 if conductor is None or cursor.keyword('LAYER') is None:
628 return
629 layer = cursor.take('a layer name')
630 if layer is None or cursor.keyword('OUTER') is None:
631 return
633 outer = self.read_ring(cursor, record, 'OUTER')
634 if outer is None:
635 return
636 holes = []
637 while cursor.peek() == 'HOLE':
638 cursor.take('HOLE')
639 hole = self.read_ring(cursor, record, 'HOLE')
640 if hole is None:
641 return
642 holes.append(hole)
643 cursor.end()
645 shape = self.file.shapes.add()
646 shape.conductor, shape.layer = conductor, layer
647 shape.kind = kinds.SHAPE_KIND_POLYGON
648 fill_ring(shape.polygon.outer, outer)
649 for hole in holes:
650 fill_ring(shape.polygon.holes.add(), hole)
651 self.attach_source(shape, record)
653 def read_ring(self, cursor: Cursor, record: Record,
654 keyword: str) -> Optional[List[Tuple[int, int]]]:
655 """
656 Read vertices until the next keyword or the end of the record.
658 Rings are implicitly closed, so the first vertex is not repeated.
659 """
660 points: List[Tuple[int, int]] = []
661 while not cursor.exhausted and cursor.peek() not in ('HOLE',):
662 x = cursor.grid(f"{keyword} x")
663 y = cursor.grid(f"{keyword} y")
664 if x is None or y is None:
665 return None
666 points.append((x, y))
668 if len(points) < 3:
669 self.error('PEX25D-E0101',
670 f"POLYGON {keyword}: a ring needs at least three vertices, "
671 f"found {len(points)}", record)
672 return None
673 return points
675 def read_terminal(self, record: Record) -> None:
676 cursor = Cursor(record, self)
677 name = cursor.take('a name')
678 if name is None or cursor.keyword('CONDUCTOR') is None:
679 return
680 conductor = cursor.take('a conductor shortname')
681 if conductor is None or cursor.keyword('LAYER') is None:
682 return
683 layer = cursor.take('a layer name')
684 if layer is None:
685 return
687 kind = 0
688 if cursor.peek() == 'KIND':
689 cursor.take('KIND')
690 spelling = cursor.take('a terminal kind')
691 if spelling is None:
692 return
693 if spelling not in TERMINAL_KINDS:
694 self.error('PEX25D-E0102',
695 f"TERMINAL KIND: '{spelling}' is not one of "
696 f"{', '.join(TERMINAL_KINDS)}", record)
697 return
698 kind = TERMINAL_KINDS[spelling]
700 if cursor.keyword('LL') is None:
701 return
702 x0, y0 = cursor.grid('TERMINAL LL x'), cursor.grid('TERMINAL LL y')
703 if cursor.keyword('UR') is None:
704 return
705 x1, y1 = cursor.grid('TERMINAL UR x'), cursor.grid('TERMINAL UR y')
706 cursor.end()
707 if None in (x0, y0, x1, y1):
708 return
710 terminal = self.file.terminals.add()
711 terminal.name, terminal.conductor, terminal.layer = name, conductor, layer
712 terminal.kind = kind
713 terminal.region.lower_left.x, terminal.region.lower_left.y = x0, y0
714 terminal.region.upper_right.x, terminal.region.upper_right.y = x1, y1
715 self.attach_source(terminal, record)
717 def read_resistance(self, record: Record) -> None:
718 cursor = Cursor(record, self)
719 what = cursor.keyword('TEMPERATURE', 'METAL', 'VIA')
720 if what is None:
721 return
723 if what == 'TEMPERATURE':
724 celsius = cursor.number('RESISTANCE TEMPERATURE')
725 cursor.end()
726 if celsius is None:
727 return
728 self.file.resistance_temperature.celsius = celsius
729 self.attach_source(self.file.resistance_temperature, record)
730 return
732 name = cursor.take('a profile name')
733 if name is None:
734 return
735 value_keyword = 'SHEET' if what == 'METAL' else 'PER_CUT'
736 if cursor.keyword(value_keyword) is None:
737 return
738 value = cursor.number(f"RESISTANCE {what} {value_keyword}")
739 if value is None:
740 return
742 coefficients: Dict[str, float] = {}
743 for keyword in ('TC1', 'TC2'):
744 if cursor.peek() != keyword:
745 break
746 cursor.take(keyword)
747 coefficient = cursor.number(f"RESISTANCE {what} {keyword}")
748 if coefficient is None:
749 return
750 coefficients[keyword] = coefficient
751 cursor.end()
753 if what == 'METAL':
754 resistance = self.file.metal_resistances.add()
755 resistance.metal, resistance.sheet = name, value
756 else:
757 resistance = self.file.via_resistances.add()
758 resistance.via, resistance.per_cut = name, value
759 if coefficients:
760 resistance.tc.tc1 = coefficients.get('TC1', 0.0)
761 resistance.tc.tc2 = coefficients.get('TC2', 0.0)
762 self.attach_source(resistance, record)
764 def read_domain_margin(self, record: Record) -> None:
765 cursor = Cursor(record, self)
766 values: Dict[str, int] = {}
767 for axis in ('X', 'Y', 'Z'):
768 if cursor.keyword(axis) is None:
769 return
770 value = cursor.grid(f"DOMAIN_MARGIN {axis}")
771 if value is None:
772 return
773 values[axis] = value
774 cursor.end()
775 margin = self.file.domain_margin
776 margin.x, margin.y, margin.z = values['X'], values['Y'], values['Z']
778 def read_domain_box(self, record: Record) -> None:
779 cursor = Cursor(record, self)
780 corners: Dict[str, List[int]] = {}
781 for keyword in ('LL', 'UR'):
782 if cursor.keyword(keyword) is None:
783 return
784 coordinates = []
785 for axis in 'xyz':
786 value = cursor.grid(f"DOMAIN_BOX {keyword} {axis}")
787 if value is None:
788 return
789 coordinates.append(value)
790 corners[keyword] = coordinates
791 cursor.end()
793 box = self.file.domain_box.box
794 box.lower_left.x, box.lower_left.y, box.lower_left.z = corners['LL']
795 box.upper_right.x, box.upper_right.y, box.upper_right.z = corners['UR']
796 self.attach_source(self.file.domain_box, record)
798 HANDLERS: Dict[str, Callable[['Pex25DTextReader', Record], None]] = {}
801Pex25DTextReader.HANDLERS = {
802 'PEX25D': Pex25DTextReader.read_header,
803 'UNITS': Pex25DTextReader.read_units,
804 'META': Pex25DTextReader.read_meta,
805 'INCLUDE': Pex25DTextReader.read_include,
806 'GROUND_PLANE': Pex25DTextReader.read_ground_plane,
807 'METAL': Pex25DTextReader.read_metal,
808 'VIA': Pex25DTextReader.read_via,
809 'DIELECTRIC_SIMPLE': Pex25DTextReader.read_dielectric_simple,
810 'DIELECTRIC_CONFORMAL': Pex25DTextReader.read_dielectric_conformal,
811 'DIELECTRIC_BACKGROUND': Pex25DTextReader.read_dielectric_background,
812 'CONDUCTOR': Pex25DTextReader.read_conductor,
813 'BOX': Pex25DTextReader.read_box,
814 'POLYGON': Pex25DTextReader.read_polygon,
815 'TERMINAL': Pex25DTextReader.read_terminal,
816 'RESISTANCE': Pex25DTextReader.read_resistance,
817 'DOMAIN_MARGIN': Pex25DTextReader.read_domain_margin,
818 'DOMAIN_BOX': Pex25DTextReader.read_domain_box,
819}
822def fill_ring(ring: Any, points: Sequence[Tuple[int, int]]) -> None:
823 for x, y in points:
824 point = ring.points.add()
825 point.x, point.y = x, y
828def read_pex25d_text(data: bytes,
829 source_name: Optional[str] = None,
830 report: Optional[DiagnosticsReport] = None,
831 with_source_refs: bool = False) -> Any:
832 """
833 Parse the PEX25D text format into a ``kpex.pex25d.PEX25DFile``.
835 :param data: raw UTF-8 bytes of the top-level file. ``INCLUDE`` is textual,
836 so the result covers the whole include tree, flattened.
837 :param source_name: path used for ``SourceRef.file`` and for resolving
838 relative includes; for ``-`` they resolve against the current directory.
839 :param report: collects syntax- and semantic-tier diagnostics.
840 :param with_source_refs: record where each message came from. Off by
841 default, like every other source-ref switch, so that a text-to-protobuf
842 conversion is idempotent; worth turning on when reading an include tree,
843 which is the case the field exists for. Diagnostics carry positions
844 either way.
845 :raises ReadError: when the file could not be read; the reasons are in
846 ``report``.
847 """
848 return Pex25DTextReader(report=report,
849 with_source_refs=with_source_refs).read(data, source_name)