diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index 771ba36..d24fc6c 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -152,6 +152,12 @@ def _seed() -> None: requirement="Series resistor on the HS net vs datasheet ohms.") _add("PE-SI-010", "TYPICAL", "INFO", domain="pcb", requirement="HS bus measured; library has no SI FACT — not 90 Ω folklore.") + _add("PE-SI-007", "RECOMMENDED", "RISK", domain="pcb", + requirement="Dangling track stub vs datasheet millimetres; pad/via/zone are not stubs.") + _add("PE-SI-011", "TYPICAL", "REVIEW", domain="pcb", + requirement="Continuous GND pour under an HF pair when that pour exists (not IEC).") + _add("PE-SI-012", "TYPICAL", "INFO", domain="pcb", + requirement="BOM termination on an HF net is reported; missing terminator is a skip.") _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", @@ -224,6 +230,26 @@ def _seed() -> None: requirement="PoE class/type from 802.3af/at/bt or Class n — never invented.") _add("PE-POE-003", "MANDATORY", "RULE", domain="shared", requirement="PoE requires magnetics/isolation (MagJack or LAN transformer).") + _add("PE-DDR-001", "TYPICAL", "INFO", domain="shared", + requirement="DDR class from a DRAM on this graph — never invented.") + _add("PE-DDR-002", "MANDATORY", "RULE", domain="shared", + requirement="DDR needs CK, DQS, and ≥8 DQ nets when the DRAM is fitted.") + _add("PE-DDR-003", "TYPICAL", "INFO", domain="shared", + requirement="VTT/VREF reported only when those nets exist.") + _add("PE-CPU-001", "TYPICAL", "INFO", domain="shared", + requirement="CPU data/address/control bus only if that parallel bus is on the graph.") + _add("PE-CPU-002", "TYPICAL", "INFO", domain="shared", + requirement="Parallel data bus ≥8 D/DQ/AD nets.") + _add("PE-CPU-003", "TYPICAL", "INFO", domain="shared", + requirement="Address or muxed AD nets when that CPU bus exists.") + _add("PE-CPU-004", "TYPICAL", "INFO", domain="shared", + requirement="CS/WE/OE (or FMC equivalents) on the CPU parallel bus.") + _add("PE-FPGA-001", "TYPICAL", "INFO", domain="shared", + requirement="FPGA class only when an FPGA is fitted.") + _add("PE-FPGA-002", "TYPICAL", "INFO", domain="shared", + requirement="FPGA config flash reported when a flash IC is on the graph.") + _add("PE-FPGA-003", "TYPICAL", "INFO", domain="shared", + requirement="FPGA core/IO rails reported only when VCCINT/VCCIO (named) exist.") _seed() diff --git a/periscope/src/backend/periscopex/hf_bus_class.py b/periscope/src/backend/periscopex/hf_bus_class.py new file mode 100644 index 0000000..2e17ac4 --- /dev/null +++ b/periscope/src/backend/periscopex/hf_bus_class.py @@ -0,0 +1,328 @@ +"""DDR / CPU parallel / FPGA class certifiers — only if that device is on the graph. + +Not SI Z0. Not invented VTT/DDR/FPGA. Same FACT/REQUIREMENT/INFERENCE contract +as USB-C / Ethernet / PoE (2.61.2). +""" + +from __future__ import annotations + +import re + +from backend.periscopex.models import Component, ComponentType, DesignGraph, Finding +from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net + +SOURCE = "hf_bus_class" + +_MIN_PARALLEL_BITS = 8 + +_DDR_MPN_RE = re.compile( + r"DDR[234]|MT41|MT47|MT48|K4B|K4A|IS43TR|IS42S|W971|W972|EDB\d|H5TQ|H5TC", + re.I, +) +_SRAM_MPN_RE = re.compile(r"IS61|IS62|CY7C|AS6C|\bSRAM\b", re.I) +_FPGA_MPN_RE = re.compile( + r"XC[2367]|XCKU|XCZU|LFE5|ICE40|EP[1-4]C|10CL|10M\d|ECP5|" + r"ARTIX|KINTEX|SPARTAN|CYCLONE|MAX.?10|POLARFIRE|\bLATTICE\b", + re.I, +) +_FLASH_MPN_RE = re.compile(r"W25|S25|M25|MX25|GD25|AT25|SST25|N25Q", re.I) +_DATA_RE = re.compile(r"(?:^|[_/.])(?:DQ|AD|DATA|FMC_D|D)(\d+)(?:$|[_/.])", re.I) +_ADDR_RE = re.compile(r"(?:^|[_/.])(?:ADDR|FMC_A|BA|A)(\d+)(?:$|[_/.])", re.I) +_CTRL_RE = re.compile( + r"(?:^|[_/.])(?:N?WE|N?OE|N?CS|N?LB|N?UB|RAS|CAS|CKE|ODT|" + r"FMC_NWE|FMC_NOE|FMC_NE\d|NE\d)(?:$|[_/.])", + re.I, +) +_CK_RE = re.compile(r"(?:DDR.*)?(?:CK|CLK)[_]?[PN](?:$|[_/.])|(?:^|[_/.])CK[_]?[PN]", re.I) +_DQS_RE = re.compile(r"DQS", re.I) +_VTT_RE = re.compile(r"VTT|VREF", re.I) +_FPGA_IO_RE = re.compile(r"VCCINT|VCCIO|VCCAUX|VCORE|VCCBRAM", re.I) + + +def check_memory_fpga_classes(graph: DesignGraph) -> list[Finding]: + """Certify DDR / CPU parallel / FPGA only when that part exists on this graph.""" + out: list[Finding] = [] + out.extend(_check_ddr(graph)) + out.extend(_check_cpu(graph)) + out.extend(_check_fpga(graph)) + return out + + +def _leaf(net: str) -> str: + n = normalize_kicad_hierarchy_net(net) + return n.split("/")[-1] if n else "" + + +def _blob(comp: Component) -> str: + return " ".join(str(x or "") for x in (comp.mpn, comp.value, comp.component_subtype)) + + +def _nets_of(comp: Component) -> list[str]: + return [n for n in comp.pins.values() if n] + + +def _indices(nets: list[str], cre: re.Pattern[str]) -> set[int]: + found: set[int] = set() + for n in nets: + m = cre.search(_leaf(n)) + if m: + found.add(int(m.group(1))) + return found + + +def _has_ctrl(nets: list[str]) -> list[str]: + return sorted({_leaf(n) for n in nets if _CTRL_RE.search(_leaf(n))}) + + +def _is_ddr_ic(comp: Component) -> bool: + if comp.component_type != ComponentType.IC: + return False + sub = (comp.component_subtype or "").lower() + if sub == "ic.memory.ddr" or "ddr" in sub: + return True + return bool(_DDR_MPN_RE.search(_blob(comp))) + + +def _is_fpga_ic(comp: Component) -> bool: + if comp.component_type != ComponentType.IC: + return False + sub = (comp.component_subtype or "").lower() + if sub == "ic.fpga" or sub.startswith("ic.fpga."): + return True + return bool(_FPGA_MPN_RE.search(_blob(comp))) + + +def _is_sram_ic(comp: Component) -> bool: + if comp.component_type != ComponentType.IC: + return False + sub = (comp.component_subtype or "").lower() + if sub == "ic.memory.sram": + return True + return bool(_SRAM_MPN_RE.search(_blob(comp))) + + +def _is_flash_ic(comp: Component) -> bool: + if comp.component_type != ComponentType.IC: + return False + sub = (comp.component_subtype or "").lower() + if sub == "ic.memory.flash": + return True + return bool(_FLASH_MPN_RE.search(_blob(comp))) + + +def _is_mcu(comp: Component) -> bool: + if comp.component_type != ComponentType.IC: + return False + if _is_ddr_ic(comp) or _is_fpga_ic(comp) or _is_sram_ic(comp) or _is_flash_ic(comp): + return False + sub = (comp.component_subtype or "").lower() + return sub == "ic.mcu" or sub.startswith("ic.cpu") or "mcu" in (comp.value or "").lower() + + +def _parallel_groups(nets: list[str]) -> tuple[set[int], set[int], list[str]]: + data = _indices(nets, _DATA_RE) + addr = _indices(nets, _ADDR_RE) + return data, addr, _has_ctrl(nets) + + +def _check_ddr(graph: DesignGraph) -> list[Finding]: + chips = [c for c in graph.components.values() if _is_ddr_ic(c)] + if not chips: + return [] + out: list[Finding] = [] + for comp in sorted(chips, key=lambda c: c.reference): + nets = _nets_of(comp) + leaves = [_leaf(n) for n in nets] + dq = _indices(nets, _DATA_RE) + has_dqs = any(_DQS_RE.search(x) for x in leaves) + has_ck = any(_CK_RE.search(x) for x in leaves) + klass = "DDR" + blob = _blob(comp).upper() + if "DDR4" in blob: + klass = "DDR4" + elif "DDR3" in blob or "MT41" in blob or "K4B" in blob: + klass = "DDR3" + elif "DDR2" in blob: + klass = "DDR2" + out.append(_info( + comp.reference, "PE-DDR-001", + f"{comp.reference} class {klass} (device on this graph).", + f"mpn={comp.mpn!r}; subtype={comp.component_subtype!r}; dq_bits={len(dq)}.", + "DDR class from MPN/subtype on a DRAM that is actually fitted.", + mpn=comp.mpn or "", + )) + if len(dq) >= _MIN_PARALLEL_BITS and has_dqs and has_ck: + out.append(_info( + comp.reference, "PE-DDR-002", + f"{comp.reference} has CK, DQS, and {len(dq)} DQ nets.", + f"ck={has_ck}; dqs={has_dqs}; dq_indices={sorted(dq)[:16]}.", + "DDR needs CK, DQS, and a data bus on the netlist.", + mpn=comp.mpn or "", + )) + else: + out.append(_insufficient( + comp.reference, "PE-DDR-002", + f"{comp.reference} is DDR; CK/DQS/DQ bus is not fully evidenced.", + f"ck={has_ck}; dqs={has_dqs}; dq_bits={len(dq)} (need ≥{_MIN_PARALLEL_BITS}).", + "Do not invent DDR pinout; need CK, DQS, and ≥8 DQ nets.", + mpn=comp.mpn or "", + )) + vtt = [n for n in nets if _VTT_RE.search(_leaf(n))] + extra = [n for n in graph.nets if _VTT_RE.search(_leaf(n))] + names = sorted({_leaf(n) for n in vtt + extra}) + if names: + out.append(_info( + comp.reference, "PE-DDR-003", + f"{comp.reference} VTT/VREF nets present: {', '.join(names)}.", + f"vtt_vref={names}.", + "VTT/VREF is reported only when those nets exist — never invented.", + mpn=comp.mpn or "", + )) + return out + + +def _cpu_host(graph: DesignGraph) -> Component | None: + for comp in sorted(graph.components.values(), key=lambda c: c.reference): + if not (_is_mcu(comp) or _is_fpga_ic(comp)): + continue + data, addr, ctrl = _parallel_groups(_nets_of(comp)) + if len(data) >= _MIN_PARALLEL_BITS and ctrl and (addr or _muxed(data, addr)): + if _is_fpga_ic(comp) and not _is_mcu(comp): + continue + return comp + for comp in sorted(graph.components.values(), key=lambda c: c.reference): + if not _is_sram_ic(comp): + continue + data, addr, ctrl = _parallel_groups(_nets_of(comp)) + if len(data) >= _MIN_PARALLEL_BITS and ctrl: + return comp + return None + + +def _muxed(data: set[int], addr: set[int]) -> bool: + return bool(data) and data == addr + + +def _check_cpu(graph: DesignGraph) -> list[Finding]: + host = _cpu_host(graph) + if host is None: + return [] + if _is_ddr_ic(host): + return [] + nets = _nets_of(host) + data, addr, ctrl = _parallel_groups(nets) + if _is_fpga_ic(host) and not _is_mcu(host): + return [] + kind = "mux AD" if _muxed(data, addr) else "parallel" + if any("FMC" in _leaf(n).upper() for n in nets): + kind = "FMC" + elif _is_sram_ic(host) or any(_is_sram_ic(c) for c in graph.components.values()): + kind = "SRAM" + out = [_info( + host.reference, "PE-CPU-001", + f"{host.reference} class CPU {kind} bus (present on this graph).", + f"data_bits={len(data)}; addr_bits={len(addr)}; ctrl={ctrl}.", + "CPU data/address/control is certified only when that bus exists.", + mpn=host.mpn or "", + )] + out.append(_info( + host.reference, "PE-CPU-002", + f"{host.reference} data bus {len(data)} bits.", + f"dq_indices={sorted(data)[:16]}.", + "Parallel data bus needs ≥8 D/DQ/AD nets.", + mpn=host.mpn or "", + )) + if addr and not _muxed(data, addr): + out.append(_info( + host.reference, "PE-CPU-003", + f"{host.reference} address bus {len(addr)} bits.", + f"a_indices={sorted(addr)[:16]}.", + "Address bus reported when A/ADDR nets exist (mux AD is not a second bus).", + mpn=host.mpn or "", + )) + elif _muxed(data, addr): + out.append(_info( + host.reference, "PE-CPU-003", + f"{host.reference} multiplexed AD bus ({len(data)} bits).", + f"ad_indices={sorted(data)[:16]}.", + "Muxed AD is one bus, not invented separate D and A.", + mpn=host.mpn or "", + )) + else: + return out + if ctrl: + out.append(_info( + host.reference, "PE-CPU-004", + f"{host.reference} control: {', '.join(ctrl)}.", + f"ctrl={ctrl}.", + "CS/WE/OE (or FMC equivalents) from net names on this bus.", + mpn=host.mpn or "", + )) + return out + + +def _check_fpga(graph: DesignGraph) -> list[Finding]: + fpgas = [c for c in graph.components.values() if _is_fpga_ic(c)] + if not fpgas: + return [] + flashes = [c for c in graph.components.values() if _is_flash_ic(c)] + out: list[Finding] = [] + for comp in sorted(fpgas, key=lambda c: c.reference): + out.append(_info( + comp.reference, "PE-FPGA-001", + f"{comp.reference} class FPGA (device on this graph).", + f"mpn={comp.mpn!r}; subtype={comp.component_subtype!r}.", + "FPGA analysis runs only when an FPGA is fitted — never invented.", + mpn=comp.mpn or "", + )) + if flashes: + names = ", ".join(sorted(c.reference for c in flashes)) + out.append(_info( + comp.reference, "PE-FPGA-002", + f"{comp.reference} config flash present: {names}.", + f"flash={[c.reference + '=' + (c.mpn or '') for c in flashes]}.", + "Config flash is reported when a flash IC is on the graph.", + mpn=comp.mpn or "", + )) + rails = sorted({ + _leaf(n) for n in _nets_of(comp) if _FPGA_IO_RE.search(_leaf(n)) + }) + if rails: + out.append(_info( + comp.reference, "PE-FPGA-003", + f"{comp.reference} FPGA rails: {', '.join(rails)}.", + f"rails={rails}.", + "Core/IO supplies reported only when VCCINT/VCCIO (or named equivalent) exist.", + mpn=comp.mpn or "", + )) + return out + + +def _info( + designator: str, rule_id: str, finding: str, facts: str, requirement: str, + *, mpn: str = "", +) -> Finding: + rec = "No connection-integrity change required for this check." + return Finding( + designator=designator, mpn=mpn, aspect="interface_class", + finding=finding, why=requirement, facts=facts, requirement=requirement, + inference="Class/integrity FACT from netlist — not SI Z0 and not invented I/Z/mm.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id=rule_id, finding_class="INFO", provenance="TYPICAL", + evidence_status="SUFFICIENT", confidence=0.9, pins=[], + ) + + +def _insufficient( + designator: str, rule_id: str, finding: str, facts: str, requirement: str, + *, mpn: str = "", +) -> Finding: + rec = "Add BOM/net evidence, then re-run. Do not invent DDR/CPU/FPGA buses." + return Finding( + designator=designator, mpn=mpn, aspect="interface_class", + finding=finding, why=requirement, facts=facts, requirement=requirement, + inference="Insufficient evidence — not inventing DDR pinout, VTT, Z, I, or mm.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id=rule_id, finding_class="INFO", provenance="TYPICAL", + evidence_status="INSUFFICIENT", confidence=0.3, pins=[], + ) diff --git a/periscope/src/backend/periscopex/hf_line_check.py b/periscope/src/backend/periscopex/hf_line_check.py new file mode 100644 index 0000000..9b595d4 --- /dev/null +++ b/periscope/src/backend/periscopex/hf_line_check.py @@ -0,0 +1,384 @@ +"""HF pair geometry: stubs, reference split, BOM termination. + +Track ≠ via ≠ pad ≠ zone. Skip without evidence. No invented 50/90 Ω or mm. +""" + +from __future__ import annotations + +import math +from collections import defaultdict + +from backend.periscopex.constraints_lookup import match_constraints as _match_constraints +from backend.periscopex.models import ( + ComponentType, + DesignGraph, + Finding, + LayoutGraph, + LayoutSegment, + ResistorSpecs, +) +from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net +from backend.periscopex.pcb_power_thermal import _is_gnd_name +from backend.periscopex.placement_check import _in_poly +from backend.periscopex.si_check import ( + _bus_in_targets, + _expand_bus_token, + _num, + _quote, + bus_class, + partner_net, + skip_si_net, +) + +SOURCE = "hf_line_check" + +# Join tolerance for collinear KiCad segment ends — not an SI length limit. +ENDPOINT_SNAP_MM = 0.05 +COORD_QUANT_MM = 0.01 + + +def check_hf_lines( + graph: DesignGraph, + constraints_map: dict, + layout: LayoutGraph | None, +) -> list[Finding]: + if layout is None or not layout.segments: + return [] + hs = _hs_nets(layout) + if not hs: + return [] + out: list[Finding] = [] + out.extend(_stub_findings(graph, constraints_map, layout, hs)) + out.extend(_split_findings(layout, hs)) + out.extend(_termination_findings(graph, hs)) + return out + + +def _hs_nets(layout: LayoutGraph) -> list[str]: + names = sorted({s.net for s in layout.segments if s.net}) + return [n for n in names if bus_class(n) and not skip_si_net(n)] + + +def _qxy(x: float, y: float) -> tuple[float, float]: + q = COORD_QUANT_MM + return (round(x / q) * q, round(y / q) * q) + + +def _edge_key(a: tuple[float, float], b: tuple[float, float]) -> tuple: + return tuple(sorted((a, b))) + + +def _track_graph(layout: LayoutGraph, net: str) -> tuple[ + dict[tuple[float, float], set[tuple[float, float]]], + dict[tuple, float], +]: + nbrs: dict[tuple[float, float], set[tuple[float, float]]] = defaultdict(set) + elen: dict[tuple, float] = {} + for s in layout.segments: + if not s.net or not kicad_nets_match(s.net, net): + continue + a = _qxy(*s.start) + b = _qxy(*s.end) + if a == b: + continue + nbrs[a].add(b) + nbrs[b].add(a) + elen[_edge_key(a, b)] = math.hypot(s.end[0] - s.start[0], s.end[1] - s.start[1]) + return nbrs, elen + + +def _snap(pt: tuple[float, float], nodes: list[tuple[float, float]]) -> tuple[float, float] | None: + px, py = _qxy(*pt) + best = None + best_d = ENDPOINT_SNAP_MM + for n in nodes: + d = math.hypot(n[0] - px, n[1] - py) + if d <= best_d: + best_d = d + best = n + return best + + +def _terminals(layout: LayoutGraph, net: str, nodes: list[tuple[float, float]]) -> set[tuple[float, float]]: + found: set[tuple[float, float]] = set() + for fp in layout.footprints.values(): + for pad in fp.pads: + if pad.net and kicad_nets_match(pad.net, net): + hit = _snap((pad.x, pad.y), nodes) + if hit: + found.add(hit) + for v in layout.vias: + if v.net and kicad_nets_match(v.net, net): + hit = _snap((v.x, v.y), nodes) + if hit: + found.add(hit) + for z in layout.zones: + if not z.net or not kicad_nets_match(z.net, net): + continue + for outline in z.outlines: + if len(outline) < 3: + continue + for node in nodes: + if _in_poly(node[0], node[1], outline): + found.add(node) + return found + + +def stub_lengths_mm(layout: LayoutGraph, net: str) -> list[float]: + """Dangling track branches on ``net``. Pads, vias, and same-net zones end a stub.""" + nbrs, elen = _track_graph(layout, net) + if not nbrs: + return [] + nodes = list(nbrs) + terms = _terminals(layout, net, nodes) + stubs: list[float] = [] + seen: set[tuple[float, float]] = set() + for node, friends in nbrs.items(): + if len(friends) != 1 or node in terms or node in seen: + continue + prev = None + cur = node + total = 0.0 + for _ in range(len(nbrs) + 1): + nxts = [n for n in nbrs[cur] if n != prev] + if not nxts: + break + nxt = nxts[0] + total += elen.get(_edge_key(cur, nxt), 0.0) + prev, cur = cur, nxt + if cur in terms or len(nbrs[cur]) != 2: + break + if total > COORD_QUANT_MM: + stubs.append(total) + seen.add(node) + return stubs + + +def _stub_limit(graph: DesignGraph, constraints_map: dict, bus: str) -> tuple[float | None, str]: + for _ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, constraints_map) + if not cons: + continue + for rule in cons.layout_rules or []: + kind = str(rule.get("kind") or "") + if kind not in {"stub", "max_stub"}: + continue + targets = _expand_bus_token(str(rule.get("net_class") or "")) + if targets and not _bus_in_targets(bus, targets): + continue + lim = _num(rule.get("max_distance_mm")) + if lim is None: + continue + return lim, _quote(rule) + return None, "" + + +def _pair_seen(net: str, seen: set[tuple[str, str]]) -> bool: + p = partner_net(net) + key = tuple(sorted((net, p or ""))) + if key in seen: + return True + seen.add(key) # type: ignore[arg-type] + return False + + +def _stub_findings( + graph: DesignGraph, + constraints_map: dict, + layout: LayoutGraph, + hs: list[str], +) -> list[Finding]: + out: list[Finding] = [] + seen: set[tuple[str, str]] = set() + for net in hs: + if _pair_seen(net, seen): + continue + lengths = stub_lengths_mm(layout, net) + p = partner_net(net) + if p: + lengths.extend(stub_lengths_mm(layout, p)) + if not lengths: + continue + stub_mm = max(lengths) + bc = bus_class(net) or "hs" + lim, quote = _stub_limit(graph, constraints_map, bc) + facts = f"stub_mm={stub_mm:.3f}; branches={ [round(x, 3) for x in lengths] }." + if lim is None: + rec = ( + "Re-extract layout_rules stub millimetres from the datasheet. " + "Do not assume a default stub length." + ) + out.append(Finding( + designator="layout", mpn="", aspect="si", + finding=( + f"Unverified: {bc} {net} stub {stub_mm:.2f} mm — " + "library has no stub millimetre FACT." + ), + facts=facts, + requirement="Datasheet layout_rules stub max_distance_mm on this bus (none on file).", + inference="Not an invented stub limit; pads/vias/zones are not stubs.", + why="Insufficient library stub millimetres for this bus.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-SI-007", finding_class="INFO", provenance="TYPICAL", + evidence_status="INSUFFICIENT", net=net, pins=[], + )) + continue + fail = stub_mm > lim + 1e-9 + rec = "Shorten the dangling copper on the pair." if fail else "Stub length is within the datasheet millimetre." + out.append(Finding( + designator="layout", mpn="", aspect="si", + finding=( + f"{'FAIL' if fail else 'PASS'}: {net} stub {stub_mm:.2f} mm " + f"(max {lim:g} mm)." + ), + facts=facts, + requirement=f"stub max_distance_mm={lim:g} ({quote or 'layout_rules'})", + inference="FAIL vs datasheet stub millimetre." if fail else "", + why="Stub is dangling track, not a via or pad.", + status="WARNING" if fail else "INFO", + recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-SI-007", + finding_class="RISK" if fail else "INFO", + provenance="RECOMMENDED" if fail else "TYPICAL", + evidence_status="SUFFICIENT", net=net, pins=[], + )) + return out + + +def _ref_layer(layout: LayoutGraph, track_layer: str) -> str | None: + stack = layout.stackup + if stack is None or not stack.copper_layers: + return None + layers = stack.copper_layers + want = track_layer + idx = None + for i, name in enumerate(layers): + if name == want or name.lower() == want.lower(): + idx = i + break + if idx is None: + return None + if idx + 1 < len(layers): + return layers[idx + 1] + if idx - 1 >= 0: + return layers[idx - 1] + return None + + +def _seg_samples(seg: LayoutSegment) -> list[tuple[float, float]]: + return [ + seg.start, + ((seg.start[0] + seg.end[0]) / 2.0, (seg.start[1] + seg.end[1]) / 2.0), + seg.end, + ] + + +def _point_in_gnd_zone(layout: LayoutGraph, x: float, y: float, layer: str) -> bool: + for z in layout.zones: + if z.keepout or not z.net or not _is_gnd_name(z.net): + continue + if z.layer and z.layer.lower() != layer.lower(): + continue + for outline in z.outlines: + if len(outline) >= 3 and _in_poly(x, y, outline): + return True + return False + + +def _split_findings(layout: LayoutGraph, hs: list[str]) -> list[Finding]: + if layout.stackup is None: + return [] + gnd_layers = { + z.layer for z in layout.zones + if z.net and _is_gnd_name(z.net) and z.layer and not z.keepout and z.outlines + } + if not gnd_layers: + return [] + out: list[Finding] = [] + seen: set[tuple[str, str]] = set() + for net in hs: + if _pair_seen(net, seen): + continue + segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)] + exposed = 0 + checked = 0 + ref = None + for s in segs: + if not s.layer: + continue + ref = _ref_layer(layout, s.layer) + if ref is None or ref not in gnd_layers: + continue + for x, y in _seg_samples(s): + checked += 1 + if not _point_in_gnd_zone(layout, x, y, ref): + exposed += 1 + if checked == 0 or exposed == 0: + continue + rec = f"Restore a continuous GND reference under {net}, then re-run PCB review." + out.append(Finding( + designator="layout", mpn="", aspect="si", + finding=( + f"{net} crosses a gap in the GND pour on reference layer " + f"{ref} ({exposed}/{checked} samples off pour)." + ), + facts=( + f"ref_layer={ref}; samples={checked}; off_pour={exposed}; " + f"gnd_zone_layers={sorted(gnd_layers)}." + ), + requirement="Continuous reference pour under an HF pair (typical, not IEC).", + inference="REVIEW — no creepage/IEC millimetre in context.", + why="Split under the line is geometry FACT; via/pad/track stay distinct.", + status="WARNING", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-SI-011", finding_class="REVIEW", provenance="TYPICAL", + evidence_status="SUFFICIENT", net=net, pins=[], + )) + return out + + +def _termination_findings(graph: DesignGraph, hs: list[str]) -> list[Finding]: + out: list[Finding] = [] + seen: set[str] = set() + hs_set = set(hs) + for net in hs: + if skip_si_net(net): + continue + for ref in refs_on_matched_net(graph, net): + if ref in seen: + continue + c = graph.components.get(ref) + if not c or c.component_type != ComponentType.RESISTOR: + continue + pins = [n for n in c.pins.values() if n] + if len(pins) < 2: + continue + others = [n for n in pins if not kicad_nets_match(n, net)] + if not others: + continue + other = others[0] + series = (not _is_gnd_name(other)) and (not skip_si_net(other) or other in hs_set) + parallel = _is_gnd_name(other) + if not series and not parallel: + continue + seen.add(ref) + ohms = None + if isinstance(c.specs, ResistorSpecs) and c.specs.value_ohms: + ohms = float(c.specs.value_ohms) + kind = "parallel-to-GND" if parallel else "series" + rec = "No termination change required; this is a BOM FACT." + facts = f"{ref} {kind} on {net}; other={other}; ohms={ohms}." + out.append(Finding( + designator=ref, mpn=c.mpn or "", aspect="si", + finding=f"{net} has {kind} termination {ref}" + + (f" ({ohms:g} Ω)" if ohms is not None else "") + ".", + facts=facts, + requirement="Report matching parts that exist on the HF net; do not invent a terminator.", + inference="BOM FACT — missing terminator is a skip, not a folklore FAIL.", + why="Termination is certified only when the resistor is on the netlist.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-SI-012", finding_class="INFO", provenance="TYPICAL", + evidence_status="SUFFICIENT", net=net, pins=[ref], + )) + return out diff --git a/periscope/src/backend/periscopex/pcb_checks.py b/periscope/src/backend/periscopex/pcb_checks.py index be8272d..5384544 100644 --- a/periscope/src/backend/periscopex/pcb_checks.py +++ b/periscope/src/backend/periscopex/pcb_checks.py @@ -29,6 +29,8 @@ from backend.periscopex.pcb_power_thermal import ( from backend.periscopex.pi_check import check_power_integrity from backend.periscopex.placement_check import check_placement from backend.periscopex.interface_class_check import SOURCE as IF_SOURCE, check_interface_classes +from backend.periscopex.hf_bus_class import check_memory_fpga_classes +from backend.periscopex.hf_line_check import check_hf_lines from backend.periscopex.si_check import check_si from backend.periscopex.spof_check import check_spof from backend.periscopex.timing_check import check_timing @@ -270,6 +272,7 @@ def run_pcb_checks( ("pcb_net_match", lambda: check_pcb_net_match(graph, layout)), ("placement_check", lambda: check_placement(graph, constraints_map, layout)), ("si_check", lambda: check_si(graph, constraints_map, layout, impedance_nets)), + ("hf_line", lambda: check_hf_lines(graph, constraints_map, layout)), ("pcb_derating", lambda: check_pcb_derating(graph)), ("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)), ("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)), @@ -286,6 +289,7 @@ def run_pcb_checks( ("emi_check", lambda: check_emi(graph, constraints_map, layout)), ("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)), ("interface_class", lambda: check_interface_classes(graph)), + ("hf_bus_class", lambda: check_memory_fpga_classes(graph)), ): try: out.extend(fn()) diff --git a/periscope/src/backend/periscopex/si_check.py b/periscope/src/backend/periscopex/si_check.py index 77b24ae..d6c0a76 100644 --- a/periscope/src/backend/periscopex/si_check.py +++ b/periscope/src/backend/periscopex/si_check.py @@ -1,9 +1,9 @@ """SI checks: datasheet layout_rules vs board + ImpedenceFinder. USB2 / USB3 SuperSpeed / Ethernet MDI / RGMII / SGMII / DDR3 / HDMI / -PCIe / LVDS are checked only against a rule whose quote or ``net_class`` -names that bus. I2C, GPIO, EN, analog REGN, USB CC, and strap/EN RC are -not HS pairs. No invented USB/IEC 90 Ω. +PCIe / LVDS / MIPI / HF clock are checked only against a rule whose quote +or ``net_class`` names that bus. I2C, GPIO, EN, analog REGN, USB CC, +strap/EN RC, and crystal XTAL nets are not HS pairs. No invented USB/IEC 90 Ω. """ from __future__ import annotations @@ -30,6 +30,11 @@ _SKIP_RE = re.compile( re.I, ) +_XTAL_RE = re.compile( + r"XTAL|OSCIN|OSCOUT|HFXIN|LFXIN|\bXIN\b|\bXOUT\b", + re.I, +) + _HS_CLASS_RE = ( ("usb3", re.compile( r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I, @@ -43,6 +48,7 @@ _HS_CLASS_RE = ( r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45", re.I, )), + ("mipi", re.compile(r"MIPI|\bDSI\b|\bCSI\b|CSI[_-]|DSI[_-]", re.I)), ("lvds", re.compile(r"LVDS", re.I)), ("ddr3_clk", re.compile(r"DDR3?.*CK|(?:^|[_/])CK[_]?[PN](?:$|[_/])", re.I)), ("ddr3_dqs", re.compile(r"DQS", re.I)), @@ -51,6 +57,10 @@ _HS_CLASS_RE = ( r"DDR3?.*(?:A\d+|ADDR|BA\d+|RAS|CAS|WE|ODT|CKE|(?:^|[_/])CS)", re.I, )), + ("hf_clk", re.compile( + r"GTX_CLK|(?:^|[_/])CLK[_]?[PN](?:$|[_/])|(?:^|[_/])MCLK[_]?[PN]", + re.I, + )), ) _STRAP_RULE_RE = re.compile( @@ -94,6 +104,11 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = { "hdmi": frozenset({"hdmi"}), "pcie": frozenset({"pcie"}), "lvds": frozenset({"lvds"}), + "mipi": frozenset({"mipi"}), + "dsi": frozenset({"mipi"}), + "csi": frozenset({"mipi"}), + "hf_clk": frozenset({"hf_clk"}), + "clock": frozenset({"hf_clk"}), } _SI_KINDS = frozenset({ @@ -151,6 +166,8 @@ def bus_class(net: str) -> str | None: if skip_si_net(net): return None leaf = _leaf(net) + if _XTAL_RE.search(leaf): + return None u = leaf.upper() if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u): return "usb3" @@ -217,6 +234,8 @@ def _rule_target_buses(rule: dict) -> frozenset[str]: (r"hdmi", "hdmi"), (r"pcie|pci[\s-]*express", "pcie"), (r"lvds", "lvds"), + (r"mipi|\bdsi\b|\bcsi\b", "mipi"), + (r"gtx_clk|hf\s*clock|ref\s*clk", "hf_clk"), (r"usb\s*2|d\s*\+|d\s*−|d\s*-|dp\s*/\s*dm|dp/dm", "usb2"), (r"\busb\b", "usb2"), (r"\bethernet\b|\beth\b", "eth_mdi"), diff --git a/periscope/src/docs/interface-class-certifiers.md b/periscope/src/docs/interface-class-certifiers.md index cf9d39f..3b7324a 100644 --- a/periscope/src/docs/interface-class-certifiers.md +++ b/periscope/src/docs/interface-class-certifiers.md @@ -18,6 +18,8 @@ Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). S | PE-ETH-001…004 | c’è un RJ45 | 10/100 vs GbE, coppie TX/RX o 4 MDI, magnetics se GbE, Bob Smith REVIEW | | PE-POE-001…003 | evidenza PoE sul jack | classe/tipo se citati; isolamento/magnetics; **skip** se RJ45 nudo | | PE-DDR-* | solo se c’è un dispositivo DDR | non inventato su HubAudio (nessun DDR in BOM) | +| PE-CPU-* | solo se c’è un bus parallelo D/A/controllo | SRAM/NOR/FMC/mux AD; niente bus inventato | +| PE-FPGA-* | solo se c’è un FPGA | bank/IO rails e flash di config se presenti; niente FPGA inventata | Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. L’IA spiega i FACT, non certifica. Nessun I inventato: PE-PWR **skip** se il datasheet non riporta I_load/Imax/I_abs. diff --git a/periscope/src/docs/piano-pcb-review.md b/periscope/src/docs/piano-pcb-review.md index 0ba033d..829cd4f 100644 --- a/periscope/src/docs/piano-pcb-review.md +++ b/periscope/src/docs/piano-pcb-review.md @@ -57,7 +57,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net | PE-SI-001 | `length_match` mm on USB/HDMI/PCIe/ETH/LVDS/DDR nets (not I2C/GPIO/CC) | | PE-SI-002 | ImpedenceFinder Zavg/min/max vs `impedance` window | | PE-SI-003…009 | max length, spacing, ref plane, vias, return, series R — each a datasheet number | +| PE-SI-007 | dangling **track** stub vs datasheet mm; via ≠ pad ≠ zone; INFO if no mm | | PE-SI-010 | HS bus measured, library has no SI FACT (not 90 Ω folklore) | +| PE-SI-011 | GND-pour gap under HF pair when stackup + pour exist (REVIEW, not IEC) | +| PE-SI-012 | BOM terminator on HF net (FACT); skip if none | | PE-PWR-001 | I_load/Imax/I_abs + width; **skip** se manca I (no 500 mA, no INFO); IPC solo con thickness e Tjmax | | PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR | | PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN | @@ -115,6 +118,8 @@ Gate: library `layout_rules` kind + `net_class`/quote on that bus. Empty cell = | SGMII | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | | DDR3 CLK/DQS/DQ/ADDR | gated | gated | gated | gated | gated | gated | gated | gated | per-subclass if no Z | | HDMI / PCIe / LVDS | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | +| MIPI DSI/CSI | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | +| HF clock (not XTAL) | gated | gated | gated | gated | gated | gated | gated | gated | per-bus | | I2C / GPIO / EN / CC | skip | skip | skip | skip | skip | skip | skip | skip | skip | Emmaforo USB: extract with **no Z number** → Zavg ~88 Ω is **PE-SI-010** INFO (insufficient), **not** a 90 Ω FAIL. If library later has 90 Ω ±10% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists. PE-SI-009 must not fire on USB from ESP32 EN RC 10 kΩ / 1 µF p.28. diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 96b8006..f7d24cb 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,18 @@ What's new in Periscope. +## 2.62.0 — 2026-09-21 — HF lines/buses in PCB checks (Phase A) + +USB / Eth / DDR / MIPI / PCIe / HDMI / LVDS / HF clock in `run_pcb_checks` at the same finding quality as 2.61.2. Pair integrity: stub (track only; via ≠ pad ≠ zone), reference-split under the line, BOM termination. CPU data/address/control and FPGA only if that device is on the graph. Skip if no evidence. Not DRC. Not FEM. Datasheet millimetres only; no invented Z/I. + +- [New] `PE-SI-007` dangling track stub vs datasheet mm (INFO if no mm). +- [New] `PE-SI-011` GND-pour gap under an HF pair (REVIEW; skip without stackup/zone). +- [New] `PE-SI-012` BOM series/parallel terminator FACT (skip if none). +- [New] `PE-DDR-001`…`003` DRAM class / CK-DQS-DQ / VTT only if DRAM present. +- [New] `PE-CPU-001`…`004` parallel CPU bus only if D/A/control exist. +- [New] `PE-FPGA-001`…`003` FPGA class / config flash / VCCINT·VCCIO only if FPGA present. +- [Changed] `bus_class` adds MIPI and leftover HF clocks; XTAL/OSCIN stay out. + ## 2.61.2 — 2026-09-21 — Certifiers only for connectors on this board USB-C / RJ45 / PoE / DDR3 are the method, not a catalog. If this graph has a USB-C receptacle, run USB-C questions. If it has RJ45, run Ethernet. PoE only with PoE evidence — never because RJ45 exists. No connector → no that certifier (not N/A). HubAudio has USB-C USB2 + PoE 10/100 MagJack and no DDR: no DDR findings. Skip-I (2.61.1) unchanged. No FEM, no via IPC, no invented I/Z/PoE/SuperSpeed. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index e1be359..292b32f 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.61.2", + "version": "2.62.0", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/periscope/src/frontend/src/lib/layout-finding.ts b/periscope/src/frontend/src/lib/layout-finding.ts index 2df436b..2928641 100644 --- a/periscope/src/frontend/src/lib/layout-finding.ts +++ b/periscope/src/frontend/src/lib/layout-finding.ts @@ -18,9 +18,14 @@ export function isLayoutFinding(f: { f.source === "bom_pcb_check" || f.source === "spof_check" || f.source === "emi_check" || + f.source === "hf_line_check" || + f.source === "hf_bus_class" || rid.startsWith("PE-PLC") || rid.startsWith("PE-LAY") || rid.startsWith("PE-SI") || + rid.startsWith("PE-DDR") || + rid.startsWith("PE-CPU") || + rid.startsWith("PE-FPGA") || rid.startsWith("PE-DRT") || rid.startsWith("PE-PWR") || rid.startsWith("PE-THM") || diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts index b615381..03a8a05 100644 --- a/periscope/src/frontend/src/lib/version.ts +++ b/periscope/src/frontend/src/lib/version.ts @@ -1,3 +1,3 @@ /** Stamped from content/changelog.md by scripts/sync-version.mjs. */ -export const APP_VERSION = "2.61.0"; +export const APP_VERSION = "2.62.0"; export const APP_VERSION_DATE = "2026-09-21"; diff --git a/periscope/src/taxonomy/ic.json b/periscope/src/taxonomy/ic.json index 3888133..f7179d7 100644 --- a/periscope/src/taxonomy/ic.json +++ b/periscope/src/taxonomy/ic.json @@ -44,6 +44,18 @@ "ic.memory.eeprom": { "description": "EEPROM" }, + "ic.memory.ddr": { + "description": "DDR SDRAM", + "example_mpn": "MT41K256M16TW" + }, + "ic.memory.sram": { + "description": "Parallel SRAM", + "example_mpn": "IS61WV51216" + }, + "ic.fpga": { + "description": "Field-programmable gate array", + "example_mpn": "XC7A35T" + }, "ic.logic.buffer": { "description": "Buffer or line driver" }, diff --git a/tests/test_hf_line_check.py b/tests/test_hf_line_check.py new file mode 100644 index 0000000..0552e8d --- /dev/null +++ b/tests/test_hf_line_check.py @@ -0,0 +1,398 @@ +"""Phase A: HF pair integrity + CPU/FPGA/DDR class — only if present. + +Via ≠ pad ≠ track ≠ zone. No invented Z/I/mm. Skip when evidence is missing. +""" + +from __future__ import annotations + +from backend.periscopex.finding_engine import complete_finding, lookup_rule +from backend.periscopex.hf_bus_class import check_memory_fpga_classes +from backend.periscopex.hf_line_check import check_hf_lines +from backend.periscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + LayoutDielectric, + LayoutFootprint, + LayoutGraph, + LayoutPad, + LayoutSegment, + LayoutStackup, + LayoutVia, + LayoutZone, + Net, + NetType, + Pin, + PinConnection, + ResistorSpecs, +) +from backend.periscopex.pcb_checks import run_pcb_checks +from backend.periscopex.si_check import bus_class, check_si, skip_si_net + + +def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY", subtype: str | None = None) -> Component: + return Component( + reference=ref, value=mpn, footprint="", + component_type=ComponentType.IC, mpn=mpn, + component_subtype=subtype, pins=pins, + ) + + +def _net(name: str, *pairs: tuple[str, str], ntype: NetType = NetType.SIGNAL) -> Net: + return Net( + name=name, net_type=ntype, + pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs], + ) + + +def _usb_graph() -> DesignGraph: + return DesignGraph( + components={ + "U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}), + "J2": Component( + reference="J2", value="USB_C", footprint="", + component_type=ComponentType.CONNECTOR, pins={"A6": "USB_D+", "A7": "USB_D-"}, + ), + }, + nets={ + "USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")), + "USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")), + }, + ) + + +def _fp_with_pads(ref: str, pads: list[LayoutPad], x: float = 0.0, y: float = 0.0) -> LayoutFootprint: + return LayoutFootprint(reference=ref, footprint="P", x=x, y=y, pads=pads) + + +def test_bus_class_mipi_hf_clk_not_xtal(): + assert bus_class("MIPI_D0_P") == "mipi" + assert bus_class("CSI_CLK_N") == "mipi" + assert bus_class("DSI_D1_P") == "mipi" + assert bus_class("GTX_CLK") == "hf_clk" + assert bus_class("PCIE_REFCLK") == "pcie" + assert bus_class("CLK_P") == "hf_clk" + assert bus_class("XTAL_IN") is None + assert bus_class("OSCIN") is None + assert bus_class("HFXIN") is None + assert skip_si_net("USB_CC1") + assert bus_class("USB_D+") == "usb2" + + +def test_no_layout_skips_hf_geometry(): + assert check_hf_lines(_usb_graph(), {}, None) == [] + assert check_hf_lines(_usb_graph(), {}, LayoutGraph()) == [] + + +def test_pad_to_pad_usb_has_no_stub(): + layout = LayoutGraph( + footprints={ + "U1": _fp_with_pads("U1", [ + LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+"), + LayoutPad(number="2", x=0.0, y=0.4, net="USB_D-"), + ]), + "J2": _fp_with_pads("J2", [ + LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+"), + LayoutPad(number="A7", x=10.0, y=0.4, net="USB_D-"), + ], x=10.0), + }, + segments=[ + LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(0, 0.4), end=(10, 0.4), width=0.2, layer="F.Cu", net="USB_D-"), + ], + ) + findings = check_hf_lines(_usb_graph(), {}, layout) + assert [f for f in findings if f.rule_id == "PE-SI-007"] == [] + + +def test_via_is_not_a_stub(): + layout = LayoutGraph( + footprints={ + "U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]), + "J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0), + }, + segments=[ + LayoutSegment(start=(0, 0), end=(5, 0), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(5, 0), end=(10, 0), width=0.2, layer="B.Cu", net="USB_D+"), + ], + vias=[LayoutVia(x=5.0, y=0.0, net="USB_D+", drill=0.3)], + ) + findings = check_hf_lines(_usb_graph(), {}, layout) + assert [f for f in findings if f.rule_id == "PE-SI-007"] == [] + assert all(not isinstance(v, LayoutPad) for v in layout.vias) + + +def test_zone_is_not_a_track_stub(): + layout = LayoutGraph( + footprints={ + "U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]), + }, + segments=[ + LayoutSegment(start=(0, 0), end=(4, 0), width=0.2, layer="F.Cu", net="USB_D+"), + ], + zones=[LayoutZone( + net="USB_D+", layer="F.Cu", + outlines=[[(3.5, -1.0), (8.0, -1.0), (8.0, 1.0), (3.5, 1.0)]], + )], + ) + findings = check_hf_lines(_usb_graph(), {}, layout) + assert [f for f in findings if f.rule_id == "PE-SI-007"] == [] + + +def test_dangling_track_stub_vs_datasheet_mm_is_fail(): + layout = LayoutGraph( + footprints={ + "U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]), + "J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0), + }, + segments=[ + LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"), + ], + ) + cons = ComponentConstraints( + mpn="PHY", pintable=[Pin(number="1", name="D+")], + absolute_maximum_ratings=[], rules=[], + layout_rules=[{ + "kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0, + "note": "USB stub < 1 mm", "source_page": 12, + }], + ) + findings = check_hf_lines(_usb_graph(), {"PHY": cons}, layout) + stubs = [f for f in findings if f.rule_id == "PE-SI-007"] + assert len(stubs) == 1 + assert stubs[0].finding.startswith("FAIL:") + assert stubs[0].facts.startswith("stub_mm=") + assert "1" in stubs[0].requirement + complete_finding(stubs[0]) + assert stubs[0].status == "WARNING" + assert stubs[0].finding_class != "RULE" or stubs[0].provenance != "MANDATORY" + assert stubs[0].evidence_status == "SUFFICIENT" + + +def test_measured_stub_without_datasheet_mm_is_insufficient(): + layout = LayoutGraph( + footprints={ + "U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]), + "J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0), + }, + segments=[ + LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(5, 0), end=(5, 4), width=0.2, layer="F.Cu", net="USB_D+"), + ], + ) + findings = check_hf_lines(_usb_graph(), {}, layout) + stubs = [f for f in findings if f.rule_id == "PE-SI-007"] + assert len(stubs) == 1 + assert stubs[0].status == "INFO" + assert stubs[0].evidence_status == "INSUFFICIENT" + assert "90" not in (stubs[0].requirement or "") + assert "FAIL" not in stubs[0].finding + + +def test_split_under_pair_needs_stackup_and_zone(): + segs = [ + LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="USB_D+"), + ] + bare = LayoutGraph(segments=segs) + assert [f for f in check_hf_lines(_usb_graph(), {}, bare) if f.rule_id == "PE-SI-011"] == [] + + stack = LayoutStackup( + copper_layers=["F.Cu", "B.Cu"], + dielectrics=[LayoutDielectric(name="dielectric_1", er=4.5, height_mm=0.2)], + copper_thickness_mm=0.035, + ) + no_zone = LayoutGraph(segments=segs, stackup=stack) + assert [f for f in check_hf_lines(_usb_graph(), {}, no_zone) if f.rule_id == "PE-SI-011"] == [] + + covered = LayoutGraph( + segments=segs, stackup=stack, + zones=[LayoutZone( + net="GND", layer="B.Cu", + outlines=[[(-1, -2), (21, -2), (21, 2), (-1, 2)]], + )], + ) + assert [f for f in check_hf_lines(_usb_graph(), {}, covered) if f.rule_id == "PE-SI-011"] == [] + + split = LayoutGraph( + segments=segs, stackup=stack, + zones=[LayoutZone( + net="GND", layer="B.Cu", + outlines=[[(0, -2), (5, -2), (5, 2), (0, 2)]], + )], + ) + hits = [f for f in check_hf_lines(_usb_graph(), {}, split) if f.rule_id == "PE-SI-011"] + assert len(hits) == 1 + assert hits[0].status == "WARNING" + assert hits[0].finding_class == "REVIEW" + assert hits[0].facts + assert hits[0].requirement + assert hits[0].inference + complete_finding(hits[0]) + assert hits[0].status != "ERROR" + + +def test_bom_termination_is_fact_missing_is_skip(): + layout = LayoutGraph( + segments=[ + LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"), + ], + ) + g = _usb_graph() + assert [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"] == [] + + g.components["R22"] = Component( + reference="R22", value="22R", footprint="", + component_type=ComponentType.RESISTOR, + pins={"1": "USB_D+", "2": "USB_D+_PHY"}, + specs=ResistorSpecs(value_ohms=22.0, value_formatted="22"), + ) + g.nets["USB_D+_PHY"] = _net("USB_D+_PHY", ("R22", "2")) + g.nets["USB_D+"].pins.append(PinConnection(component_ref="R22", pin_number="1")) + found = [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"] + assert len(found) == 1 + assert found[0].status == "INFO" + assert found[0].evidence_status == "SUFFICIENT" + assert "R22" in found[0].facts + assert "22" in found[0].facts + assert "50 Ω" not in found[0].requirement + + +def test_no_ddr_cpu_fpga_on_usb_only_graph(): + findings = check_memory_fpga_classes(_usb_graph()) + assert findings == [] + ids = {f.rule_id for f in run_pcb_checks(_usb_graph(), {}, None)} + assert not any(str(i).startswith(("PE-DDR", "PE-CPU", "PE-FPGA")) for i in ids) + + +def test_ddr_present_class_and_nets_skip_invented_vtt(): + pins = {str(i + 1): f"DDR3_DQ{i}" for i in range(8)} + pins["20"] = "DDR3_DQS0_P" + pins["21"] = "DDR3_DQS0_N" + pins["22"] = "DDR3_CK_P" + pins["23"] = "DDR3_CK_N" + pins["24"] = "DDR3_A0" + comps = {"U10": _ic("U10", pins, mpn="MT41K256M16TW", subtype="ic.memory.ddr")} + nets = {n: _net(n, ("U10", p)) for p, n in pins.items()} + g = DesignGraph(components=comps, nets=nets) + findings = check_memory_fpga_classes(g) + for f in findings: + complete_finding(f) + assert [f.rule_id for f in findings if f.rule_id == "PE-DDR-001"] + assert [f for f in findings if f.rule_id == "PE-DDR-001"][0].status == "INFO" + assert [f for f in findings if f.rule_id == "PE-DDR-002"][0].status == "INFO" + assert not any(f.rule_id == "PE-DDR-003" for f in findings) + assert not any((f.rule_id or "").startswith("PE-CPU") for f in findings) + assert not any((f.rule_id or "").startswith("PE-FPGA") for f in findings) + + g.nets["DDR_VTT"] = _net("DDR_VTT", ("U10", "30"), ntype=NetType.POWER) + g.components["U10"].pins["30"] = "DDR_VTT" + with_vtt = check_memory_fpga_classes(g) + assert [f for f in with_vtt if f.rule_id == "PE-DDR-003"][0].status == "INFO" + + +def test_cpu_parallel_bus_only_when_data_addr_control_exist(): + mcu_pins = {str(i + 1): f"MCU_D{i}" for i in range(8)} + for i in range(8): + mcu_pins[str(20 + i)] = f"MCU_A{i}" + mcu_pins["40"] = "MCU_NWE" + mcu_pins["41"] = "MCU_NOE" + mcu_pins["42"] = "MCU_NCS" + sram_pins = dict(mcu_pins) + comps = { + "U1": _ic("U1", mcu_pins, mpn="STM32F407", subtype="ic.mcu"), + "U2": _ic("U2", sram_pins, mpn="IS61WV51216", subtype="ic.memory.sram"), + } + nets = {} + for p, n in mcu_pins.items(): + nets[n] = _net(n, ("U1", p), ("U2", p)) + g = DesignGraph(components=comps, nets=nets) + findings = check_memory_fpga_classes(g) + ids = {f.rule_id for f in findings} + assert "PE-CPU-001" in ids + assert "PE-CPU-002" in ids + assert "PE-CPU-003" in ids + assert "PE-CPU-004" in ids + assert not any(str(i).startswith("PE-DDR") for i in ids) + assert not any(str(i).startswith("PE-FPGA") for i in ids) + + gpio = DesignGraph( + components={"U1": _ic("U1", {"1": "GPIO0", "2": "GPIO1"}, mpn="MSPM0", subtype="ic.mcu")}, + nets={ + "GPIO0": _net("GPIO0", ("U1", "1")), + "GPIO1": _net("GPIO1", ("U1", "2")), + }, + ) + assert check_memory_fpga_classes(gpio) == [] + + +def test_fpga_only_when_present_config_flash_optional(): + fpga_pins = {"1": "IO_L1P", "2": "IO_L1N", "3": "VCCINT", "4": "VCCIO"} + g = DesignGraph( + components={"U5": _ic("U5", fpga_pins, mpn="XC7A35T", subtype="ic.fpga")}, + nets={ + "IO_L1P": _net("IO_L1P", ("U5", "1")), + "IO_L1N": _net("IO_L1N", ("U5", "2")), + "VCCINT": _net("VCCINT", ("U5", "3"), ntype=NetType.POWER), + "VCCIO": _net("VCCIO", ("U5", "4"), ntype=NetType.POWER), + }, + ) + findings = check_memory_fpga_classes(g) + ids = {f.rule_id for f in findings} + assert "PE-FPGA-001" in ids + assert "PE-FPGA-003" in ids + assert "PE-FPGA-002" not in ids + assert not any(str(i).startswith("PE-DDR") for i in ids) + assert not any(str(i).startswith("PE-CPU") for i in ids) + + g.components["U6"] = _ic("U6", {"1": "FPGA_CS", "2": "FPGA_MOSI"}, mpn="W25Q64", subtype="ic.memory.flash") + g.nets["FPGA_CS"] = _net("FPGA_CS", ("U6", "1"), ("U5", "10")) + g.components["U5"].pins["10"] = "FPGA_CS" + with_flash = check_memory_fpga_classes(g) + assert any(f.rule_id == "PE-FPGA-002" for f in with_flash) + + +def test_rule_catalog_hf_ids(): + for rid, domain in ( + ("PE-SI-007", "pcb"), + ("PE-SI-011", "pcb"), + ("PE-SI-012", "pcb"), + ("PE-DDR-001", "shared"), + ("PE-DDR-002", "shared"), + ("PE-DDR-003", "shared"), + ("PE-CPU-001", "shared"), + ("PE-CPU-002", "shared"), + ("PE-CPU-003", "shared"), + ("PE-CPU-004", "shared"), + ("PE-FPGA-001", "shared"), + ("PE-FPGA-002", "shared"), + ("PE-FPGA-003", "shared"), + ): + rec = lookup_rule(rid) + assert rec is not None, rid + assert rec.domain == domain + + +def test_existing_si_usb_not_polluted_by_hf_geometry(): + layout = LayoutGraph( + segments=[ + LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(0, 0.4), end=(12.5, 0.4), width=0.2, layer="F.Cu", net="USB_D-"), + ], + vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)], + ) + findings = check_si(_usb_graph(), {}, layout, [ + { + "net_name": "USB_D+", "partner_net_name": "USB_D-", + "z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0, + "length_mm": 10.0, "topologies": ["MICROSTRIP"], + }, + { + "net_name": "USB_D-", "partner_net_name": "USB_D+", + "z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0, + "length_mm": 12.5, "topologies": ["MICROSTRIP"], + }, + ]) + assert any(f.rule_id == "PE-SI-010" for f in findings) + assert all(f.rule_id != "PE-SI-002" for f in findings) diff --git a/tests/test_interface_class_check.py b/tests/test_interface_class_check.py index 05060ba..b20ba9e 100644 --- a/tests/test_interface_class_check.py +++ b/tests/test_interface_class_check.py @@ -559,7 +559,9 @@ def test_run_pcb_checks_skips_absent_interfaces(): assert "PE-USBC-001" not in ids assert "PE-ETH-001" not in ids assert "PE-POE-001" not in ids - if_f = [f for f in findings if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE", "PE-DDR"))] + if_f = [f for f in findings if (f.rule_id or "").startswith( + ("PE-USBC", "PE-ETH", "PE-POE", "PE-DDR", "PE-CPU", "PE-FPGA") + )] assert if_f == [] diff --git a/tests/test_periscope_frontend_upload_rewrite.py b/tests/test_periscope_frontend_upload_rewrite.py index 315ea26..6d35aa1 100644 --- a/tests/test_periscope_frontend_upload_rewrite.py +++ b/tests/test_periscope_frontend_upload_rewrite.py @@ -6,14 +6,17 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "periscope" / "src" / "frontend" / "src" / "components/upload/file-upload-zone.tsx" +SKIP = ROOT / "periscope" / "src" / "frontend" / "src" / "lib" / "kicad-project-files.ts" def test_upload_zone_is_src(): text = SRC.read_text(encoding="utf-8") + skip = SKIP.read_text(encoding="utf-8") assert "Native Periscope overlay" not in text[:400] assert "export function FileUploadZone" in text assert "webkitGetAsEntry" in text assert "webkitRelativePath" in text - assert "-backups" in text + assert "skipWalkDirName" in text + assert "-backups" in skip assert "border-dashed" in text assert "border-emerald-500/40" in text diff --git a/tests/test_si_check.py b/tests/test_si_check.py index de2b1fc..3e2e4de 100644 --- a/tests/test_si_check.py +++ b/tests/test_si_check.py @@ -78,6 +78,9 @@ def test_skip_i2c_gpio_cc_regn(): assert bus_class("SGMII_TX_P") == "sgmii" assert bus_class("DDR3_DQ0") == "ddr3_dq" assert bus_class("DDR3_DQS0_P") == "ddr3_dqs" + assert bus_class("MIPI_D0_P") == "mipi" + assert bus_class("GTX_CLK") == "hf_clk" + assert bus_class("XTAL_IN") is None assert bus_class("USB_CC1") is None assert bus_class("I2C_SCL") is None