main
John Lauer Publish 0.1.0 55a9481 2d ago
//! A substitute part, proven against the one the design chose, from both datasheets.
//!
//! The common case: a BOM part is not stocked at JLCPCB (a JLCPCB build needs LCSC numbers), or a JLCPCB basic part
//! avoids the per-type extended-part fee. A swap is a design decision, so it is checked against facts, not names. The
//! facts file is the AI's reading of BOTH datasheets, with where each number came from:
//!
//!   { "ref": "L1", "kind": "inductor" | "resistor" | "capacitor" | "electrolytic" | "led" | "ic" | "other",
//!     "original":  { "mpn", "manufacturer", "datasheet": "url (rev, page)", "params": {...}, "terminals": [...], "polarity": {...},
//!                    "lcscSearch": { "queries": ["EEE-FK1V470P", "EEEFK1V470P"], "found": false, "checked": "2026-10-05" } },
//!     "candidate": { "mpn", "manufacturer", "lcsc": "C123", "stock": 1234, "stockChecked": "2026-10-05", "jlcLibrary": "Basic",
//!                    "datasheet", "params": {...}, "terminals": [{"pad": "1", "x": -2.0, "y": 0, "w": 1.5, "h": 3.0}], "polarity": {...} },
//!     "requires":  { "isat_A": [">=", 2.5, "calcs.json inductor.Isat_required_A"] },
//!     "reason": "basic-part" (optional), "waive": {"<check>": "why"}, "slack": {"esr_ohm": 0.2}, "drive": {...}, "notes": "..." }
//!
//! params: value (L_uH | C_uF | R_ohm, within the original's tolerance), tol_pct (<=), ratings (>=: voltage_V isat_A
//! irms_A power_W ripple_mA if_max_mA tc_max_C), losses (<= x (1 + slack): dcr_mohm_max esr_ohm tcr_ppm leakage_uA),
//! dielectric (C0G > X7R > X5R > X6S > Y5V), temp_C [min, max] (contains), body_mm.H (<= original + 0.3),
//! wavelength_nm (within 15 nm). A design limit in `requires` decides over the original's rating for that quantity.
//! Terminals must sit >= 85 % on their own pad; polarity must agree.
use crate::csvio::{self, Table};
use crate::kicad::{Footprint, Pad};
use serde_json::{json, Value};
use std::path::Path;

const GE: [&str; 7] = ["voltage_V", "isat_A", "irms_A", "power_W", "ripple_mA", "if_max_mA", "tc_max_C"];
const LE: [&str; 4] = ["dcr_mohm_max", "esr_ohm", "tcr_ppm", "leakage_uA"];
const VALUE: [&str; 3] = ["L_uH", "C_uF", "R_ohm"];

fn rank(d: &str) -> i32 {
    match d.to_uppercase().as_str() { "C0G" | "NP0" => 5, "X8R" | "X7R" => 4, "X7S" | "X6S" | "X5R" => 3, "X7T" => 2, "Y5V" | "Z5U" => 1, _ => 0 }
}

pub struct Row { pub check: String, pub pass: bool, pub detail: String, pub info: bool }

fn n(v: &Value) -> Option<f64> { v.as_f64() }
fn s(v: &Value) -> String { match v { Value::Null => "None".into(), Value::String(x) => x.clone(), Value::Number(x) => x.as_f64().map(gnum).unwrap_or(x.to_string()), other => other.to_string() } }
fn gnum(v: f64) -> String { crate::kicad::g(v) }

