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.
180 lines
7.5 KiB
Python
180 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate extraction output against the pintable schema."""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
SCHEMA_PATH = Path(__file__).parent / "schema.json"
|
|
|
|
|
|
def validate(data: dict) -> list[str]:
|
|
"""Return list of validation errors (empty = valid)."""
|
|
errors = []
|
|
schema = json.loads(SCHEMA_PATH.read_text())
|
|
|
|
for field in schema.get("required", []):
|
|
if field not in data:
|
|
errors.append(f"Missing required field: {field}")
|
|
|
|
if "component_subtype" in data:
|
|
st = data["component_subtype"]
|
|
if not isinstance(st, str) or "." not in st:
|
|
errors.append(f"component_subtype must be dotted path, got: {st!r}")
|
|
|
|
if "package_info" in data:
|
|
pkg = data["package_info"]
|
|
for f in ["base_family", "package", "pin_count"]:
|
|
if f not in pkg:
|
|
errors.append(f"package_info missing required field: {f}")
|
|
if "pin_count" in pkg and not isinstance(pkg["pin_count"], int):
|
|
errors.append(f"package_info.pin_count must be integer, got: {type(pkg['pin_count']).__name__}")
|
|
|
|
if "pintable" in data:
|
|
pins = data["pintable"]
|
|
if not isinstance(pins, list) or len(pins) == 0:
|
|
errors.append("pintable must be a non-empty array")
|
|
else:
|
|
numbers = []
|
|
for i, pin in enumerate(pins):
|
|
if "number" not in pin:
|
|
errors.append(f"pintable[{i}] missing required field: number")
|
|
if "name" not in pin:
|
|
errors.append(f"pintable[{i}] missing required field: name")
|
|
if "number" in pin:
|
|
numbers.append(pin["number"])
|
|
dupes = [n for n in set(numbers) if numbers.count(n) > 1]
|
|
if dupes:
|
|
errors.append(f"Duplicate pin numbers: {dupes}")
|
|
|
|
names = {
|
|
str(p.get("number")): str(p.get("name") or "").upper()
|
|
for p in pins if "number" in p
|
|
}
|
|
pin1 = names.get("1", "")
|
|
looks_like_rf_die = bool(
|
|
re.search(r"\bANT\b|^CHIP_PU$|^XTAL", pin1)
|
|
and any("XTAL" in n for n in names.values())
|
|
)
|
|
mpn = str(data.get("mpn") or "")
|
|
is_module_mpn = bool(re.search(r"WROOM|WROVER|\bMODULE\b|\bSIP\b", mpn, re.I))
|
|
if looks_like_rf_die and is_module_mpn:
|
|
errors.append(
|
|
"Pin 1 looks like a bare RF SoC ball (ANT/CHIP_PU) with XTAL "
|
|
"pins in the table. Module footprints (WROOM) use pad 1 = GND; "
|
|
"extract the module landing-pad table, not the die map."
|
|
)
|
|
|
|
if "absolute_maximum_ratings" in data:
|
|
ratings = data["absolute_maximum_ratings"]
|
|
if ratings is not None and not isinstance(ratings, list):
|
|
errors.append("absolute_maximum_ratings must be an array")
|
|
elif isinstance(ratings, list):
|
|
for i, row in enumerate(ratings):
|
|
if not isinstance(row, dict):
|
|
errors.append(f"absolute_maximum_ratings[{i}] must be an object")
|
|
continue
|
|
for f in ("parameter", "unit", "source_page"):
|
|
if f not in row:
|
|
errors.append(
|
|
f"absolute_maximum_ratings[{i}] missing required field: {f}"
|
|
)
|
|
|
|
if "layout_rules" in data and data["layout_rules"] is not None:
|
|
if not isinstance(data["layout_rules"], list):
|
|
errors.append("layout_rules must be an array")
|
|
else:
|
|
kinds = {
|
|
"decoupling_proximity", "thermal_via", "keepout", "length_match",
|
|
"impedance", "max_length", "spacing", "ref_plane", "si_via",
|
|
"layer", "series_resistor", "return_path", "si",
|
|
"emi", "common_mode", "shield",
|
|
}
|
|
for i, row in enumerate(data["layout_rules"]):
|
|
if not isinstance(row, dict):
|
|
errors.append(f"layout_rules[{i}] must be an object")
|
|
continue
|
|
kind = row.get("kind")
|
|
if kind not in kinds:
|
|
errors.append(f"layout_rules[{i}] unknown kind: {kind!r}")
|
|
continue
|
|
dist = row.get("max_distance_mm")
|
|
if dist is not None and dist is not False:
|
|
if isinstance(dist, bool):
|
|
errors.append(
|
|
f"layout_rules[{i}].max_distance_mm must be a number or null"
|
|
)
|
|
elif isinstance(dist, (int, float)):
|
|
if float(dist) <= 0:
|
|
errors.append(
|
|
f"layout_rules[{i}].max_distance_mm must be > 0"
|
|
)
|
|
else:
|
|
try:
|
|
v = float(str(dist).strip())
|
|
except (TypeError, ValueError):
|
|
errors.append(
|
|
f"layout_rules[{i}].max_distance_mm must be numeric "
|
|
f"or null (got {dist!r}) — do not invent distances; "
|
|
f"use null when the PDF only says 'close'"
|
|
)
|
|
else:
|
|
if v <= 0:
|
|
errors.append(
|
|
f"layout_rules[{i}].max_distance_mm must be > 0"
|
|
)
|
|
via = row.get("min_via_count")
|
|
if via is not None and via is not False and not isinstance(via, bool):
|
|
if isinstance(via, int):
|
|
if via <= 0:
|
|
errors.append(
|
|
f"layout_rules[{i}].min_via_count must be > 0"
|
|
)
|
|
else:
|
|
try:
|
|
iv = int(float(str(via).strip()))
|
|
except (TypeError, ValueError):
|
|
errors.append(
|
|
f"layout_rules[{i}].min_via_count must be an integer "
|
|
f"or null (got {via!r})"
|
|
)
|
|
else:
|
|
if iv <= 0:
|
|
errors.append(
|
|
f"layout_rules[{i}].min_via_count must be > 0"
|
|
)
|
|
page = row.get("source_page")
|
|
if page is not None and not isinstance(page, int):
|
|
errors.append(
|
|
f"layout_rules[{i}].source_page must be an integer or null"
|
|
)
|
|
same = row.get("same_layer")
|
|
if same is not None and not isinstance(same, bool):
|
|
errors.append(
|
|
f"layout_rules[{i}].same_layer must be a boolean or null"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python3 validate.py '<json string>'")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
data = json.loads(sys.argv[1])
|
|
except json.JSONDecodeError as e:
|
|
print(f"INVALID JSON: {e}")
|
|
sys.exit(1)
|
|
|
|
errors = validate(data)
|
|
if errors:
|
|
print("VALIDATION FAILED:")
|
|
for err in errors:
|
|
print(f" - {err}")
|
|
sys.exit(1)
|
|
else:
|
|
print("VALIDATION PASSED")
|