master
John Lauer Honest DRC/ERC reports: derive counts from native findings, exit 5 on error findings, ERC coordinate-scale check, load… fa1d9ce 1mo ago
//! Input-relative sanity check for ERC item positions.
//!
//! KiCad 10.0 `eeschema/erc/erc_report.cpp`: `GetTextReport()` builds
//! `UNITS_PROVIDER( schIUScale, ...)` but `WriteJsonReport()` builds
//! `UNITS_PROVIDER( pcbIUScale, ...)`. Schematic IU is 1e4 per mm, PCB IU is
//! 1e6 per mm, so every JSON `pos` is the true millimetre value divided by
//! 100. This check does not trust that story blindly: it matches ERC items
//! to the symbols they name in the uploaded schematic and asks which
//! factor puts them nearest. Nothing is rescaled; the caller decides.
use crate::sexpr::{parse, Node};
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ScaleCheck {
    /// `consistent` | `scaled` | `not_checked`
    pub status: &'static str,
    /// Multiply report positions by this to get source millimetres.
    /// `Some(1.0)` when consistent.
    pub factor: Option<f64>,
    /// ERC items matched to a source symbol and used in the vote.
    pub matched_items: usize,
    /// Median distance (mm) between item and its symbol anchor at the
    /// chosen factor, and at factor 1.
    pub median_distance_mm: Option<f64>,
    pub median_distance_at_unity_mm: Option<f64>,
    pub reason: String,
}

const CANDIDATE_FACTORS: [f64; 4] = [1.0, 10.0, 100.0, 1000.0];
/// A pin belongs within this radius of its symbol anchor on any sane sheet.
const MAX_PLAUSIBLE_MM: f64 = 60.0;

/// Reference designator -> anchor positions (multi-unit symbols have several).
fn symbol_anchors(root: &Node) -> BTreeMap<String, Vec<(f64, f64)>> {
    let mut map: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();
    for sym in root.children("symbol") {
        let Some(at) = sym.child("at") else { continue };
        let (Some(x), Some(y)) = (at.items().get(1).and_then(Node::as_f64), at.items().get(2).and_then(Node::as_f64)) else { continue };
        let reference = sym.children("property").find_map(|p| {
            let items = p.items();
            (items.get(1).and_then(Node::text) == Some("Reference")).then(|| items.get(2).and_then(Node::text)).flatten()
        });
        if let Some(r) = reference {
            map.entry(r.to_string()).or_default().push((x, y));
        }
    }
    map
}

/// "Symbol U1 Pin 3 [EN, Input, Line]" -> "U1"
fn item_reference(description: &str) -> Option<&str> {
    description.strip_prefix("Symbol ")?.split_whitespace().next()
}

fn median(v: &mut Vec<f64>) -> Option<f64> {
    if v.is_empty() { return None; }
    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    Some(v[v.len() / 2])
}

fn not_checked(reason: impl Into<String>) -> ScaleCheck {
    ScaleCheck { status: "not_checked", factor: None, matched_items: 0, median_distance_mm: None, median_distance_at_unity_mm: None, reason: reason.into() }
}

