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