Package/pinout/ratings mismatches, SPOF as REVIEW, EMI only with a datasheet quote, and Tj from copper+vias+θJA when every parameter exists. Re-extract SI layout_rules from 1.12.0; ImpedenceFinder license stays UNKNOWN.
134 lines
4.7 KiB
Python
134 lines
4.7 KiB
Python
"""Validate datasheet layout_rules. Distances stay null unless numeric."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from packaging.version import Version
|
|
|
|
SI_KINDS = frozenset({
|
|
"impedance", "length_match", "max_length", "spacing",
|
|
"ref_plane", "si_via", "layer", "series_resistor", "return_path", "si",
|
|
})
|
|
EMI_KINDS = frozenset({"emi", "common_mode", "shield"})
|
|
KNOWN_KINDS = frozenset({
|
|
"decoupling_proximity", "thermal_via", "keepout",
|
|
}) | SI_KINDS | EMI_KINDS
|
|
|
|
|
|
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 has_si_layout_rule(raw: object) -> bool:
|
|
if not isinstance(raw, list):
|
|
return False
|
|
for row in raw:
|
|
if isinstance(row, dict) and str(row.get("kind") or "").strip() in SI_KINDS:
|
|
return True
|
|
return False
|
|
|
|
|
|
def needs_layout_rules_refresh(
|
|
data: dict,
|
|
*,
|
|
min_scan_version: str,
|
|
) -> bool:
|
|
"""True when this extract should be re-run against the current pintable skill.
|
|
|
|
After a successful extract at ``min_scan_version`` or newer, empty
|
|
``layout_rules`` means the datasheet had no guidance — do not loop.
|
|
|
|
From skill 1.11.0, SI kinds are first-class. An older JSON that only
|
|
has decoupling/thermal rules is stale and must be re-extracted so
|
|
ImpedenceFinder checks are not starved.
|
|
"""
|
|
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:
|
|
stale = Version(ver) < Version(min_scan_version)
|
|
except Exception:
|
|
return True
|
|
if not stale:
|
|
return False
|
|
try:
|
|
need_si = Version(min_scan_version) >= Version("1.11.0")
|
|
except Exception:
|
|
need_si = False
|
|
if need_si:
|
|
return not has_si_layout_rule(data.get("layout_rules"))
|
|
return not has_any_layout_rule(data.get("layout_rules"))
|
|
|
|
|
|
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
|