/// The candidate's terminal rectangle against one board pad, as the fraction of the terminal on copper. The pad's own
/// rotation is relative to the footprint (KiCad stores pad angles with the footprint's angle included).
fn on_pad(t: (f64, f64, f64, f64), p: &Pad, fp_rot: f64) -> f64 {
    let (w, h) = if ((p.rot - fp_rot).round() as i64).rem_euclid(180) == 90 { (p.h, p.w) } else { (p.w, p.h) };
    let ov = |a0: f64, a1: f64, b0: f64, b1: f64| (a1.min(b1) - a0.max(b0)).max(0.0);
    let area = t.2 * t.3;
    if area <= 0.0 { return 0.0; }
    ov(t.0 - t.2 / 2.0, t.0 + t.2 / 2.0, p.x - w / 2.0, p.x + w / 2.0) * ov(t.1 - t.3 / 2.0, t.1 + t.3 / 2.0, p.y - h / 2.0, p.y + h / 2.0) / area
}

pub fn check(f: &Value, fp: Option<&Footprint>, board_given: bool) -> Vec<Row> {
    let (o, c) = (&f["original"], &f["candidate"]);
    let (po, pc) = (&o["params"], &c["params"]);
    let req = &f["requires"];
    let mut rows = Vec::new();
    let mut add = |name: String, ok: bool, detail: String, info: bool| rows.push(Row { check: name, pass: ok, detail, info });
    for k in VALUE {
        let Some(vo) = n(&po[k]) else { continue };
        let Some(vc) = n(&pc[k]) else { add(k.into(), false, "candidate datasheet value missing".into(), false); continue };
        let tol = n(&po["tol_pct"]).unwrap_or(0.0).max(0.5) / 100.0;
        add(k.into(), (vc - vo).abs() <= tol * vo + 1e-12, format!("{} vs {} (original +/-{} %)", gnum(vc), gnum(vo), gnum(tol * 100.0)), false);
    }
    if let Some(t) = n(&po["tol_pct"]) { add("tolerance".into(), n(&pc["tol_pct"]).unwrap_or(1e9) <= t, format!("{} % vs {} %", s(&pc["tol_pct"]), s(&po["tol_pct"])), false); }
    for k in GE {
        let Some(vo) = n(&po[k]) else { continue };
        let design = req.get(k).is_some();
        add(k.into(), n(&pc[k]).map(|v| v >= vo).unwrap_or(false), format!("{} vs original {}{}", s(&pc[k]), s(&po[k]), if design { " (the design limit below decides)" } else { " (needs >=)" }), design);
    }
    for k in LE {
        let Some(vo) = n(&po[k]) else { continue };
        let sl = n(&f["slack"][k]);
        let design = req.get(k).is_some();
        add(k.into(), n(&pc[k]).map(|v| v <= vo * (1.0 + sl.unwrap_or(0.0))).unwrap_or(false),
            format!("{} vs original {}{}{}", s(&pc[k]), s(&po[k]), sl.map(|x| format!(" +{} % allowed", (100.0 * x).round())).unwrap_or_default(), if design { " (the design limit below decides)" } else { " (needs <=)" }), design);
    }
    if let Some(d) = po["dielectric"].as_str() {
        let dc = pc["dielectric"].as_str().unwrap_or("");
        add("dielectric".into(), rank(dc) >= rank(d), format!("{} vs {d}", s(&pc["dielectric"])), false);
    }
    if let Some(t) = po["temp_C"].as_array() {
        let (t0, t1) = (n(&t[0]).unwrap_or(0.0), n(&t[1]).unwrap_or(0.0));
        let tc = pc["temp_C"].as_array();
        let ok = tc.map(|a| n(&a[0]).unwrap_or(1e9) <= t0 && n(&a[1]).unwrap_or(-1e9) >= t1).unwrap_or(false);
        add("temperature range".into(), ok, format!("{} covers {}", tc.map(|a| Value::from(a.clone()).to_string()).unwrap_or("[1e9, -1e9]".into()).replace(',', ", "), po["temp_C"].to_string().replace(',', ", ")), false);
    }
    if let Some(w) = n(&po["wavelength_nm"]) {
        add("colour".into(), n(&pc["wavelength_nm"]).map(|v| (v - w).abs() <= 15.0).unwrap_or(false), format!("dominant {} nm vs {} nm (within 15)", s(&pc["wavelength_nm"]), s(&po["wavelength_nm"])), false);
    }
    let body = |side: &Value, p: &Value| if side["body_mm"].is_object() { side["body_mm"].clone() } else { p["body_mm"].clone() };
    let (bo, bc) = (body(o, po), body(c, pc));
    if let (Some(h0), Some(h1)) = (n(&bo["H"]), n(&bc["H"])) { if h0 != 0.0 && h1 != 0.0 { add("height".into(), h1 <= h0 + 0.3, format!("{} mm vs {} mm", gnum(h1), gnum(h0)), false); } }
    if let Some(r) = req.as_object() {
        for (k, spec) in r {
            let (op, lim, src) = (spec[0].as_str().unwrap_or(""), n(&spec[1]).unwrap_or(0.0), s(&spec[2]));
            let v = n(&pc[k]);
            let ok = v.map(|v| match op { ">=" => v >= lim, "<=" => v <= lim, "==" => (v - lim).abs() <= 1e-9 * lim.abs().max(1.0), _ => false }).unwrap_or(false);
            add(format!("design: {k} {op} {}", gnum(lim)), ok, format!("{} (from {src})", s(&pc[k])), false);
        }
    }
    // land pattern: every candidate terminal on its own pad
    let kind = f["kind"].as_str().unwrap_or("");
    match (board_given, c["terminals"].as_array()) {
        (true, Some(ts)) => {
            let reference = f["ref"].as_str().unwrap_or("");
            match fp {
                None => add("land fit".into(), false, format!("no footprint {reference} on the board"), false),
                Some(fp) => for t in ts {
                    let pad = match &t["pad"] { Value::String(x) => x.clone(), v => v.to_string() };
                    let Some(p) = fp.pads.get(&pad) else { add(format!("land fit pad {pad}"), false, format!("no pad {pad} on {reference}"), false); continue };
                    let tr = (n(&t["x"]).unwrap_or(0.0), n(&t["y"]).unwrap_or(0.0), n(&t["w"]).unwrap_or(0.0), n(&t["h"]).unwrap_or(0.0));
                    let inside = on_pad(tr, p, fp.rot);
                    add(format!("land fit pad {pad}"), inside >= 0.85, format!("{:.0} % of the terminal on pad {pad} ({:.2} x {:.2} at {:.2}, {:.2})", 100.0 * inside, p.w, p.h, p.x, p.y), false);
                },
            }
        }
        (_, None) if c.get("terminals").map(|v| v.is_null()).unwrap_or(true) && kind != "resistor" && kind != "capacitor" => {
            add("land fit".into(), false, "candidate terminals not given: read them off its datasheet drawing".into(), false);
        }
        _ => {}
    }
    let (pol_o, pol_c) = (&o["polarity"], &c["polarity"]);
    if !pol_o.is_null() || !pol_c.is_null() { add("polarity".into(), pol_o == pol_c, format!("original {}, candidate {}", s(pol_o), s(pol_c)), false); }
    if kind == "led" { if let (Some(vf), Some(d)) = (n(&pc["vf_V"]), f["drive"].as_object()) {
        let i = (n(&d["supply_V"]).unwrap_or(0.0) - vf) / n(&d["R_ohm"]).unwrap_or(1.0) * 1000.0;
        add("LED current".into(), n(&d.get("min_mA").cloned().unwrap_or(json!(0.5))).unwrap_or(0.5) <= i && i <= n(&pc["if_max_mA"]).unwrap_or(1e9),
            format!("{i:.2} mA through {} ohm from {} V (Vf {} V)", s(&d["R_ohm"]), s(&d["supply_V"]), gnum(vf)), false);
    } }
    if c["lcsc"].as_str().map(|x| x.is_empty()).unwrap_or(true) { add("LCSC number".into(), false, "a JLCPCB build needs the candidate's LCSC number".into(), false); }
    // the exact part first: a swap is only justified when the original is really not orderable, or a basic part saves the fee
    if f["reason"] == "basic-part" {
        let lib = c["jlcLibrary"].as_str();
        add("candidate is a JLCPCB basic part".into(), lib == Some("Basic"), format!("{} (checked on {})", lib.unwrap_or("not checked"), c["jlcLibraryChecked"].as_str().unwrap_or("?")), false);
    } else {
        match o["lcscSearch"].as_object() {
            None => add("original not on LCSC".into(), false, "search for the original MPN first (with and without hyphens: Panasonic EEE-FK1V470P is listed as EEEFK1V470P) and record original.lcscSearch {queries, found, checked}".into(), false),
            Some(sr) => {
                let q: Vec<String> = sr.get("queries").and_then(|v| v.as_array()).map(|a| a.iter().map(s).collect()).unwrap_or_default();
                let not_found = sr.get("found") == Some(&json!(false));
                add("original not on LCSC".into(), not_found, format!("searched {} on {}: {}", q.join(", "), sr.get("checked").map(s).unwrap_or("?".into()), if not_found { "not found" } else { "FOUND, order the original instead" }), false);
            }
        }
    }
    rows
}

