pixalynx/pixal-gps

These files define two separate printed circuit boards: a small, two-layer battery cartridge with protection circuitry and contact pads for a pouch cell, and a larger, two-layer wireless charging dock featuring USB-C input, a resonant coil, and wireless power transmission components.

Version
0.1.2
License
unset
Stars
0

review/source-build/scripts/validate.ts

/** Design assertions against the built Circuit JSON of each board (run `bun run build` first).
 * These check the netlist that tscircuit actually produced, so a wiring mistake in the TSX
 * shows up here even if the schematic looks right. Not a substitute for hardware bring-up. */
import assert from "node:assert/strict"
import { existsSync } from "node:fs"

type El = Record<string, any>
function load(board: string): El[] {
  const p = `dist/${board}/circuit.json`
  assert(existsSync(p), `${p} missing: run bun run build`)
  return JSON.parse(require("node:fs").readFileSync(p, "utf8"))
}
class Netlist {
  portNet = new Map<string, string>()
  netPorts = new Map<string, Set<string>>()
  errors: El[]
  constructor(public json: El[]) {
    const comp = new Map(json.filter((e) => e.type === "source_component").map((e) => [e.source_component_id, e.name]))
    const ports = new Map(json.filter((e) => e.type === "source_port").map((e) => [e.source_port_id, e]))
    const nets = new Map(json.filter((e) => e.type === "source_net").map((e) => [e.source_net_id, e.name]))
    const keyName = new Map<string, string>()
    for (const t of json.filter((e) => e.type === "source_trace")) {
      const netNames = (t.connected_source_net_ids ?? []).map((id: string) => nets.get(id)).filter(Boolean)
      for (const pid of t.connected_source_port_ids ?? []) {
        const p = ports.get(pid); if (!p) continue
        const key = p.subcircuit_connectivity_map_key
        if (netNames.length && key) keyName.set(key, netNames[0])
      }
    }
    for (const p of ports.values()) {
      const key = p.subcircuit_connectivity_map_key
      const name = key ? keyName.get(key) ?? `~${key}` : null
      const sel = `${comp.get(p.source_component_id)}.${p.name}`
      if (name) {
        this.portNet.set(sel, name); if (!this.netPorts.has(name)) this.netPorts.set(name, new Set()); this.netPorts.get(name)!.add(sel)
        for (const h of p.port_hints ?? []) this.portNet.set(`${comp.get(p.source_component_id)}.${h}`, name)
      }
    }
    this.errors = json.filter((e) => typeof e.type === "string" && e.type.endsWith("_error"))
  }
  net(sel: string) { return this.portNet.get(sel) ?? null }
  same(a: string, b: string) { const n = this.net(a); return n !== null && n === this.net(b) }
}
let checks = 0
const ok = (v: unknown, msg: string) => { assert(v, msg); checks++ }

