Files
periscope/backend/periscopex/layout_rules.py
T
michele 65a91419a2 Check ImpedenceFinder against datasheet SI rules, not a dump table.
Each layout_rules SI requirement (diff/SE Z, skew, max length, spacing,
ref plane, vias, layer, return, series R) is one PASS/FAIL/MARGIN finding
with FACT and REQUIREMENT. USB/HDMI/PCIe/ETH/LVDS/DDR only; I2C GPIO CC
and analog REGN are skipped. No invented 90 Ω.
2026-09-20 10:45:27 +02:00

111 lines
4.0 KiB
Python

"""Validate datasheet layout_rules. Distances stay null unless numeric."""
from __future__ import annotations
from typing import Any
from packaging.version import Version
KNOWN_KINDS = frozenset({
"decoupling_proximity", "thermal_via", "keepout", "length_match",
"impedance", "max_length", "spacing", "ref_plane", "si_via",
"layer", "series_resistor", "return_path", "si",
})
def _num(v: Any) -> float | None:
if v is None or v is False:
return None
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).strip())
except (TypeError, ValueError):
return None
def has_any_layout_rule(raw: object) -> bool:
"""True when extraction already produced at least one structured rule."""
if not isinstance(raw, list):
return False
for row in raw:
if isinstance(row, dict) and str(row.get("kind") or "").strip() in KNOWN_KINDS:
return True
return False
def needs_layout_rules_refresh(
data: dict,
*,
min_scan_version: str,
) -> bool:
"""True when layout_rules are empty and the extract predates the scan version.
After a successful extract at ``min_scan_version`` or newer, an empty
``layout_rules`` list means the datasheet had no guidance — do not loop.
"""
if has_any_layout_rule(data.get("layout_rules")):
return False
ver = str(data.get("model_version") or "0.0.0")
if not min_scan_version or min_scan_version == "0.0.0":
return False
try:
return Version(ver) < Version(min_scan_version)
except Exception:
return True
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
"""Return (normalized rows, errors). Empty list is a valid skip."""
if not raw:
return [], []
if not isinstance(raw, list):
return [], ["layout_rules must be an array"]
ok: list[dict] = []
errors: list[str] = []
for i, row in enumerate(raw):
if not isinstance(row, dict):
errors.append(f"layout_rules[{i}] must be an object")
continue
kind = str(row.get("kind") or "").strip()
if kind not in KNOWN_KINDS:
errors.append(f"layout_rules[{i}] unknown kind {kind!r}")
continue
dist = _num(row.get("max_distance_mm"))
via = row.get("min_via_count")
via_i = None
if isinstance(via, int) and not isinstance(via, bool):
via_i = via
elif via is not None:
n = _num(via)
via_i = int(n) if n is not None else None
page = row.get("source_page")
page_i = int(page) if isinstance(page, int) else None
mx_via = row.get("max_via_count")
mx_via_i = int(mx_via) if isinstance(mx_via, int) and not isinstance(mx_via, bool) else None
ok.append({
"kind": kind,
"pin": row.get("pin"),
"cap_value_hint": row.get("cap_value_hint"),
"max_distance_mm": dist,
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
"min_via_count": via_i,
"max_via_count": mx_via_i,
"net_class": row.get("net_class"),
"note": row.get("note"),
"source_page": page_i,
"z0_ohm": _num(row.get("z0_ohm")),
"zdiff_ohm": _num(row.get("zdiff_ohm")),
"tolerance_pct": _num(row.get("tolerance_pct")),
"z_min_ohm": _num(row.get("z_min_ohm")),
"z_max_ohm": _num(row.get("z_max_ohm")),
"topology": row.get("topology") if isinstance(row.get("topology"), str) else None,
"min_spacing_mm": _num(row.get("min_spacing_mm")),
"value_ohms": _num(row.get("value_ohms")),
"ref_plane": row.get("ref_plane") if isinstance(row.get("ref_plane"), str) else None,
"parameter": row.get("parameter") if isinstance(row.get("parameter"), str) else None,
})
return ok, errors