/// Rewrite every BOM row that carried the original MPN (or only this reference with "all_refs": false).
pub fn apply(bom_path: &Path, f: &Value) -> std::io::Result<Vec<String>> {
    let t = Table::read(bom_path)?;
    let (old, c) = (f["original"]["mpn"].as_str().unwrap_or(""), &f["candidate"]);
    let reference = f["ref"].as_str().unwrap_or("");
    let all = f["all_refs"].as_bool().unwrap_or(true);
    let stock_col = t.head.iter().position(|h| h.starts_with("stock_checked"));
    let idx = |k: &str| t.col(k);
    let today = crate::ledger::now()[..10].to_string();
    let mut changed = Vec::new();
    let mut rows = vec![t.head.clone()];
    for mut r in t.rows.clone() {
        let get = |r: &Vec<String>, k: &str| idx(k).and_then(|i| r.get(i)).cloned().unwrap_or_default();
        if get(&r, "mpn") == old && (get(&r, "ref") == reference || all) {
            let set = |r: &mut Vec<String>, k: &str, v: String| if let Some(i) = idx(k) { if r.len() <= i { r.resize(i + 1, String::new()); } r[i] = v; };
            let lcsc = c["lcsc"].as_str().unwrap_or("").to_string();
            set(&mut r, "mpn", c["mpn"].as_str().unwrap_or("").into());
            set(&mut r, "manufacturer", c["manufacturer"].as_str().unwrap_or("").into());
            if !lcsc.is_empty() { set(&mut r, "source", "JLCPCB".into()); }
            if idx("lcsc").is_some() { set(&mut r, "lcsc", lcsc.clone()); } else if idx("vendor_pn").is_some() { set(&mut r, "vendor_pn", lcsc.clone()); }
            if let Some(i) = stock_col { r[i] = format!("{} (checked {})", s(&c["stock"]).replace("None", "?"), c["stockChecked"].as_str().unwrap_or(&today)); }
            if idx("note").is_some() {
                let note = format!("substitute for {old}, verified: design/swaps/{reference}.result.json; {}", get(&r, "note"));
                set(&mut r, "note", note.trim_matches(|ch| ch == ';' || ch == ' ').to_string());
            }
            changed.push(get(&r, "ref"));
        }
        rows.push(r);
    }
    std::fs::write(bom_path, csvio::write(&rows, "\n"))?;
    Ok(changed)
}

pub fn print(rows: &[Row]) -> String {
    let w = rows.iter().map(|r| r.check.len()).max().unwrap_or(10);
    rows.iter().map(|r| format!("  {}  {:<w$}  {}\n", if r.pass || r.info { if r.info { "note" } else { "PASS" } } else { "FAIL" }, r.check, r.detail)).collect()
}