mohan-bee/compact-pir

Compact 3.3 V ESP8684 wireless motion/GPIO breakout board with LiPo regulation, 40 MHz crystal, U.FL antenna matching, digital PIR sensor, switched LED, decoupling, and dual 10-pin headers.

Version
1.0.2
License
unset
Stars
0

validation/check-native-geometry.py

#!/usr/bin/env python3
"""Read-only native routing checks; writes only a JSON report next to its input.

This is not a replacement for native clearance/physical connectivity DRC.
Source connectivity is compared independently of generated IDs. Protected-area
checks use capsule/disc geometry including full copper width, in Circuit JSON
coordinates (X right, Y up). No external Python dependencies are required.
"""
import argparse
from collections import Counter, defaultdict
import hashlib
import json
import math
from pathlib import Path

REGIONS = {
    "rf_bottom_reference": [-13.7, -2.8, -6.1, 3.1],
    "crystal_bottom_reference": [-5.2, 2.8, -0.65, 7.9],
}
LAYERS = {"top", "bottom"}
EPS = 1e-8


class NetResolver:
    def __init__(self, rows):
        self.parents = {}
        self.nets = {r["source_net_id"]: r for r in rows if r["type"] == "source_net"}
        self.traces = {r["source_trace_id"]: r for r in rows if r["type"] == "source_trace"}
        self.ports = {r["source_port_id"]: r for r in rows if r["type"] == "source_port"}
        self.pcb_ports = {r["pcb_port_id"]: r for r in rows if r["type"] == "pcb_port"}
        self.pcb_traces = {r["pcb_trace_id"]: r for r in rows if r["type"] == "pcb_trace"}
        for row in [*self.nets.values(), *self.traces.values(), *self.ports.values()]:
            own = row[row["type"] + "_id"]
            self.find(own)
            key = row.get("subcircuit_connectivity_map_key")
            if key:
                self.join(own, "key:" + key)
            for field in ("connected_source_port_ids", "connected_source_net_ids"):
                for other in row.get(field, []):
                    self.join(own, other)
        for row in rows:
            if row["type"] == "source_component_internal_connection":
                ids = row.get("source_port_ids", [])
                for other in ids[1:]:
                    self.join(ids[0], other)
        self.names = defaultdict(set)
        for nid, row in self.nets.items():
            self.names[self.find(nid)].add(row["name"])

    def find(self, item):
        if item not in self.parents:
            self.parents[item] = item
        while self.parents[item] != item:
            self.parents[item] = self.parents[self.parents[item]]
            item = self.parents[item]
        return item

    def join(self, a, b):
        self.parents[self.find(b)] = self.find(a)

    def source_names(self, item):
        return set(self.names.get(self.find(item), set()))

    def resolve(self, row, include_parent_trace=True):
        """Use explicit source identity/connectivity keys, never parse an ID suffix."""
        names = set()
        for field in ("source_net_id", "source_trace_id", "source_port_id"):
            if row.get(field):
                names.update(self.source_names(row[field]))
        if row.get("subcircuit_connectivity_map_key"):
            names.update(self.source_names("key:" + row["subcircuit_connectivity_map_key"]))
        if row.get("connection_name") in self.nets:
            names.update(self.source_names(row["connection_name"]))
        if include_parent_trace and row.get("pcb_trace_id") in self.pcb_traces:
            parent = self.pcb_traces[row["pcb_trace_id"]]
            if parent is not row:
                names.update(self.resolve(parent, False))
        for port_id in row.get("connectsTo", []):
            port = self.pcb_ports.get(port_id)
            if port:
                names.update(self.source_names(port["source_port_id"]))
        return names


def normalized_pins(rows, resolver):
    components = {r["source_component_id"]: r for r in rows if r["type"] == "source_component"}
    result = {}
    for pid, port in resolver.ports.items():
        component = components.get(port.get("source_component_id"))
        if not component:
            continue  # Native stitching vias have synthetic ports, not chip pins.
        pin = str(port.get("pin_number", port["name"]))
        identity = component["name"] + ".pin" + pin
        value = sorted(resolver.source_names(pid))
        if identity in result and result[identity] != value:
            raise ValueError("Conflicting duplicate source pin: " + identity)
        result[identity] = value
    return result


