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