// ---------------------------------------------------------------- tracker (A)
{
  const n = new Netlist(load("tracker"))
  ok(n.net("U1.VDD") === "VDD_NRF" && n.same("FB1.B", "U1.VDD") && n.net("FB1.A") === "VSYS", "nRF9151 VDD fed from VSYS through the ferrite bead")
  ok(["C1", "C2"].every((c) => n.net(`${c}.pin1`) === "VDD_NRF" && n.net(`${c}.pin2`) === "GND"), "1 uF pair after the bead")
  ok(["C3", "C4"].every((c) => n.net(`${c}.pin1`) === "VSYS" && n.net(`${c}.pin2`) === "GND"), "bulk caps on VSYS")
  ok(n.same("R1.pin2", "U1.ENABLE") && n.net("R1.pin1") === "VDD_NRF", "ENABLE pulled to VDD through 10k")
  ok(n.net("U1.VDD_GPIO") === "V1V8" && n.net("C6.pin1") === "V1V8", "VDD_GPIO on the 1.8 V rail")
  ok(n.same("U1.DEC0", "C7.pin2") && n.netPorts.get(n.net("U1.DEC0")!)!.size === 2, "DEC0 carries exactly its 4.7 uF and nothing else")
  ok(n.same("U1.nRESET", "C8.pin1") && n.same("U1.nRESET", "R2.pin2") && n.netPorts.get(n.net("U1.nRESET")!)!.size === 3, "nRESET: only C8, R2 (no pull-up)")
  ok(n.net("U1.SIM_DET") === null, "SIM_DET left floating (Nordic)")
  for (let p = 81; p <= 104; p++) ok(n.net(`U1.RESERVED_${p}`) === null, `RESERVED_${p} unconnected`)
  ok(["RESERVED_31", "RESERVED_32", "RESERVED_33", "MAGPIO0", "MAGPIO1", "MAGPIO2", "SDATA", "SCLK", "VIO", "COEX1", "COEX2"].every((p) => n.net(`U1.${p}`) === null), "reserved / RFFE / unused COEX pins open")
  ok(n.net("U1.COEX0") === "COEX0" && n.net("R5.pin1") === "COEX0" && n.net("R5.pin2") === "GND", "COEX0 has its mandatory 100k pull-down")
  ok(n.same("R4.pin2", "U2.VEN") && n.net("R4.pin1") === "COEX0", "LNA enable from COEX0 through 1k")
  ok(n.same("U2.AMP_IN", "L2.pin1") && n.same("U2.FILT_OUT", "L2.pin2"), "9.1 nH inter-stage inductor")
  ok(n.same("U2.RF_OUT", "C12.pin2") && n.same("C12.pin1", "U1.GPS"), "LNA output to the GPS pin through the DC block")
  ok(n.same("U2.RF_IN", "C13.pin1") && n.same("C13.pin2", "J2.SIG"), "GNSS connector to the LNA input")
  ok(n.same("U1.ANT", "C10.pin2") && n.same("C10.pin2", "R3.pin2") && n.same("R3.pin1", "J1.SIG"), "LTE pi-match chain to J1")
  ok(n.same("U1.AUX", "C18.pin2") && n.same("C18.pin1", "R6.pin1") && n.same("R6.pin2", "U2.RF_IN"), "AUX single-antenna path through C18/R6")
  ok(n.net("FB2.A") === "V1V8" && n.same("FB2.B", "U2.VCC"), "LNA supply from 1.8 V through a bead")
  // SIM
  ok(n.same("U1.SIM_RST", "U4.DATA1_IN") && n.same("U1.SIM_CLK", "U4.CLK_IN") && n.same("U1.SIM_IO", "U4.DATA2_IN"), "SIM lines enter the TPD3F303")
  ok(n.same("U4.DATA1_OUT", "J3.C2") && n.same("U4.CLK_OUT", "J3.C3") && n.same("U4.DATA2_OUT", "J3.C7"), "filtered SIM lines reach the socket contacts")
  ok(n.net("U1.SIM_1V8") === "SIM_1V8" && n.net("J3.C1") === "SIM_1V8" && n.net("U4.VCC") === "SIM_1V8" && n.net("C9.pin2") === "SIM_1V8" && n.net("C46.pin1") === "SIM_1V8", "SIM_1V8 with its capacitors and clamp")
  ok(n.net("J3.C6") === null, "SIM VPP unused")
  // nPM1300
  ok(n.net("U3.VBAT") === "VBAT" && n.net("P1.P") === "VBAT" && n.net("C20.pin1") === "VBAT", "battery contact to VBAT")
  ok(n.net("U3.VBUS") === "VBUS_QI" && n.net("U5.OUT") === "VBUS_QI", "Qi receiver output feeds VBUS")
  ok(n.net("U3.CC1") === "GND" && n.net("U3.CC2") === "GND", "CC pins grounded (not a USB-C input)")
  ok(n.same("U3.VBUSOUT", "C23.pin1") && n.netPorts.get(n.net("U3.VBUSOUT")!)!.size === 2, "VBUSOUT has its mandatory 1 uF and nothing else")
  ok(n.same("U3.VSET1", "R9.pin1") && n.net("R9.pin2") === "GND", "VSET1 47k -> 1.8 V")
  ok(n.net("U3.VSET2") === "GND" && n.net("U3.VOUT2") === "VSYS" && n.net("U3.SW2") === null, "BUCK2 disabled per Nordic config 3")
  ok(n.net("U3.LSIN2") === "GND" && n.net("U3.LSOUT2") === "GND", "LDO2 unused, grounded")
  ok(n.same("U3.SW1", "L1.pin2") && n.net("L1.pin1") === "V1V8" && n.net("U3.VOUT1") === "V1V8" && n.net("U3.VDDIO") === "V1V8", "BUCK1 inductor and 1.8 V outputs")
  ok(n.net("U3.PVDD") === "VSYS" && n.net("C24.pin1") === "VSYS", "PVDD on VSYS with its input cap")
  ok(n.net("U3.NTC") === "BAT_NTC" && n.net("P3.P") === "BAT_NTC" && n.net("C28.pin1") === "BAT_NTC", "NTC contact to the PMIC")
  ok(n.same("U3.SHPHLD", "SW1.A") && n.net("SW1.B") === "GND", "SHPHLD button")
  ok(n.same("U3.GPIO0", "U1.P0_30"), "PMIC interrupt to P0.30")
  ok(n.same("U3.SDA", "U1.P0_28") && n.same("U3.SCL", "U1.P0_29") && n.same("U6.SDA", "U1.P0_28") && n.same("U6.SCL", "U1.P0_29"), "one I2C bus")
  ok(n.net("R10.pin2") === "V1V8" && n.net("R11.pin2") === "V1V8", "I2C pull-ups to 1.8 V")
  ok(n.net("D1.anode") === "VSYS" && n.same("D1.cathode", "U3.LED1") && n.net("D2.anode") === "VSYS" && n.same("D2.cathode", "U3.LED2"), "LEDs on the PMIC current sinks")
  // accelerometer
  ok(n.net("U6.VDD") === "V1V8" && n.net("U6.VDD_IO") === "V1V8" && n.net("U6.CS") === "V1V8" && n.net("U6.SA0") === "GND", "LIS2DW12 I2C mode, address 0x18")
  ok(n.same("U6.INT1", "U1.P0_31") && n.same("U6.INT2", "U1.P0_00"), "motion interrupts")
  // Qi receiver
  ok(n.net("U5.AD") === "GND" && n.net("U5.AD_EN") === null, "AD grounded, AD-EN floating (wireless-only)")
  ok(n.same("U5.EN1", "U5.EN2") && n.same("U5.EN1", "U1.P0_01"), "EN1/EN2 tied to P0.01")
  ok(n.same("U5.ILIM", "R12.pin1") && n.same("R12.pin2", "U5.FOD") && n.same("R13.pin1", "U5.FOD") && n.net("R13.pin2") === "GND" && n.same("R14.pin2", "U5.FOD") && n.net("R14.pin1") === "RECT", "ILIM/FOD/ROS network per TI")
  ok(n.same("U5.TS_CTRL", "R15.pin1") && n.net("R15.pin2") === "GND", "TS parked with 10k")
  ok(n.same("U5.AC1", "C36.pin1") && n.same("C36.pin2", "P5.P") && n.same("C37.pin2", "P5.P") && n.same("U5.AC2", "P6.P"), "series resonant caps between AC1 and the coil; AC2 direct")
  ok(n.same("C38.pin1", "U5.AC1") && n.same("C38.pin2", "U5.AC2"), "C2 across AC1/AC2")
  ok(n.same("C30.pin2", "U5.AC1") && n.same("C31.pin2", "U5.AC1") && n.same("C32.pin2", "U5.AC1") && n.same("C33.pin2", "U5.AC2") && n.same("C34.pin2", "U5.AC2") && n.same("C35.pin2", "U5.AC2"), "BOOT/CLAMP/COMM caps return to the AC nodes")
  ok(n.same("U5.CHG", "R16.pin1") && n.same("U5.CHG", "U1.P0_02") && n.net("R16.pin2") === "V1V8", "CHG open-drain to P0.02 with pull-up")
  // battery ID
  ok(n.same("P4.P", "R8.pin2") && n.same("R8.pin1", "U1.P0_13") && n.same("R7.pin2", "P4.P") && n.same("R7.pin1", "U1.P0_14"), "BAT_ID divider driven from P0.14, sensed on AIN0")
  ok(n.net("P2.P") === "GND", "GND contact")
  // test pads
  ok(n.same("TP1.P", "U1.SWDIO") && n.same("TP2.P", "U1.SWDCLK") && n.same("TP3.P", "R2.pin1") && n.net("TP4.P") === "V1V8" && n.net("TP5.P") === "GND", "SWD/reset/rail pads")
  ok(n.same("TP6.P", "U1.P0_26") && n.same("TP7.P", "U1.P0_27") && n.net("TP8.P") === "VSYS" && n.net("TP9.P") === "VBAT" && n.net("TP10.P") === "VBUS_QI", "UART and rail pads")
  const placement = n.errors.filter((e) => ["pcb_placement_error", "pcb_footprint_overlap_error", "pcb_courtyard_overlap_error", "pcb_pad_pad_clearance_error"].includes(e.type))
  ok(placement.length === 0, `tracker placement errors: ${placement.map((e) => e.message).join("; ")}`)
  // Imported Freerouting legs (routing/tracker-routes.json): the owning component of every leg must still sit where
  // the router saw it, every connected land must have copper, and the board must be DRC clean.
  const routes = JSON.parse(require("node:fs").readFileSync("routing/tracker-routes.json", "utf8"))
  const compName = new Map(n.json.filter((e) => e.type === "source_component").map((e) => [e.source_component_id, e.name]))
  const centres = new Map(n.json.filter((e) => e.type === "pcb_component").map((e) => [compName.get(e.source_component_id), e]))
  ok(routes.legs.length > 0, "tracker has imported signal/power routing (run route:export / route:run / route:import)")
  for (const leg of routes.legs) {
    const c = centres.get(leg.from.split(".")[0])
    ok(c && Math.abs(c.center.x - leg.fromCentre.x) < 1e-3 && Math.abs(c.center.y - leg.fromCentre.y) < 1e-3 && ((c.rotation ?? 0) % 360) === (leg.fromRotation % 360), `routed leg ${leg.id}: ${leg.from} moved since the Freerouting pass; re-route`)
    ok(n.same(leg.from, leg.to.startsWith("net.") ? leg.from : leg.to) && (!leg.to.startsWith("net.") || n.net(leg.from) === leg.to.slice(4)), `routed leg ${leg.id} joins ${leg.from} and ${leg.to} which are not the same net`)
  }
  // core reports nothing for unrouted connections when routingDisabled, so check every land ourselves
  const ends = new Set<string>()
  for (const t of n.json.filter((e) => e.type === "pcb_trace")) for (const p of t.route) for (const k of ["start_pcb_port_id", "end_pcb_port_id"]) if (p[k]) ends.add(p[k])
  const srcPortName = new Map(n.json.filter((e) => e.type === "source_port").map((e) => [e.source_port_id, `${compName.get(e.source_component_id)}.${e.name}`]))
  const holePorts = new Set(n.json.filter((e) => e.type === "pcb_plated_hole" && e.pcb_port_id).map((e) => e.pcb_port_id))
  const open = n.json.filter((e) => e.type === "pcb_port").filter((p) => {
    const sel = srcPortName.get(p.source_port_id)!, net = n.net(sel); if (!net) return false
    return n.netPorts.get(net)!.size > 1 && !ends.has(p.pcb_port_id) && !holePorts.has(p.pcb_port_id)
  }).map((p) => srcPortName.get(p.source_port_id)!)
  ok(open.length === 0, `tracker lands without copper (${open.length}): ${open.slice(0, 12).join(", ")}${open.length > 12 ? " ..." : ""}`)
  ok(n.errors.length === 0, `tracker pcb errors: ${[...new Set(n.errors.map((e) => e.type))].join(", ")}`)
  console.log(`tracker: ${checks} assertions passed; pcb errors by type:`, Object.entries(n.errors.reduce((a: any, e) => ((a[e.type] = (a[e.type] ?? 0) + 1), a), {})))
}
// ---------------------------------------------------------------- battery pack (B)
{
  const c0 = checks; const n = new Netlist(load("battery-pack"))
  ok(n.net("P1.P") === "CELL_P" && n.net("T1.CELL_P") === "CELL_P", "BAT+ pad is the cell positive")
  ok(n.net("P2.P") === "GND" && n.net("Q1.S2") === "GND" && n.net("Q1.S1") === "CELL_N" && n.net("T2.CELL_N") === "CELL_N", "protection FETs sit in the negative leg between the cell and the GND pad")
  ok(n.same("Q1.D1A", "Q1.D2A"), "common-drain pair joined")
  ok(n.same("U1.DOUT", "Q1.G1") && n.same("U1.COUT", "Q1.G2"), "DSG/CHG gates")
  ok(n.net("R1.pin1") === "CELL_P" && n.same("R1.pin2", "U1.BAT") && n.same("C1.pin1", "U1.BAT") && n.net("C1.pin2") === "CELL_N", "BQ29700 supply network")
  ok(n.same("R2.pin2", "U1.VM") && n.net("R2.pin1") === "GND", "V- sense through 2.2k")
  ok(n.net("P3.P") === "PACK_NTC" && n.same("R3.pin1", "P3.P") && n.net("R3.pin2") === "GND", "NTC to GND")
  ok(n.net("P4.P") === "PACK_ID" && n.same("R4.pin1", "P4.P") && n.net("R4.pin2") === "GND", "ID resistor to GND")
  ok(n.net("JP1.pin1") === "CELL_N" && n.net("JP1.pin2") === "GND", "JP1 bridges the FET pair only when fitted")
  ok(n.errors.filter((e) => e.type.includes("placement") || e.type.includes("overlap")).length === 0, "pack placement clean")
  console.log(`battery-pack: ${checks - c0} assertions passed; pcb errors by type:`, Object.entries(n.errors.reduce((a: any, e) => ((a[e.type] = (a[e.type] ?? 0) + 1), a), {})))
}
// ---------------------------------------------------------------- dock (C)
{
  const c0 = checks; const n = new Netlist(load("dock"))
  ok(n.net("J1.VBUS1") === "VIN" && n.net("U1.VIN1") === "VIN" && ["C1", "C2", "C3", "C4"].every((c) => n.net(`${c}.pin1`) === "VIN" && n.net(`${c}.pin2`) === "PGND"), "USB VBUS to VIN with input caps to PGND")
  ok(n.same("J1.CC1", "U1.CC1") && n.same("J1.CC2", "U1.CC2") && n.same("J1.DP1", "U1.DP") && n.same("J1.DM1", "U1.DM"), "CC and D+/D- to the transmitter")
  ok(n.net("U1.PGND") === "PGND" && n.net("R3.pin1") === "PGND" && n.net("R3.pin2") === "GND" && n.net("U1.GND") === "GND" && n.net("U1.AGND") === "GND", "power ground through the 20 mOhm sense resistor")
  ok(n.same("U1.ISENSE", "R1.pin1") && n.net("R1.pin2") === "PGND" && n.same("U1.IGND", "R2.pin1") && n.net("R2.pin2") === "GND", "Kelvin sense links")
  ok(n.same("U1.BST1", "C8.pin1") && n.net("C8.pin2") === "LX1" && n.same("U1.BST2", "C6.pin1") && n.net("C6.pin2") === "LX2", "bootstrap caps")
  ok(["C11", "C12", "C13", "C14"].every((c) => n.net(`${c}.pin1`) === "LX1" && n.net(`${c}.pin2`) === "COIL_A"), "4 x 100 nF resonant bank LX1 -> coil")
  ok(n.net("P1.COIL_A") === "COIL_A" && n.net("P2.COIL_B") === "LX2", "coil between the resonant bank and LX2")
  ok(n.net("D1.anode") === "COIL_A" && n.same("D1.cathode", "R4.pin1") && n.same("D1.cathode", "R6.pin1"), "demodulator diode from the coil node")
  ok(n.same("R4.pin2", "R5.pin1") && n.same("R4.pin2", "C9.pin1") && n.same("R4.pin2", "C10.pin1") && n.same("C10.pin2", "U1.V_DECODE"), "V_DECODE network")
  ok(n.same("R6.pin2", "U1.VDET") && n.same("R7.pin1", "U1.VDET") && n.same("C5.pin1", "U1.VDET"), "VDET divider and cap")
  ok(n.same("U1.LOSS_THR", "R8.pin1") && n.net("R8.pin2") === "GND" && n.same("U1.NTC", "R9.pin1") && n.net("R9.pin2") === "GND", "FOD threshold and parked NTC")
  ok(n.same("U1.LED1", "R10.pin1") && n.same("R10.pin2", "D2.anode") && n.same("U1.LED2", "R11.pin1") && n.same("R11.pin2", "D3.anode"), "LEDs")
  ok(n.net("U1.HOSCI") === null && n.net("U1.HOSCO") === null && n.net("U1.DEBUG") === null && n.net("U1.IO4") === null && n.net("U1.IO7") === null && n.net("U1.LED3") === null, "unused IP6829 pins open per datasheet")
  ok(n.errors.filter((e) => e.type.includes("placement") || e.type.includes("overlap")).length === 0, "dock placement clean")
  console.log(`dock: ${checks - c0} assertions passed; pcb errors by type:`, Object.entries(n.errors.reduce((a: any, e) => ((a[e.type] = (a[e.type] ?? 0) + 1), a), {})))
}
console.log(`${checks} design assertions passed. This checks the generated netlist and placement; RF, power and Qi behaviour still need bench validation.`)