def distance_point_rect(p, rect):
    return math.hypot(max(rect[0] - p[0], 0, p[0] - rect[2]),
                      max(rect[1] - p[1], 0, p[1] - rect[3]))


def distance_point_segment(p, a, b):
    dx, dy = b[0] - a[0], b[1] - a[1]
    den = dx * dx + dy * dy
    t = 0 if den == 0 else max(0, min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / den))
    return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)


def cross(a, b, c):
    return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])


def distance_segments(a, b, c, d):
    if cross(a, b, c) * cross(a, b, d) < 0 and cross(c, d, a) * cross(c, d, b) < 0:
        return 0.0
    return min(distance_point_segment(a, c, d), distance_point_segment(b, c, d),
               distance_point_segment(c, a, b), distance_point_segment(d, a, b))


def distance_segment_rect(a, b, rect):
    if distance_point_rect(a, rect) == 0 or distance_point_rect(b, rect) == 0:
        return 0.0
    corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[2], rect[3]), (rect[0], rect[3])]
    return min(distance_segments(a, b, c, d) for c, d in zip(corners, corners[1:] + corners[:1]))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("circuit", type=Path)
    parser.add_argument("--reference", type=Path,
                        default=Path(__file__).resolve().parents[2] / "outputs/circuit.json")
    args = parser.parse_args()
    raw = args.circuit.read_bytes()
    rows = json.loads(raw)
    baseline = json.loads(args.reference.read_text())
    resolver, old_resolver = NetResolver(rows), NetResolver(baseline)
    pins, old_pins = normalized_pins(rows, resolver), normalized_pins(baseline, old_resolver)
    pin_differences = [{"pin": key, "reference_nets": old_pins.get(key), "current_nets": pins.get(key)}
                       for key in sorted(pins.keys() | old_pins.keys()) if pins.get(key) != old_pins.get(key)]
    components = sorted(r["name"] for r in rows if r["type"] == "source_component")
    old_components = sorted(r["name"] for r in baseline if r["type"] == "source_component")
    boards = [r for r in rows if r["type"] == "pcb_board"]
    dimensions_ok = len(boards) == 1 and all(abs(boards[0].get(k, 0) - v) < EPS
                                           for k, v in (("width", 29), ("height", 25), ("num_layers", 2)))
    layer_errors, unresolved, protected, invalid_geometry = [], [], [], []
    bottom_segments = 0
    non_ground_vias = 0
    route_via_checks = 0

    def check_layers(row, declared, context):
        for layer in declared:
            if layer not in LAYERS:
                layer_errors.append({"item": row.get(row["type"] + "_id"), "context": context, "layer": layer})

    def check_disc(row, x, y, diameter, names, context):
        if names == {"GND"}:
            return
        if not math.isfinite(diameter) or diameter <= 0:
            invalid_geometry.append({"item": context, "reason": "Missing/invalid via copper diameter"})
            return
        for name, rect in REGIONS.items():
            penetration = diameter / 2 - distance_point_rect((x, y), rect)
            if penetration > EPS:
                protected.append({"item": context, "kind": "via", "region": name, "nets": sorted(names),
                                  "center_mm": [x, y], "copper_diameter_mm": diameter,
                                  "copper_intrusion_mm": penetration})

    for row in rows:
        typ = row["type"]
        if typ in ("pcb_smtpad", "pcb_plated_hole", "pcb_via", "pcb_port", "pcb_copper_pour"):
            declared = list(row.get("layers", []))
            for key in ("layer", "from_layer", "to_layer"):
                if row.get(key):
                    declared.append(row[key])
            check_layers(row, declared, "copper item")
        if typ not in ("pcb_trace", "pcb_via"):
            continue
        names = resolver.resolve(row)
        identity = row[typ + "_id"]
        if not names:
            unresolved.append({"item": identity, "kind": typ})
        if typ == "pcb_via":
            non_ground_vias += names != {"GND"}
            check_disc(row, row["x"], row["y"], row.get("outer_diameter", 0), names, identity)
            continue
        route = row.get("route", [])
        for i, point in enumerate(route):
            declared = [point[k] for k in ("layer", "from_layer", "to_layer") if point.get(k)]
            check_layers(row, declared, "route point " + str(i))
            if point.get("route_type") == "via":
                route_via_checks += 1
                check_disc(row, point["x"], point["y"],
                           point.get("via_diameter", point.get("outer_diameter", 0)), names,
                           identity + ":route_via" + str(i))
        for i, (a, b) in enumerate(zip(route, route[1:])):
            at, bt = a.get("route_type"), b.get("route_type")
            if at == bt == "wire":
                if a.get("layer") != b.get("layer"):
                    invalid_geometry.append({"item": identity, "segment": i, "reason": "Wire layer transition lacks explicit via point"})
                    continue
                layer, width = a.get("layer"), a.get("width", b.get("width", 0))
            elif at == "wire" and bt == "via":
                layer, width = a.get("layer"), a.get("width", 0)
            elif at == "via" and bt == "wire":
                layer, width = b.get("layer"), b.get("width", 0)
            else:
                continue
            if not math.isfinite(width) or width <= 0:
                invalid_geometry.append({"item": identity, "segment": i, "reason": "Missing/invalid conductor width"})
                continue
            if layer != "bottom" or names == {"GND"}:
                continue
            bottom_segments += 1
            start, end = (a["x"], a["y"]), (b["x"], b["y"])
            for name, rect in REGIONS.items():
                penetration = width / 2 - distance_segment_rect(start, end, rect)
                if penetration > EPS:
                    protected.append({"item": identity, "segment": i, "kind": "bottom trace", "region": name,
                                      "nets": sorted(names), "start_mm": start, "end_mm": end,
                                      "width_mm": width, "copper_intrusion_mm": penetration})
    errors = [r for r in rows if r["type"].endswith("_error")]
    success = (dimensions_ok and components == old_components and not pin_differences and not layer_errors
               and not unresolved and not protected and not invalid_geometry and not errors)
    report = {
        "input": str(args.circuit.resolve()), "input_sha256": hashlib.sha256(raw).hexdigest(),
        "reference": str(args.reference.resolve()),
        "scope": "Dimensions, allowed copper layers, source component/pin-net equivalence, full-width protected RF/crystal trace/via geometry, emitted build errors. Requires separate physical clearance/connectivity DRC.",
        "result": "PASS" if success else "FAIL",
        "board": {k: boards[0].get(k) for k in ("width", "height", "num_layers", "thickness")} if boards else None,
        "dimensions_29x25mm_two_layers": dimensions_ok,
        "counts": dict(Counter(r["type"] for r in rows)),
        "component_references_equal": components == old_components,
        "normalized_pin_count": len(pins), "reference_pin_count": len(old_pins),
        "normalized_component_pin_net_differences": pin_differences,
        "invalid_copper_layers": layer_errors, "unresolved_trace_or_via_nets": unresolved,
        "protected_rectangles_circuit_json_mm": REGIONS,
        "non_ground_bottom_trace_segments_checked": bottom_segments,
        "non_ground_via_records_checked": non_ground_vias,
        "inline_route_via_points_checked": route_via_checks,
        "protected_region_violations": protected,
        "invalid_route_geometry": invalid_geometry,
        "emitted_build_errors": errors,
    }
    output = args.circuit.with_name(args.circuit.stem + ".geometry-check.json")
    output.write_text(json.dumps(report, indent=2) + "\n")
    print(json.dumps({"report": str(output), "result": report["result"],
                      "dimensions_match": dimensions_ok, "pin_net_differences": len(pin_differences),
                      "invalid_layers": len(layer_errors), "unresolved_nets": len(unresolved),
                      "protected_region_violations": len(protected), "invalid_route_geometry": len(invalid_geometry),
                      "emitted_build_errors": len(errors)}, indent=2))
    raise SystemExit(0 if success else 1)


if __name__ == "__main__":
    main()