pub fn check_erc_scale(schematic_bytes: &[u8], report: &Value) -> ScaleCheck {
    let Ok(text) = std::str::from_utf8(schematic_bytes) else { return not_checked("schematic is not UTF-8") };
    let root = match parse(text) { Ok(r) => r, Err(e) => return not_checked(format!("schematic did not parse: {e}")) };
    let anchors = symbol_anchors(&root);
    if anchors.is_empty() { return not_checked("no placed symbols with a Reference in the schematic"); }

    // (item pos, candidate anchors)
    let mut pairs: Vec<((f64, f64), &Vec<(f64, f64)>)> = Vec::new();
    for sheet in report.get("sheets").and_then(Value::as_array).into_iter().flatten() {
        for v in sheet.get("violations").and_then(Value::as_array).into_iter().flatten() {
            for item in v.get("items").and_then(Value::as_array).into_iter().flatten() {
                let Some(reference) = item.get("description").and_then(Value::as_str).and_then(item_reference) else { continue };
                let Some(list) = anchors.get(reference) else { continue };
                let (Some(x), Some(y)) = (item.pointer("/pos/x").and_then(Value::as_f64), item.pointer("/pos/y").and_then(Value::as_f64)) else { continue };
                pairs.push(((x, y), list));
            }
        }
    }
    if pairs.is_empty() { return not_checked("no ERC item names a symbol present in the schematic"); }

    let median_for = |factor: f64| {
        let mut d: Vec<f64> = pairs.iter().map(|((x, y), list)| {
            list.iter().map(|(ax, ay)| ((x * factor - ax).powi(2) + (y * factor - ay).powi(2)).sqrt())
                .fold(f64::INFINITY, f64::min)
        }).collect();
        median(&mut d).unwrap_or(f64::INFINITY)
    };
    let scored: Vec<(f64, f64)> = CANDIDATE_FACTORS.iter().map(|f| (*f, median_for(*f))).collect();
    let unity = scored[0].1;
    let (best, best_d) = scored.iter().copied().fold((1.0, f64::INFINITY), |acc, s| if s.1 < acc.1 { s } else { acc });
    let matched_items = pairs.len();

    if best == 1.0 || best_d > MAX_PLAUSIBLE_MM || unity <= best_d * 3.0 {
        let status = if unity <= MAX_PLAUSIBLE_MM { "consistent" } else { "not_checked" };
        return ScaleCheck {
            status, factor: (status == "consistent").then_some(1.0), matched_items,
            median_distance_mm: Some(unity), median_distance_at_unity_mm: Some(unity),
            reason: if status == "consistent" { format!("{matched_items} ERC item(s) sit within {unity:.2} mm (median) of the symbols they name") }
                    else { format!("no candidate factor places the {matched_items} matched item(s) near their symbols (median {unity:.2} mm at 1x)") },
        };
    }
    ScaleCheck {
        status: "scaled", factor: Some(best), matched_items,
        median_distance_mm: Some(best_d), median_distance_at_unity_mm: Some(unity),
        reason: format!(
            "{matched_items} ERC item(s) land a median {best_d:.2} mm from the symbols they name only after multiplying pos by {best:.0} (at 1x the median is {unity:.2} mm)"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn fixture(name: &str) -> Vec<u8> {
        std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
    }

    #[test]
    fn issue_2_kicad_10_erc_json_is_100x_small() {
        let report: Value = serde_json::from_slice(&fixture("erc-native-scaled.json")).unwrap();
        let c = check_erc_scale(&fixture("erc-source-scaled.kicad_sch"), &report);
        assert_eq!(c.status, "scaled", "{c:?}");
        assert_eq!(c.factor, Some(100.0));
        assert!(c.matched_items >= 3);
        assert!(c.median_distance_mm.unwrap() < 30.0, "{c:?}");
    }

    #[test]
    fn consistent_report_is_not_flagged() {
        let sch = br#"(kicad_sch (version 20250114) (symbol (lib_id "X:Y") (at 80 86.59 0) (property "Reference" "U1" (at 0 0 0))))"#;
        let report = json!({"sheets": [{"violations": [{"severity":"error","items":[{"description":"Symbol U1 Pin 3 [EN]","pos":{"x":52.06,"y":67.54}}]}]}]});
        let c = check_erc_scale(sch, &report);
        assert_eq!(c.status, "consistent", "{c:?}");
        assert_eq!(c.factor, Some(1.0));
    }

    #[test]
    fn scaled_synthetic_is_flagged() {
        let sch = br#"(kicad_sch (version 20250114) (symbol (lib_id "X:Y") (at 80 86.59 0) (property "Reference" "U1" (at 0 0 0))))"#;
        let report = json!({"sheets": [{"violations": [{"severity":"error","items":[{"description":"Symbol U1 Pin 3 [EN]","pos":{"x":0.5206,"y":0.6754}}]}]}]});
        let c = check_erc_scale(sch, &report);
        assert_eq!(c.status, "scaled", "{c:?}");
        assert_eq!(c.factor, Some(100.0));
    }

    #[test]
    fn nothing_to_match_is_not_checked() {
        let sch = br#"(kicad_sch (version 20250114) (wire (pts (xy 1 1) (xy 2 2))))"#;
        let c = check_erc_scale(sch, &json!({"sheets": []}));
        assert_eq!(c.status, "not_checked");
    }
}