Tighten PCB findings: actions, stitch gating, real copper.
Fill action on every finding and show it in the report. Stitch/return only on large HS/power nets. Parse stackup thickness, arcs, and vias from the board. IC courtyard pad copper is not PE-PLC-004 RULE. Recommended RC/cap stay REVIEW.
This commit is contained in:
@@ -18,7 +18,10 @@ Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"]
|
|||||||
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
|
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
|
||||||
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
|
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
|
||||||
|
|
||||||
_LLM_SOURCES = frozenset({None, "review", "pcb_review"})
|
_DEFAULT_ACTION = (
|
||||||
|
"Review this finding against the datasheet and the board, then change "
|
||||||
|
"the design if it applies."
|
||||||
|
)
|
||||||
_SOFT_PROVENANCE = frozenset({"RECOMMENDED", "TYPICAL", "EXAMPLE"})
|
_SOFT_PROVENANCE = frozenset({"RECOMMENDED", "TYPICAL", "EXAMPLE"})
|
||||||
|
|
||||||
|
|
||||||
@@ -71,17 +74,20 @@ def _seed() -> None:
|
|||||||
("PE-MUX-001", "Pin function must match the mux / datasheet pin table."),
|
("PE-MUX-001", "Pin function must match the mux / datasheet pin table."),
|
||||||
("PE-NC-001", "NC pins must not be connected."),
|
("PE-NC-001", "NC pins must not be connected."),
|
||||||
("PE-DEC-001", "Supply pin requires a decoupling capacitor on that net."),
|
("PE-DEC-001", "Supply pin requires a decoupling capacitor on that net."),
|
||||||
("PE-DEC-002", "Decoupling capacitor value vs datasheet."),
|
|
||||||
("PE-I2C-001", "I2C bus requires pull-ups."),
|
("PE-I2C-001", "I2C bus requires pull-ups."),
|
||||||
("PE-I2C-002", "I2C pull-up value vs datasheet."),
|
|
||||||
("PE-RST-001", "Reset pin requires the specified pull."),
|
("PE-RST-001", "Reset pin requires the specified pull."),
|
||||||
("PE-RST-002", "Reset pull value vs datasheet."),
|
|
||||||
("PE-BOM-001", "BOM MPN must match the netlist part."),
|
("PE-BOM-001", "BOM MPN must match the netlist part."),
|
||||||
("PE-BOM-002", "BOM line missing from the netlist."),
|
("PE-BOM-002", "BOM line missing from the netlist."),
|
||||||
("PE-DNP-001", "DNP / fitted state vs netlist."),
|
("PE-DNP-001", "DNP / fitted state vs netlist."),
|
||||||
("PE-DRT-001", "Operating voltage must not exceed the capacitor rating."),
|
("PE-DRT-001", "Operating voltage must not exceed the capacitor rating."),
|
||||||
):
|
):
|
||||||
_add(rid, "MANDATORY", "RULE", domain="schema", requirement=req)
|
_add(rid, "MANDATORY", "RULE", domain="schema", requirement=req)
|
||||||
|
for rid, req in (
|
||||||
|
("PE-DEC-002", "Decoupling capacitor value vs datasheet (recommended)."),
|
||||||
|
("PE-I2C-002", "I2C pull-up value vs datasheet (recommended)."),
|
||||||
|
("PE-RST-002", "Reset pull value vs datasheet (recommended)."),
|
||||||
|
):
|
||||||
|
_add(rid, "RECOMMENDED", "REVIEW", domain="schema", requirement=req)
|
||||||
for rid in ("PE-TH-001", "PE-TH-003"):
|
for rid in ("PE-TH-001", "PE-TH-003"):
|
||||||
_add(rid, "TYPICAL", "RISK", domain="schema",
|
_add(rid, "TYPICAL", "RISK", domain="schema",
|
||||||
requirement="Thermal estimate only with I_load and θJA from specs.")
|
requirement="Thermal estimate only with I_load and θJA from specs.")
|
||||||
@@ -93,12 +99,12 @@ def _seed() -> None:
|
|||||||
requirement="Trace width × copper thickness vs I_load (IPC-2221 when ΔT known).")
|
requirement="Trace width × copper thickness vs I_load (IPC-2221 when ΔT known).")
|
||||||
for rid, prov, cls, req in (
|
for rid, prov, cls, req in (
|
||||||
("PE-SEQ-001", "RECOMMENDED", "RISK", "Power sequencing from datasheet notes."),
|
("PE-SEQ-001", "RECOMMENDED", "RISK", "Power sequencing from datasheet notes."),
|
||||||
("PE-FLT-001", "RECOMMENDED", "RISK", "Filter topology vs datasheet."),
|
("PE-FLT-001", "RECOMMENDED", "REVIEW", "Filter topology vs datasheet."),
|
||||||
("PE-FLT-002", "RECOMMENDED", "RISK", "Filter component vs datasheet."),
|
("PE-FLT-002", "RECOMMENDED", "REVIEW", "Filter component vs datasheet."),
|
||||||
("PE-FLT-003", "RECOMMENDED", "RISK", "Filter cutoff vs datasheet."),
|
("PE-FLT-003", "RECOMMENDED", "REVIEW", "Filter cutoff vs datasheet."),
|
||||||
("PE-XTAL-001", "MANDATORY", "RULE", "Crystal load capacitance vs Cl."),
|
("PE-XTAL-001", "MANDATORY", "RULE", "Crystal load capacitance vs Cl."),
|
||||||
("PE-XTAL-002", "RECOMMENDED", "RISK", "Crystal layout / load-cap note."),
|
("PE-XTAL-002", "RECOMMENDED", "REVIEW", "Crystal layout / load-cap note."),
|
||||||
("PE-XTAL-003", "RECOMMENDED", "RISK", "Crystal drive / Cl note."),
|
("PE-XTAL-003", "RECOMMENDED", "REVIEW", "Crystal drive / Cl note."),
|
||||||
("PE-LF-001", "TYPICAL", "INFO", "Lifecycle / NRND."),
|
("PE-LF-001", "TYPICAL", "INFO", "Lifecycle / NRND."),
|
||||||
("PE-LF-002", "TYPICAL", "INFO", "Lifecycle / last-time-buy."),
|
("PE-LF-002", "TYPICAL", "INFO", "Lifecycle / last-time-buy."),
|
||||||
("PE-LF-003", "TYPICAL", "INFO", "Lifecycle / obsolete."),
|
("PE-LF-003", "TYPICAL", "INFO", "Lifecycle / obsolete."),
|
||||||
@@ -118,10 +124,11 @@ def _seed() -> None:
|
|||||||
("PE-PLC-001", "Decoupling proximity max_distance_mm from layout_rules."),
|
("PE-PLC-001", "Decoupling proximity max_distance_mm from layout_rules."),
|
||||||
("PE-PLC-002", "Thermal via min_via_count from layout_rules."),
|
("PE-PLC-002", "Thermal via min_via_count from layout_rules."),
|
||||||
("PE-PLC-003", "same_layer decoupling from layout_rules."),
|
("PE-PLC-003", "same_layer decoupling from layout_rules."),
|
||||||
("PE-PLC-004", "Keepout from layout_rules."),
|
|
||||||
("PE-SI-001", "Differential skew vs length_match mm."),
|
("PE-SI-001", "Differential skew vs length_match mm."),
|
||||||
):
|
):
|
||||||
_add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req)
|
_add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req)
|
||||||
|
_add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb",
|
||||||
|
requirement="Keepout from layout_rules — not a DRC for pad copper.")
|
||||||
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
|
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
|
||||||
requirement="Via current share from I_load and via count (no invented table).")
|
requirement="Via current share from I_load and via count (no invented table).")
|
||||||
_add("PE-THM-001", "RECOMMENDED", "RISK", domain="pcb",
|
_add("PE-THM-001", "RECOMMENDED", "RISK", domain="pcb",
|
||||||
@@ -160,8 +167,8 @@ def complete_finding(f: Finding) -> Finding:
|
|||||||
if not (f.requirement or "").strip():
|
if not (f.requirement or "").strip():
|
||||||
f.requirement = (rec.requirement if rec and rec.requirement else None) or f.why
|
f.requirement = (rec.requirement if rec and rec.requirement else None) or f.why
|
||||||
if not (f.action or "").strip():
|
if not (f.action or "").strip():
|
||||||
f.action = f.recommendation or ""
|
f.action = (f.recommendation or "").strip() or _DEFAULT_ACTION
|
||||||
if not (f.recommendation or "").strip() and f.action:
|
if not (f.recommendation or "").strip():
|
||||||
f.recommendation = f.action
|
f.recommendation = f.action
|
||||||
|
|
||||||
llm = (f.source in {None, "review", "pcb_review"}) and not f.rule_id
|
llm = (f.source in {None, "review", "pcb_review"}) and not f.rule_id
|
||||||
@@ -219,6 +226,10 @@ def complete_finding(f: Finding) -> Finding:
|
|||||||
f.why = f.requirement
|
f.why = f.requirement
|
||||||
if not (f.finding or "").strip():
|
if not (f.finding or "").strip():
|
||||||
f.finding = f.facts
|
f.finding = f.facts
|
||||||
|
if not (f.action or "").strip():
|
||||||
|
f.action = _DEFAULT_ACTION
|
||||||
|
if not (f.recommendation or "").strip():
|
||||||
|
f.recommendation = f.action
|
||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ No SI/DRC. Schema validation stays complete without this file.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
@@ -29,6 +30,24 @@ from backend.periscopex.parsers_kicad import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _copper_thickness_token(raw: object) -> float | None:
|
||||||
|
"""Parse KiCad stackup thickness; units mm/um/mil. Never default 1 oz."""
|
||||||
|
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
||||||
|
v = float(raw)
|
||||||
|
return v if v > 0 else None
|
||||||
|
s = str(raw or "").strip().lower().replace(" ", "")
|
||||||
|
m = re.match(r"^([0-9]*\.?[0-9]+)(mm|um|µm|mil)?", s)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
n = float(m.group(1))
|
||||||
|
unit = m.group(2) or "mm"
|
||||||
|
if unit in {"um", "µm"}:
|
||||||
|
n = n / 1000.0
|
||||||
|
elif unit == "mil":
|
||||||
|
n = n * 0.0254
|
||||||
|
return n if n > 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _xy(node: object, name: str) -> tuple[float, float]:
|
def _xy(node: object, name: str) -> tuple[float, float]:
|
||||||
k = _kid(node, name)
|
k = _kid(node, name)
|
||||||
if not k or len(k) < 3:
|
if not k or len(k) < 3:
|
||||||
@@ -128,7 +147,11 @@ def _parse_stackup(tree: object) -> LayoutStackup | None:
|
|||||||
name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else ""
|
name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else ""
|
||||||
kind = _layer_type(layer)
|
kind = _layer_type(layer)
|
||||||
thick = _kid(layer, "thickness")
|
thick = _kid(layer, "thickness")
|
||||||
height = _fnum(thick[1]) if thick and len(thick) > 1 else None
|
height = None
|
||||||
|
if thick and len(thick) > 1:
|
||||||
|
height = _copper_thickness_token(thick[1])
|
||||||
|
if height is None and len(thick) > 2:
|
||||||
|
height = _copper_thickness_token(f"{thick[1]}{thick[2]}")
|
||||||
if kind == "copper" or name.endswith(".Cu"):
|
if kind == "copper" or name.endswith(".Cu"):
|
||||||
if name:
|
if name:
|
||||||
copper.append(name)
|
copper.append(name)
|
||||||
@@ -147,9 +170,9 @@ def _parse_stackup(tree: object) -> LayoutStackup | None:
|
|||||||
er=er,
|
er=er,
|
||||||
height_mm=height,
|
height_mm=height,
|
||||||
))
|
))
|
||||||
if len(copper) < 2 or len(dielectrics) != len(copper) - 1:
|
|
||||||
return None
|
|
||||||
t = thicknesses[0] if thicknesses else None
|
t = thicknesses[0] if thicknesses else None
|
||||||
|
if not copper and t is None:
|
||||||
|
return None
|
||||||
return LayoutStackup(
|
return LayoutStackup(
|
||||||
copper_layers=copper,
|
copper_layers=copper,
|
||||||
dielectrics=dielectrics,
|
dielectrics=dielectrics,
|
||||||
@@ -291,6 +314,15 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
|
|||||||
net=_net_name(node, nets),
|
net=_net_name(node, nets),
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
|
if tag == "arc":
|
||||||
|
segments.append(LayoutSegment(
|
||||||
|
start=_xy(node, "start"),
|
||||||
|
end=_xy(node, "end"),
|
||||||
|
width=_fnum(_val(node, "width") or 0),
|
||||||
|
layer=_val(node, "layer"),
|
||||||
|
net=_net_name(node, nets),
|
||||||
|
))
|
||||||
|
continue
|
||||||
if tag == "via":
|
if tag == "via":
|
||||||
drill_el = _kid(node, "drill")
|
drill_el = _kid(node, "drill")
|
||||||
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
|
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ _IPC_K_EXT = 0.048
|
|||||||
_IPC_K_INT = 0.024
|
_IPC_K_INT = 0.024
|
||||||
_TJMAX_KEYS = ("tj_max", "tj_max_c", "tjmax", "max_junction_temp_c", "t_jmax")
|
_TJMAX_KEYS = ("tj_max", "tj_max_c", "tjmax", "max_junction_temp_c", "t_jmax")
|
||||||
_SENSE_RE = re.compile(r"(kelvin|sense|isns|i_sns|cs\+|cs-|iout_sns)", re.I)
|
_SENSE_RE = re.compile(r"(kelvin|sense|isns|i_sns|cs\+|cs-|iout_sns)", re.I)
|
||||||
|
_HS_NET_RE = re.compile(
|
||||||
|
r"(USB|DP|DM|D\+|D-|HS|HDMI|MIPI|DDR|CLK|XTAL|HFX|LFX|SWP|DIFF)",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_STITCH_MIN_SPAN_MM = 12.0
|
||||||
|
_STITCH_MIN_AREA_MM2 = 40.0
|
||||||
|
|
||||||
|
|
||||||
def _mm_to_mil(mm: float) -> float:
|
def _mm_to_mil(mm: float) -> float:
|
||||||
@@ -75,6 +81,17 @@ def _via_stats(layout: LayoutGraph, sch_net: str) -> tuple[int, float | None]:
|
|||||||
return n, (min(drills) if drills else None)
|
return n, (min(drills) if drills else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _footprint_region(fp) -> list[tuple[float, float]]:
|
||||||
|
if len(fp.courtyard) >= 3:
|
||||||
|
return fp.courtyard
|
||||||
|
xs = [fp.x] + [p.x for p in fp.pads]
|
||||||
|
ys = [fp.y] + [p.y for p in fp.pads]
|
||||||
|
pad = 1.5
|
||||||
|
xmin, xmax = min(xs) - pad, max(xs) + pad
|
||||||
|
ymin, ymax = min(ys) - pad, max(ys) + pad
|
||||||
|
return [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)]
|
||||||
|
|
||||||
|
|
||||||
def _external_layers(layers: list[str]) -> bool:
|
def _external_layers(layers: list[str]) -> bool:
|
||||||
if not layers:
|
if not layers:
|
||||||
return True
|
return True
|
||||||
@@ -223,28 +240,36 @@ def check_pcb_via_current(
|
|||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
n, drill = _via_stats(layout, net)
|
n, _drill = _via_stats(layout, net)
|
||||||
if n <= 0:
|
if n > 0:
|
||||||
|
# Geometry is present; no via-ampacity table in the library — do not
|
||||||
|
# invent a rating or claim the vias were not parsed.
|
||||||
continue
|
continue
|
||||||
per = i_load / n
|
|
||||||
out.append(Finding(
|
out.append(Finding(
|
||||||
designator=ref,
|
designator=ref,
|
||||||
mpn=comp.mpn or "",
|
mpn=comp.mpn or "",
|
||||||
aspect="layout_power",
|
aspect="layout_power",
|
||||||
finding=(
|
finding=(
|
||||||
f"{net} shares I_load={i_load:.3g} A across {n} via(s) "
|
f"{net} carries I_load={i_load:.3g} A with no vias on that net "
|
||||||
f"(≈{per:.3g} A each"
|
"in the parsed .kicad_pcb."
|
||||||
+ (f", min drill {drill:g} mm" if drill else "")
|
|
||||||
+ "). No via current rating in the library extraction."
|
|
||||||
),
|
),
|
||||||
why="Via current is reported from board geometry + datasheet I_load only.",
|
why="Via count is taken from board vias whose net matches this supply.",
|
||||||
status="INFO",
|
status="INFO",
|
||||||
recommendation=(
|
recommendation=(
|
||||||
"If the datasheet or stackup vendor quotes via current, add more/larger vias "
|
f"Add vias on {net} if the current leaves this layer, then re-run PCB review."
|
||||||
f"so each via stays under that rating at {i_load:.3g} A."
|
),
|
||||||
|
action=(
|
||||||
|
f"Add vias on {net} if the current leaves this layer, then re-run PCB review."
|
||||||
),
|
),
|
||||||
source="pcb_power_thermal",
|
source="pcb_power_thermal",
|
||||||
rule_id="PE-VIA-001",
|
rule_id="PE-VIA-001",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
facts=(
|
||||||
|
f"I_load={i_load:.3g} A on {net}; 0 vias with a matching net "
|
||||||
|
f"in the PCB file (board has {len(layout.vias)} via(s) total)."
|
||||||
|
),
|
||||||
|
requirement="No datasheet via current; report missing vias only.",
|
||||||
|
inference="Ampacity of vias is not judged without a rating.",
|
||||||
net=net,
|
net=net,
|
||||||
pins=[],
|
pins=[],
|
||||||
))
|
))
|
||||||
@@ -276,12 +301,15 @@ def check_pcb_thermal_copper(
|
|||||||
continue
|
continue
|
||||||
p = i_load * (vin - vout)
|
p = i_load * (vin - vout)
|
||||||
fp = layout.footprints.get(ref)
|
fp = layout.footprints.get(ref)
|
||||||
if not fp or len(fp.courtyard) < 3:
|
if not fp:
|
||||||
|
continue
|
||||||
|
region = _footprint_region(fp)
|
||||||
|
if len(region) < 3:
|
||||||
continue
|
continue
|
||||||
theta = _first(values, _THETA_KEYS)
|
theta = _first(values, _THETA_KEYS)
|
||||||
via_n = 0
|
via_n = 0
|
||||||
for v in layout.vias:
|
for v in layout.vias:
|
||||||
if _in_poly(v.x, v.y, fp.courtyard):
|
if _in_poly(v.x, v.y, region):
|
||||||
via_n += 1
|
via_n += 1
|
||||||
pour = False
|
pour = False
|
||||||
for z in layout.zones:
|
for z in layout.zones:
|
||||||
@@ -296,7 +324,6 @@ def check_pcb_thermal_copper(
|
|||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# GND by name
|
|
||||||
leaf = normalize_kicad_hierarchy_net(z.net).upper()
|
leaf = normalize_kicad_hierarchy_net(z.net).upper()
|
||||||
if not (leaf == "GND" or leaf.endswith("/GND") or "GND" in leaf):
|
if not (leaf == "GND" or leaf.endswith("/GND") or "GND" in leaf):
|
||||||
continue
|
continue
|
||||||
@@ -306,26 +333,39 @@ def check_pcb_thermal_copper(
|
|||||||
break
|
break
|
||||||
if via_n or pour:
|
if via_n or pour:
|
||||||
continue
|
continue
|
||||||
|
rec = (
|
||||||
|
"Add a copper pour and/or thermal vias under the package as the datasheet "
|
||||||
|
"layout page specifies, then re-run PCB review."
|
||||||
|
)
|
||||||
out.append(Finding(
|
out.append(Finding(
|
||||||
designator=ref,
|
designator=ref,
|
||||||
mpn=comp.mpn or "",
|
mpn=comp.mpn or "",
|
||||||
aspect="layout_thermal",
|
aspect="layout_thermal",
|
||||||
finding=(
|
finding=(
|
||||||
f"{ref} dissipates ≈{p:.3g} W (I_load={i_load:.3g} A) but the courtyard "
|
f"{ref} dissipates ≈{p:.3g} W (I_load={i_load:.3g} A) but the package "
|
||||||
"has no thermal vias and no overlapping copper pour."
|
f"region has no thermal vias ({via_n}) and no overlapping copper pour "
|
||||||
|
f"(board vias parsed: {len(layout.vias)})."
|
||||||
),
|
),
|
||||||
|
facts=(
|
||||||
|
f"P≈{p:.3g} W; {via_n} vias in package region; pour={pour}; "
|
||||||
|
f"{len(layout.vias)} vias on the board."
|
||||||
|
),
|
||||||
|
requirement=(
|
||||||
|
"Datasheet layout notes for copper/vias under the package "
|
||||||
|
"(recommended, not a shall)."
|
||||||
|
),
|
||||||
|
inference="No pour/vias under the package on the parsed board.",
|
||||||
why=(
|
why=(
|
||||||
"P = I_load×(Vin−Vout) from the shared extraction/specs. "
|
"P = I_load×(Vin−Vout) from the shared extraction/specs. "
|
||||||
+ (f"θJA={theta:.3g} °C/W. " if theta else "θJA missing. ")
|
+ (f"θJA={theta:.3g} °C/W. " if theta else "θJA missing. ")
|
||||||
+ "No millimetres invented."
|
+ "No millimetres invented."
|
||||||
),
|
),
|
||||||
status="WARNING",
|
status="WARNING",
|
||||||
recommendation=(
|
recommendation=rec,
|
||||||
"Add a copper pour and/or thermal vias under the package as the datasheet "
|
action=rec,
|
||||||
"layout page specifies, then re-run PCB review."
|
|
||||||
),
|
|
||||||
source="pcb_power_thermal",
|
source="pcb_power_thermal",
|
||||||
rule_id="PE-THM-001",
|
rule_id="PE-THM-001",
|
||||||
|
evidence_status="SUFFICIENT" if layout.vias or layout.zones else "INSUFFICIENT",
|
||||||
net=vout_n,
|
net=vout_n,
|
||||||
pins=[],
|
pins=[],
|
||||||
))
|
))
|
||||||
@@ -337,11 +377,24 @@ def _is_gnd_name(name: str) -> bool:
|
|||||||
return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND")
|
return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stitch_candidate(net_name: str, net, dx: float, dy: float) -> bool:
|
||||||
|
"""High-speed / power nets, or a large bbox — never tiny GPIO stubs."""
|
||||||
|
span = max(dx, dy)
|
||||||
|
area = dx * dy
|
||||||
|
if span < _STITCH_MIN_SPAN_MM or area < _STITCH_MIN_AREA_MM2:
|
||||||
|
return False
|
||||||
|
if net.net_type == NetType.POWER:
|
||||||
|
return True
|
||||||
|
if _HS_NET_RE.search(net_name or ""):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_pcb_gnd_stitch(
|
def check_pcb_gnd_stitch(
|
||||||
graph: DesignGraph,
|
graph: DesignGraph,
|
||||||
layout: LayoutGraph | None,
|
layout: LayoutGraph | None,
|
||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
"""INFO when a signal span has a GND pour but no GND via in its bbox."""
|
"""INFO when a long HS/power span has a GND pour but no GND via in its bbox."""
|
||||||
if layout is None:
|
if layout is None:
|
||||||
return []
|
return []
|
||||||
gnd_zones = [
|
gnd_zones = [
|
||||||
@@ -357,7 +410,7 @@ def check_pcb_gnd_stitch(
|
|||||||
out: list[Finding] = []
|
out: list[Finding] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for net_name, net in sorted(graph.nets.items()):
|
for net_name, net in sorted(graph.nets.items()):
|
||||||
if net.net_type != NetType.SIGNAL:
|
if net.net_type not in (NetType.SIGNAL, NetType.POWER):
|
||||||
continue
|
continue
|
||||||
key = normalize_kicad_hierarchy_net(net_name)
|
key = normalize_kicad_hierarchy_net(net_name)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
@@ -375,30 +428,39 @@ def check_pcb_gnd_stitch(
|
|||||||
ys.extend([s.start[1], s.end[1]])
|
ys.extend([s.start[1], s.end[1]])
|
||||||
xmin, xmax = min(xs), max(xs)
|
xmin, xmax = min(xs), max(xs)
|
||||||
ymin, ymax = min(ys), max(ys)
|
ymin, ymax = min(ys), max(ys)
|
||||||
if xmax - xmin < 2.0 and ymax - ymin < 2.0:
|
dx, dy = xmax - xmin, ymax - ymin
|
||||||
|
if not _is_stitch_candidate(net_name, net, dx, dy):
|
||||||
continue
|
continue
|
||||||
if any(xmin <= v.x <= xmax and ymin <= v.y <= ymax for v in gnd_vias):
|
if any(xmin <= v.x <= xmax and ymin <= v.y <= ymax for v in gnd_vias):
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
refs = [p.component_ref for p in net.pins[:1]]
|
refs = [p.component_ref for p in net.pins[:1]]
|
||||||
ref = refs[0] if refs else net_name
|
ref = refs[0] if refs else net_name
|
||||||
|
rec = (
|
||||||
|
f"Add GND stitch vias along '{net_name}' so the return current "
|
||||||
|
"has a nearby via to the ground plane."
|
||||||
|
)
|
||||||
out.append(Finding(
|
out.append(Finding(
|
||||||
designator=ref,
|
designator=ref,
|
||||||
mpn=(graph.components[ref].mpn if ref in graph.components else "") or "",
|
mpn=(graph.components[ref].mpn if ref in graph.components else "") or "",
|
||||||
aspect="layout_return",
|
aspect="layout_return",
|
||||||
finding=(
|
finding=(
|
||||||
f"Signal '{net_name}' spans {xmax - xmin:.1f}×{ymax - ymin:.1f} mm "
|
f"Net '{net_name}' spans {dx:.1f}×{dy:.1f} mm "
|
||||||
"over a GND pour with no GND via in that bounding box."
|
"over a GND pour with no GND via in that bounding box."
|
||||||
),
|
),
|
||||||
|
facts=(
|
||||||
|
f"BBox {dx:.1f}×{dy:.1f} mm on '{net_name}'; "
|
||||||
|
f"{len(gnd_vias)} GND via(s) on the board, none in bbox."
|
||||||
|
),
|
||||||
|
requirement="Return current needs a nearby GND via on long HS/power routes.",
|
||||||
|
inference="No GND via in the span — stitch if the return path should stay local.",
|
||||||
why=(
|
why=(
|
||||||
"Return path / stitch vias are inferred from board zones and vias only "
|
"Return path / stitch vias are inferred from board zones and vias only "
|
||||||
"(no IEC clearance invented)."
|
"(no IEC clearance invented)."
|
||||||
),
|
),
|
||||||
status="INFO",
|
status="INFO",
|
||||||
recommendation=(
|
recommendation=rec,
|
||||||
f"Add GND stitch vias along '{net_name}' so the return current "
|
action=rec,
|
||||||
"has a nearby via to the ground plane."
|
|
||||||
),
|
|
||||||
source="pcb_power_thermal",
|
source="pcb_power_thermal",
|
||||||
rule_id="PE-STCH-001",
|
rule_id="PE-STCH-001",
|
||||||
net=net_name,
|
net=net_name,
|
||||||
|
|||||||
@@ -289,9 +289,15 @@ def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGr
|
|||||||
own = _net_for_pin(graph, ref, pin_no) if pin_no else None
|
own = _net_for_pin(graph, ref, pin_no) if pin_no else None
|
||||||
if not own:
|
if not own:
|
||||||
return []
|
return []
|
||||||
|
pad_nets = [
|
||||||
|
p.net for p in fp.pads
|
||||||
|
if p.net
|
||||||
|
]
|
||||||
foreign: list[str] = []
|
foreign: list[str] = []
|
||||||
for s in layout.segments:
|
for s in layout.segments:
|
||||||
if not s.net or s.net == own:
|
if not s.net or kicad_nets_match(s.net, own):
|
||||||
|
continue
|
||||||
|
if any(kicad_nets_match(s.net, pn) for pn in pad_nets):
|
||||||
continue
|
continue
|
||||||
if _in_poly(s.start[0], s.start[1], fp.courtyard) or _in_poly(
|
if _in_poly(s.start[0], s.start[1], fp.courtyard) or _in_poly(
|
||||||
s.end[0], s.end[1], fp.courtyard
|
s.end[0], s.end[1], fp.courtyard
|
||||||
@@ -300,17 +306,32 @@ def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGr
|
|||||||
if not foreign:
|
if not foreign:
|
||||||
return []
|
return []
|
||||||
net = sorted(set(foreign))[0]
|
net = sorted(set(foreign))[0]
|
||||||
|
is_crystal = comp.component_type == ComponentType.CRYSTAL
|
||||||
|
rec = (
|
||||||
|
"Keep unrelated nets out of the keepout if the datasheet shows a "
|
||||||
|
"keep-out zone; traces to this part's own pads are normal copper."
|
||||||
|
)
|
||||||
return [Finding(
|
return [Finding(
|
||||||
designator=ref,
|
designator=ref,
|
||||||
mpn=comp.mpn or cons.mpn,
|
mpn=comp.mpn or cons.mpn,
|
||||||
aspect="placement",
|
aspect="placement",
|
||||||
finding=f"Track on {net} enters courtyard of {ref} (keepout on {own}).",
|
finding=f"Track on {net} enters courtyard of {ref} (keepout on {own}).",
|
||||||
|
facts=f"Segment on '{net}' has an endpoint inside {ref} courtyard.",
|
||||||
|
requirement="layout_rules kind=keepout (recommended courtyard isolation).",
|
||||||
|
inference=(
|
||||||
|
"Crystal keepout may apply."
|
||||||
|
if is_crystal
|
||||||
|
else "Not a pad net of this footprint — review, not a mandatory DRC."
|
||||||
|
),
|
||||||
why="layout_rules kind=keepout.",
|
why="layout_rules kind=keepout.",
|
||||||
status="WARNING",
|
status="WARNING" if is_crystal else "INFO",
|
||||||
recommendation="Keep other nets out of the courtyard.",
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
source="placement_check",
|
source="placement_check",
|
||||||
rule_id="PE-PLC-004",
|
rule_id="PE-PLC-004",
|
||||||
|
finding_class="REVIEW",
|
||||||
|
provenance="RECOMMENDED",
|
||||||
net=net,
|
net=net,
|
||||||
pins=[pin_no],
|
pins=[pin_no] if pin_no else [],
|
||||||
source_page=rule.get("source_page"),
|
source_page=rule.get("source_page"),
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ export function FindingCard({
|
|||||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||||
const commentCount = comments?.length ?? 0;
|
const commentCount = comments?.length ?? 0;
|
||||||
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
||||||
|
const actionText = (finding.action || finding.recommendation || "").trim();
|
||||||
const expandable =
|
const expandable =
|
||||||
!!finding.recommendation ||
|
!!actionText ||
|
||||||
!!finding.facts ||
|
!!finding.facts ||
|
||||||
!!finding.requirement ||
|
!!finding.requirement ||
|
||||||
!!finding.inference ||
|
!!finding.inference ||
|
||||||
@@ -116,17 +117,17 @@ export function FindingCard({
|
|||||||
)}
|
)}
|
||||||
<span>{finding.finding}</span>
|
<span>{finding.finding}</span>
|
||||||
</p>
|
</p>
|
||||||
{finding.why && (
|
{finding.why && !finding.facts && !finding.requirement && (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{finding.why}</p>
|
<p className="text-sm text-muted-foreground leading-relaxed">{finding.why}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
{finding.recommendation && (
|
{actionText && (
|
||||||
<span className="inline-flex items-center h-7 px-2 text-xs text-muted-foreground">
|
<span className="inline-flex items-center h-7 px-2 text-xs text-muted-foreground">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn("h-3.5 w-3.5 mr-1 transition-transform", open && "rotate-180")}
|
className={cn("h-3.5 w-3.5 mr-1 transition-transform", open && "rotate-180")}
|
||||||
/>
|
/>
|
||||||
Recommendation
|
Action
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasCommentSupport && finding.finding_id && (
|
{hasCommentSupport && finding.finding_id && (
|
||||||
@@ -249,9 +250,10 @@ export function FindingCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
{finding.recommendation && (
|
{actionText && (
|
||||||
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
||||||
{finding.recommendation}
|
<span className="font-medium text-foreground">Action. </span>
|
||||||
|
{actionText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{hasCommentSupport && finding.finding_id && (
|
{hasCommentSupport && finding.finding_id && (
|
||||||
|
|||||||
@@ -133,3 +133,26 @@ def test_same_object_pcb_and_schema():
|
|||||||
assert obj.finding_class
|
assert obj.finding_class
|
||||||
assert obj.provenance
|
assert obj.provenance
|
||||||
assert obj.evidence_status
|
assert obj.evidence_status
|
||||||
|
assert (obj.action or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_recommendation_gets_action():
|
||||||
|
f = Finding(designator="U1", finding="note", status="INFO", source="review")
|
||||||
|
complete_finding(f)
|
||||||
|
assert f.action.strip()
|
||||||
|
assert f.recommendation.strip() == f.action.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommended_rc_cap_is_review_not_rule():
|
||||||
|
f = Finding(
|
||||||
|
designator="C1",
|
||||||
|
finding="10n vs 100n",
|
||||||
|
why="datasheet typical 100n",
|
||||||
|
status="WARNING",
|
||||||
|
rule_id="PE-DEC-002",
|
||||||
|
source="passive_rail_check",
|
||||||
|
)
|
||||||
|
complete_finding(f)
|
||||||
|
assert f.finding_class == "REVIEW"
|
||||||
|
assert f.provenance == "RECOMMENDED"
|
||||||
|
assert f.status != "ERROR"
|
||||||
|
|||||||
@@ -237,3 +237,25 @@ def test_layout_graph_written_from_valid_pcb(tmp_path: Path):
|
|||||||
assert out.is_file()
|
assert out.is_file()
|
||||||
data = __import__("json").loads(out.read_text())
|
data = __import__("json").loads(out.read_text())
|
||||||
assert "R1" in data["footprints"]
|
assert "R1" in data["footprints"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stackup_thickness_without_full_dielectrics(tmp_path: Path):
|
||||||
|
p = tmp_path / "t.kicad_pcb"
|
||||||
|
p.write_text("""(kicad_pcb (version 20240108)
|
||||||
|
(net 0 "")
|
||||||
|
(net 1 "VOUT")
|
||||||
|
(setup
|
||||||
|
(stackup
|
||||||
|
(layer "F.Cu" (type "copper") (thickness 0.035mm))
|
||||||
|
(layer "B.Cu" (type "copper") (thickness 0.035))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(via (at 1 1) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1))
|
||||||
|
(arc (start 0 0) (end 2 0) (width 0.4) (layer "F.Cu") (net 1))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
g = parse_kicad_pcb(p)
|
||||||
|
assert g.stackup is not None
|
||||||
|
assert g.stackup.copper_thickness_mm == pytest.approx(0.035)
|
||||||
|
assert len(g.vias) == 1
|
||||||
|
assert any(s.width == pytest.approx(0.4) and s.net == "VOUT" for s in g.segments)
|
||||||
|
|||||||
@@ -394,12 +394,19 @@ def test_power_trace_ipc2221_errors_when_load_exceeds_ampacity():
|
|||||||
assert findings[0].recommendation
|
assert findings[0].recommendation
|
||||||
|
|
||||||
|
|
||||||
|
def test_power_trace_uses_parsed_width_and_thickness():
|
||||||
|
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
||||||
|
|
||||||
|
graph, cmap, layout = _ldo_graph_layout(i_load=0.05, width=1.0, thickness=0.035)
|
||||||
|
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||||
|
assert findings == []
|
||||||
|
|
||||||
|
|
||||||
def test_power_trace_skips_without_i_load():
|
def test_power_trace_skips_without_i_load():
|
||||||
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
||||||
|
|
||||||
graph, cmap, layout = _ldo_graph_layout(i_load=10)
|
graph, cmap, layout = _ldo_graph_layout(i_load=10)
|
||||||
graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr]
|
graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr]
|
||||||
# drop i_load
|
|
||||||
graph.components["U1"].specs = None
|
graph.components["U1"].specs = None
|
||||||
assert check_pcb_power_traces(graph, cmap, layout) == []
|
assert check_pcb_power_traces(graph, cmap, layout) == []
|
||||||
|
|
||||||
@@ -502,6 +509,38 @@ def test_thermal_copper_warns_without_pour_or_vias():
|
|||||||
assert findings[0].recommendation
|
assert findings[0].recommendation
|
||||||
|
|
||||||
|
|
||||||
|
def test_gnd_stitch_skips_tiny_gpio():
|
||||||
|
from backend.periscopex.models import LayoutZone, PinConnection
|
||||||
|
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
|
||||||
|
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="X",
|
||||||
|
pins={"1": "GPIO4"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"GPIO4": Net(
|
||||||
|
name="GPIO4", net_type=NetType.SIGNAL,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
layout = LayoutGraph(
|
||||||
|
footprints={},
|
||||||
|
segments=[
|
||||||
|
LayoutSegment(start=(0, 0), end=(0.1, 3), width=0.2, layer="F.Cu", net="GPIO4"),
|
||||||
|
],
|
||||||
|
zones=[
|
||||||
|
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
|
||||||
|
],
|
||||||
|
vias=[],
|
||||||
|
)
|
||||||
|
assert check_pcb_gnd_stitch(graph, layout) == []
|
||||||
|
|
||||||
|
|
||||||
def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
|
def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
|
||||||
from backend.periscopex.models import LayoutVia, LayoutZone, PinConnection
|
from backend.periscopex.models import LayoutVia, LayoutZone, PinConnection
|
||||||
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
|
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
|
||||||
@@ -511,12 +550,12 @@ def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
|
|||||||
"U1": Component(
|
"U1": Component(
|
||||||
reference="U1", value="", footprint="",
|
reference="U1", value="", footprint="",
|
||||||
component_type=ComponentType.IC, mpn="X",
|
component_type=ComponentType.IC, mpn="X",
|
||||||
pins={"1": "SDA"},
|
pins={"1": "USB_DP"},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
nets={
|
nets={
|
||||||
"SDA": Net(
|
"USB_DP": Net(
|
||||||
name="SDA", net_type=NetType.SIGNAL,
|
name="USB_DP", net_type=NetType.SIGNAL,
|
||||||
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -524,17 +563,18 @@ def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
|
|||||||
layout = LayoutGraph(
|
layout = LayoutGraph(
|
||||||
footprints={},
|
footprints={},
|
||||||
segments=[
|
segments=[
|
||||||
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="SDA"),
|
LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"),
|
||||||
],
|
],
|
||||||
zones=[
|
zones=[
|
||||||
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
|
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]),
|
||||||
],
|
],
|
||||||
vias=[],
|
vias=[],
|
||||||
)
|
)
|
||||||
findings = check_pcb_gnd_stitch(graph, layout)
|
findings = check_pcb_gnd_stitch(graph, layout)
|
||||||
assert findings and findings[0].rule_id == "PE-STCH-001"
|
assert findings and findings[0].rule_id == "PE-STCH-001"
|
||||||
assert findings[0].status == "INFO"
|
assert findings[0].status == "INFO"
|
||||||
layout.vias = [LayoutVia(x=10, y=0, net="GND", drill=0.3)]
|
assert findings[0].action
|
||||||
|
layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)]
|
||||||
assert check_pcb_gnd_stitch(graph, layout) == []
|
assert check_pcb_gnd_stitch(graph, layout) == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,60 @@ def test_keepout_foreign_track_in_courtyard_is_ps_plc_004():
|
|||||||
assert len(plc) == 1
|
assert len(plc) == 1
|
||||||
assert plc[0].designator == "X1"
|
assert plc[0].designator == "X1"
|
||||||
assert plc[0].net == "GND"
|
assert plc[0].net == "GND"
|
||||||
|
assert plc[0].finding_class == "REVIEW"
|
||||||
|
assert plc[0].action
|
||||||
|
|
||||||
|
|
||||||
|
def test_ic_courtyard_pad_nets_are_not_keepout_violations():
|
||||||
|
"""GND/I2C into an IC courtyard is normal copper to that part's pads."""
|
||||||
|
from backend.periscopex.models import Component, ComponentType, Net, NetType, PinConnection
|
||||||
|
|
||||||
|
cons = {
|
||||||
|
"MCU": ComponentConstraints(
|
||||||
|
mpn="MCU",
|
||||||
|
pintable=[Pin(number="1", name="SDA")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
layout_rules=[{"kind": "keepout", "pin": "1"}],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="MCU",
|
||||||
|
pins={"1": "SDA", "2": "GND"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"SDA": Net(
|
||||||
|
name="SDA", net_type=NetType.SIGNAL,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||||
|
),
|
||||||
|
"GND": Net(
|
||||||
|
name="GND", net_type=NetType.GROUND,
|
||||||
|
pins=[PinConnection(component_ref="U1", pin_number="2")],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
layout = LayoutGraph(
|
||||||
|
footprints={
|
||||||
|
"U1": LayoutFootprint(
|
||||||
|
reference="U1", x=0, y=0, layer="F.Cu",
|
||||||
|
pads=[
|
||||||
|
LayoutPad(number="1", x=0.2, y=0.2, net="SDA"),
|
||||||
|
LayoutPad(number="2", x=0.8, y=0.2, net="GND"),
|
||||||
|
],
|
||||||
|
courtyard=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
segments=[
|
||||||
|
LayoutSegment(start=(0.4, 0.4), end=(0.6, 0.4), width=0.2, layer="F.Cu", net="GND"),
|
||||||
|
LayoutSegment(start=(0.3, 0.5), end=(0.5, 0.5), width=0.2, layer="F.Cu", net="SDA"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
findings = check_placement(graph, cons, layout)
|
||||||
|
assert [f for f in findings if f.rule_id == "PE-PLC-004"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_keepout_own_net_in_courtyard_is_silent():
|
def test_keepout_own_net_in_courtyard_is_silent():
|
||||||
|
|||||||
Reference in New Issue
Block a user