Source code for jitx.issue

"""
Design issues
=============

.. admonition:: Experimental

    This API is experimental and may change significantly without notice.

The physical-design issue list ("design checks") reported by the runtime for a
submitted design. Query it with ``design.issues()`` on a runtime design, which
returns an :py:class:`IssueReport`.

Issues form a class hierarchy, so a report can be filtered by any level of it,
for example ``report.of(SignalIntegrityIssue)``. An issue of a type this
version of JITX does not know is reported as its nearest known ancestor class,
down to :py:class:`DesignIssue` itself, and always carries its text, severity,
the objects it references and any shapes.
"""

# Issue classes consume one level of the wire hierarchy each: a class names
# the IssueDetail payload case it reads via `payload=`, and `_read` pulls that
# level's fields. Classification (the forward scan) lives in
# jitx._translate.reverse_flow.issue.

from __future__ import annotations

from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from enum import IntEnum
from typing import TYPE_CHECKING, ClassVar

import jitxcore._proto.design_issues_pb2 as dipb2

from jitx.fileinfo import FileInfo
from jitx.shapes import Shape

if TYPE_CHECKING:
    from jitx._translate.reverse_flow.issue import IssueReader


[docs] class IssueCategory(IntEnum): """The runtime's issue categories. A report is partitioned by category; categories do not classify issues (use the issue classes for that).""" SIGNAL_CONSTRAINT = 1 TOPOLOGY = 2 GEOMETRY = 3 REALIZATION = 4 ROUTING = 5 LENGTH_TUNING = 6 GENERAL = 7
[docs] class Severity(IntEnum): """Severity of an issue.""" UNSPECIFIED = 0 ERROR = 1 WARNING = 2 INFO = 3
[docs] class CategoryStatus(IntEnum): """Whether a category's issues are final. ``PENDING`` means background tasks were still running when the report was taken, so the list may be incomplete.""" UNKNOWN = 0 PENDING = 1 COMPLETE = 2
[docs] @dataclass(frozen=True) class IssueObject: """A design object referenced by an issue. Attributes: role: What the object is to the issue, for example ``"pad"``, ``"pin"``, ``"route"`` or ``"route-segment"``. ref: Human-readable reference, possibly empty. instance: The Python object, or None if it could not be resolved, for example because the design was not submitted by this process. """ role: str ref: str instance: object | None
[docs] @dataclass(frozen=True) class IssueShape: """Geometry carried by an issue. Attributes: role: What the shape is to the issue, for example ``"overlap"``. shape: The shape, positioned in board coordinates. layers: Layer indices the shape applies to; empty if not layer-specific. """ role: str shape: Shape layers: tuple[int, ...]
[docs] @dataclass(frozen=True) class IssuePath: """A constrained path referenced by an issue.""" ref: str pads: tuple[IssueObject, ...] pins: tuple[IssueObject, ...] route_segments: tuple[IssueObject, ...]
[docs] @dataclass(frozen=True) class IssuePinPad: """A pin, and the pads realizing it.""" pads: tuple[IssueObject, ...] pin: IssueObject
[docs] @dataclass(frozen=True) class IssueStructure: """An active routing structure along a path.""" path: IssuePath source: FileInfo | None
[docs] @dataclass(frozen=True) class IssueIncidentStructure: """A routing structure that could apply at a point, with the path it would apply to.""" proposed_path: IssuePath structure: IssueStructure
[docs] @dataclass(frozen=True) class IssueRoute: """A route and its terminal pads.""" route: IssueObject pads: tuple[IssueObject, ...]
_REGISTRY: dict[str, type[DesignIssue]] = {}
[docs] class DesignIssue: """An issue reported by the runtime. Instances of this exact class are issues of a type this version of JITX does not recognize. Attributes: text: Human-readable description; always present. severity: The issue's severity. category: The category it was reported in, an :py:class:`IssueCategory` or, for a category this version does not know, its number. type: The runtime's name for the issue type, even when no class here matches it. For display only. source: Where in the design source the issue originates, if known. objects: Every design object the issue references. shapes: Every shape the issue carries. group: The group the issue belongs to. depth: How many levels of the runtime's hierarchy were understood. details: The raw per-level detail sequence; ``details[depth:]`` is the part this version could not interpret. """ _payload: ClassVar[str | None] = None text: str severity: Severity | int category: IssueCategory | int type: str source: FileInfo | None objects: tuple[IssueObject, ...] shapes: tuple[IssueShape, ...] group: IssueGroup depth: int details: Sequence[dipb2.IssueDetail] def __init_subclass__(cls, *, payload: str | None = None, **kwargs: object): super().__init_subclass__(**kwargs) cls._payload = payload if payload is not None: if payload in _REGISTRY: raise TypeError( f"Issue payload {payload!r} already registered by " f"{_REGISTRY[payload].__name__}" ) _REGISTRY[payload] = cls @property def complete(self) -> bool: """True if every level of the runtime's detail was understood.""" return self.depth == len(self.details) def __str__(self) -> str: return self.text def __repr__(self) -> str: return f"{type(self).__name__}({self.text!r})" def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: pass
# --- Connectivity ---
[docs] class ConnectivityIssue(DesignIssue, payload="connectivity"): """A constrained path or requested route is not (correctly) connected."""
[docs] class MissingRoute(ConnectivityIssue, payload="missing_route"): """A topology segment has no route. Attributes: path_ref: The constrained path, possibly empty. pads: Pads at the segment ends. pins: Pins at the segment ends. """ path_ref: str pads: tuple[IssueObject, ...] pins: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.missing_route self.path_ref = d.path_ref self.pads, self.pins = reader.endpoints(d.endpoints)
[docs] class DuplicateRoute(ConnectivityIssue, payload="duplicate_route"): """A topology segment has more than one route. Attributes: path_ref: The constrained path, possibly empty. pads: Pads at the segment ends. pins: Pins at the segment ends. route_segments: The competing route segments. """ path_ref: str pads: tuple[IssueObject, ...] pins: tuple[IssueObject, ...] route_segments: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.duplicate_route self.path_ref = d.path_ref self.pads, self.pins = reader.endpoints(d.endpoints) self.route_segments = reader.segments(d.route_segments)
[docs] class UnconnectedTopology(ConnectivityIssue, payload="unconnected_topology"): """The ends of a topology segment are not connected. Attributes: path_ref: The constrained path, possibly empty. pads: Pads at the segment ends. pins: Pins at the segment ends. """ path_ref: str pads: tuple[IssueObject, ...] pins: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unconnected_topology self.path_ref = d.path_ref self.pads, self.pins = reader.endpoints(d.endpoints)
[docs] class UnassignedPin(ConnectivityIssue, payload="unassigned_pin"): """A pin on a constrained path has no pin assignment. Attributes: path_ref: The constrained path, possibly empty. pin: The unassigned pin. """ path_ref: str pin: IssueObject def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unassigned_pin self.path_ref = d.path_ref self.pin = reader.stable("pin", d.pin_id, d.pin_ref)
[docs] class BlockedPath(ConnectivityIssue, payload="blocked_path"): """No path could be found for a requested route. Attributes: route: The requested route and its terminal pads. """ route: IssueRoute | None def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.blocked_path self.route = reader.route(d.route) if d.HasField("route") else None
[docs] class DroppedRoute(ConnectivityIssue, payload="dropped_route"): """A code route was dropped without being made. Attributes: route: The dropped route and its terminal pads. cause: Why it was dropped: ``"not-on-layer"`` (an end is not on the route's layer), ``"not-in-topology"`` (the ends are on one net, but its declared topology does not join them directly), ``"different-nets"``, ``"never-connectable"`` (any other reason the ends can never be joined) or ``"conflicting"`` (other connections prevent it). """ route: IssueRoute | None cause: str def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.dropped_route self.route = reader.route(d.route) if d.HasField("route") else None self.cause = d.cause
# --- Signal integrity ---
[docs] class UnmodelledVirtualConnection( ConnectivityIssue, payload="unmodelled_virtual_connection" ): """A virtual connection joins the two ends of a topology segment, so no route is expected there, but it carries no pin model and the signal check cannot time the step. Attributes: path_ref: The constrained path, possibly empty. pads: Pads at the segment ends. pins: Pins at the segment ends. """ path_ref: str pads: tuple[IssueObject, ...] pins: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unmodelled_virtual_connection self.path_ref = d.path_ref self.pads, self.pins = reader.endpoints(d.endpoints)
[docs] class SignalIntegrityIssue(DesignIssue, payload="signal_integrity"): """A signal constraint measurement. Measurements are reported for satisfied constraints too; check ``satisfied``. Attributes: measured_range: The measured ``(low, high)`` range. acceptable_range: The constraint's ``(low, high)`` range. satisfied: Whether the measurement is within the acceptable range. """ measured_range: tuple[float, float] | None acceptable_range: tuple[float, float] | None satisfied: bool def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.signal_integrity self.measured_range = ( (d.measured_range.lo, d.measured_range.hi) if d.HasField("measured_range") else None ) self.acceptable_range = ( (d.acceptable_range.lo, d.acceptable_range.hi) if d.HasField("acceptable_range") else None ) self.satisfied = d.satisfied
[docs] class TimingIssue(SignalIntegrityIssue, payload="timing"): """A timing measurement; ranges are in seconds."""
[docs] class UnmetTime(TimingIssue, payload="unmet_time"): """A delay constraint measurement along one path. Attributes: path: The measured path. """ path: IssuePath | None def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unmet_time self.path = reader.path(d.path) if d.HasField("path") else None
[docs] class UnmetTimeDiff(TimingIssue, payload="unmet_time_diff"): """A skew constraint measurement between two paths. Attributes: longer_path: The longer of the two paths. shorter_path: The shorter of the two paths. """ longer_path: IssuePath | None shorter_path: IssuePath | None def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unmet_time_diff self.longer_path = ( reader.path(d.longer_path) if d.HasField("longer_path") else None ) self.shorter_path = ( reader.path(d.shorter_path) if d.HasField("shorter_path") else None )
[docs] class LossIssue(SignalIntegrityIssue, payload="loss"): """A loss measurement; ranges are in dB."""
[docs] class UnmetLoss(LossIssue, payload="unmet_loss"): """An insertion loss constraint measurement along one path. Attributes: path: The measured path. """ path: IssuePath | None def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unmet_loss self.path = reader.path(d.path) if d.HasField("path") else None
# --- Routing structure ---
[docs] class RoutingStructureIssue(DesignIssue, payload="routing_structure"): """A problem with the routing structures or structure models of a path."""
[docs] class OverlappingStructure(RoutingStructureIssue, payload="overlapping_structure"): """Routing structures overlap along a path. Attributes: active: The structure in effect. overlaps: The structures it overlaps. """ active: IssueStructure | None overlaps: tuple[IssueStructure, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.overlapping_structure self.active = reader.structure(d.active) if d.HasField("active") else None self.overlaps = tuple(reader.structure(s) for s in d.overlaps)
[docs] class AmbiguousTouchingStructure( RoutingStructureIssue, payload="ambiguous_touching_structure" ): """Several routing structures meet at a point and it is ambiguous which applies. Attributes: common_point: Where the structures meet. alternatives: The candidate structures. """ common_point: IssuePinPad | None alternatives: tuple[IssueIncidentStructure, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.ambiguous_touching_structure self.common_point = ( reader.pin_pad(d.common_point) if d.HasField("common_point") else None ) self.alternatives = tuple(reader.incident(a) for a in d.alternatives)
[docs] class MissingRoutingStructure( RoutingStructureIssue, payload="missing_routing_structure" ): """A route segment on a constrained path has no routing structure. Attributes: path_ref: The constrained path, possibly empty. pads: Pads at the segment ends. pins: Pins at the segment ends. route_segments: The route segments without a structure. """ path_ref: str pads: tuple[IssueObject, ...] pins: tuple[IssueObject, ...] route_segments: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.missing_routing_structure self.path_ref = d.path_ref self.pads, self.pins = reader.endpoints(d.endpoints) self.route_segments = reader.segments(d.route_segments)
[docs] class MissingStructureModel(RoutingStructureIssue, payload="missing_structure_model"): """A feature on a constrained path has no structure model; a placeholder model was used. Attributes: path_ref: The constrained path, possibly empty. cause: The runtime's reason code, for example ``"NoDefForTraversedLayers"``. error_point: The feature without a model. entry_layers: Layers the path enters the feature on. exit_layers: Layers the path leaves the feature on. via_def_name: The configured via definition, if any. """ path_ref: str cause: str error_point: IssuePinPad | None entry_layers: tuple[int, ...] exit_layers: tuple[int, ...] via_def_name: str | None def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.missing_structure_model self.path_ref = d.path_ref self.cause = d.cause self.error_point = ( reader.pin_pad(d.error_point) if d.HasField("error_point") else None ) self.entry_layers = tuple(d.entry_layers) self.exit_layers = tuple(d.exit_layers) self.via_def_name = d.via_def_name if d.HasField("via_def_name") else None
# --- Realization ---
[docs] class RealizationIssue(DesignIssue, payload="realization"): """A route could not be turned into final copper."""
[docs] class UnrealizedRoute(RealizationIssue, payload="unrealized_route"): """A route could not be realized. Its geometry is in ``shapes``. Attributes: cause: Why it could not be realized. route_segments: The affected route segments. """ cause: str route_segments: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unrealized_route self.cause = d.cause self.route_segments = reader.segments(d.route_segments)
[docs] class UnsquiggledRoute(RealizationIssue, payload="unsquiggled_route"): """Length tuning could not generate a serpentine trace for a route. Attributes: route: The route and its terminal pads. cause: Why, when known, e.g. a route end shaped to its pad; empty otherwise. """ route: IssueRoute | None cause: str def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unsquiggled_route self.route = reader.route(d.route) if d.HasField("route") else None self.cause = d.cause
# --- Geometry ---
[docs] class GeometryIssue(DesignIssue, payload="geometry"): """A problem with copper geometry."""
[docs] class GeometricOverlap(GeometryIssue, payload="geometric_overlap"): """Copper overlaps where it must not. The overlapping shapes and their layers are in ``shapes``."""
# --- Text only ---
[docs] class TopologyIssue(DesignIssue, payload="topology"): """A problem in the topology definition. Subclasses carry the pins the problem is about; an instance of this exact class is described by ``text`` only."""
[docs] class AmbiguousTopology(TopologyIssue, payload="ambiguous_topology"): """The signal path crosses a net whose connection points are joined in more than one way, so the path through them cannot be inferred. Attributes: ends: The two ends of the signal constraint. net_pins: The net's connection points. """ ends: tuple[IssueObject, ...] net_pins: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.ambiguous_topology _, self.ends = reader.endpoints(d.ends) _, self.net_pins = reader.endpoints(d.net_pins)
[docs] class UnroutedTopology(TopologyIssue, payload="unrouted_topology"): """The signal path needs two points of a net that no route joins yet. Attributes: ends: The two ends of the signal constraint. gap: The two pins no route joins. """ ends: tuple[IssueObject, ...] gap: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.unrouted_topology _, self.ends = reader.endpoints(d.ends) _, self.gap = reader.endpoints(d.gap)
[docs] class MissingPinModel(TopologyIssue, payload="missing_pin_model"): """The signal path would pass through a part that has no pin model between the two pins named, so the signal cannot be followed through it. Attributes: ends: The two ends of the signal constraint. part: The two pins of the part lacking a model. """ ends: tuple[IssueObject, ...] part: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.missing_pin_model _, self.ends = reader.endpoints(d.ends) _, self.part = reader.endpoints(d.part)
[docs] class EquivalentPaths(TopologyIssue, payload="equivalent_paths"): """Two paths of equal length run between the constraint's ends. The constraint is checked over ``chosen``; ``alternative`` is the other. Reported as a warning: the constraint still resolves. Attributes: ends: The two ends of the signal constraint. chosen: The pins of the path taken, end to end. alternative: The pins of the other path, end to end. """ ends: tuple[IssueObject, ...] chosen: tuple[IssueObject, ...] alternative: tuple[IssueObject, ...] def _read(self, detail: dipb2.IssueDetail, reader: IssueReader) -> None: d = detail.equivalent_paths _, self.ends = reader.endpoints(d.ends) _, self.chosen = reader.endpoints(d.chosen) _, self.alternative = reader.endpoints(d.alternative)
[docs] class GeneralIssue(DesignIssue, payload="general"): """A general design-check message. Described by ``text`` only."""
[docs] @dataclass(eq=False) class IssueGroup: """A group of related issues, for example every issue found checking one signal constraint. Attributes: group_id: The runtime's identifier, stable for the same subject. category: The category the group was reported in. satisfied: True if the check behind the group passed. text: Human-readable summary; always present. issues: The issues in the group. """ group_id: int category: IssueCategory | int satisfied: bool text: str issues: tuple[DesignIssue, ...] = () def __iter__(self) -> Iterator[DesignIssue]: return iter(self.issues)
[docs] @dataclass(frozen=True) class CategoryEntry: """The reported state of one category.""" category: IssueCategory | int status: CategoryStatus | int groups: tuple[IssueGroup, ...]
def _category_name(category: IssueCategory | int) -> str: if isinstance(category, IssueCategory): return category.name.replace("_", " ").title() return f"Category {category}"
[docs] @dataclass(eq=False) class IssueReport: """The design issues of a design, as reported by the runtime. Iterating yields every issue. The report is true if it contains any error-severity issue, so it can be used directly as a pass/fail check. Attributes: generation: Opaque token naming the runtime's design generation the report was computed on. categories: Every reported category, including empty ones. """ generation: str categories: Mapping[IssueCategory | int, CategoryEntry] _by_instance: dict[int, list[DesignIssue]] = field(default_factory=dict, repr=False) def __post_init__(self): for issue in self: seen: set[int] = set() for ob in issue.objects: if ob.instance is not None and id(ob.instance) not in seen: seen.add(id(ob.instance)) self._by_instance.setdefault(id(ob.instance), []).append(issue) def __iter__(self) -> Iterator[DesignIssue]: for entry in self.categories.values(): for group in entry.groups: yield from group.issues def __len__(self) -> int: return sum(1 for _ in self) def __bool__(self) -> bool: return any(issue.severity == Severity.ERROR for issue in self) @property def errors(self) -> tuple[DesignIssue, ...]: """All error-severity issues.""" return tuple(i for i in self if i.severity == Severity.ERROR) @property def complete(self) -> bool: """True if every reported category is complete.""" return all( e.status == CategoryStatus.COMPLETE for e in self.categories.values() )
[docs] def groups( self, category: IssueCategory | int | None = None ) -> tuple[IssueGroup, ...]: """The groups of one category, or of all categories. Args: category: The category, or None for all. """ if category is None: return tuple(g for e in self.categories.values() for g in e.groups) entry = self.categories.get(category) return entry.groups if entry else ()
[docs] def of[T: DesignIssue](self, cls: type[T]) -> tuple[T, ...]: """Issues that are instances of ``cls``, including its subclasses. >>> report.of(SignalIntegrityIssue) """ return tuple(i for i in self if isinstance(i, cls))
[docs] def referencing(self, obj: object) -> tuple[DesignIssue, ...]: """Issues that reference ``obj`` (by identity), for example a component or pad of the submitted design.""" return tuple(self._by_instance.get(id(obj), ()))
[docs] def status(self, category: IssueCategory | int) -> CategoryStatus | int: """Status of a category; ``UNKNOWN`` if it was not reported.""" entry = self.categories.get(category) return entry.status if entry else CategoryStatus.UNKNOWN
[docs] def text(self) -> str: """A human-readable rendering of the whole report.""" lines: list[str] = [] for entry in self.categories.values(): if not entry.groups: continue status = "" if entry.status == CategoryStatus.COMPLETE else " (pending)" lines.append(f"{_category_name(entry.category)}{status}:") for group in entry.groups: lines.append(f" - {group.text}") if len(group.issues) > 1: lines.extend(f" - {issue.text}" for issue in group.issues) if not lines: return "No design issues." return "\n".join(lines)