main
John Lauer adom-fields 0.8.0: heat through the parts and the heatsink (solids), fan levels, whole-scene default view c278351
//! Explicit steady-state package nodes. Heat is injected once at the junction;
//! board terminals and the external sink share it through declared resistances.
//! This is a supplied-parameter screen, not an airflow or assembly qualification.
use crate::solve::System;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Package {
    pub reference: String,
    pub junction_case_k_per_w: f64,
    pub case_sink_k_per_w: f64,
    pub sink_ambient_still_k_per_w: f64,
    pub sink_ambient_moving_k_per_w: f64,
    pub board_links: Vec<BoardLink>,
    /// Cite the part/TIM/sink and conditions, and identify assumptions.
    pub provenance: String,
}

#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BoardLink {
    pub pad: String,
    pub at: [f64; 2],
    pub layer: String,
    pub resistance_k_per_w: f64,
}

pub struct Nodes {
    pub junction: usize,
    pub case: usize,
    pub sink: usize,
    pub links: Vec<(usize, f64)>,
    pub sink_conductance: f64,
}

impl Package {
    pub fn validate_board(
        &self,
        board: &crate::board::Board,
        layers: &[&str],
        pitch: f64,
    ) -> Result<(), String> {
        self.validate()?;
        if !pitch.is_finite() || pitch <= 0.0 {
            return Err("invalid package grid pitch".into());
        }
        let width = ((board.width() / (pitch / 2.0)).round() as usize).max(1) / 2;
        let height = ((board.height() / (pitch / 2.0)).round() as usize).max(1) / 2;
        for link in &self.board_links {
            if !layers.contains(&link.layer.as_str()) {
                return Err(format!(
                    "{}: package link names an unsupported layer",
                    self.reference
                ));
            }
            let matches: Vec<_> = board
                .pads
                .iter()
                .filter(|p| {
                    p.reference == self.reference
                        && p.pad == link.pad
                        && (p.x - link.at[0]).abs() < 1e-6
                        && (p.y - link.at[1]).abs() < 1e-6
                        && p.layers.contains(&link.layer)
                })
                .collect();
            if matches.len() != 1 {
                return Err(format!(
                    "{}: package link must identify exactly one physical pad on its copper layer",
                    self.reference
                ));
            }
            if link.at[0] < board.x0
                || link.at[0] >= board.x0 + width as f64 * pitch
                || link.at[1] < board.y0
                || link.at[1] >= board.y0 + height as f64 * pitch
            {
                return Err(format!(
                    "{}: package link lies outside thermal grid",
                    self.reference
                ));
            }
        }
        Ok(())
    }

    pub fn validate(&self) -> Result<(), String> {
        if self.reference.trim().is_empty() || self.provenance.trim().is_empty() {
            return Err("packageCooling requires reference and parameter provenance".into());
        }
        for r in [
            self.junction_case_k_per_w,
            self.case_sink_k_per_w,
            self.sink_ambient_still_k_per_w,
            self.sink_ambient_moving_k_per_w,
        ]
        .into_iter()
        .chain(self.board_links.iter().map(|l| l.resistance_k_per_w))
        {
            if !r.is_finite() || r <= 0.0 || !(1.0 / r).is_finite() {
                return Err(format!(
                    "{}: package resistances must be finite and positive",
                    self.reference
                ));
            }
        }
        for (i, link) in self.board_links.iter().enumerate() {
            if link.pad.trim().is_empty() || !link.at.iter().all(|x| x.is_finite()) {
                return Err(format!(
                    "{}: every board link needs a pad and finite coordinates",
                    self.reference
                ));
            }
            if self.board_links[..i]
                .iter()
                .any(|p| p.pad == link.pad && p.at == link.at && p.layer == link.layer)
            {
                return Err(format!("{}: duplicate package board link", self.reference));
            }
        }
        Ok(())
    }

    /// Caller resolves and validates physical pads and layer indices first.
    pub fn attach(&self, sys: &mut System, board_nodes: &[usize], level: f64, fan_n: f64) -> Nodes {
        assert_eq!(board_nodes.len(), self.board_links.len());
        let board_count = sys.nl * sys.h * sys.w;
        assert!(board_nodes.iter().all(|i| *i < board_count));
        // the sink's conductance to the air follows the fan level like h does (crate::fields::fan_mix)
        let sink_conductance = crate::fields::fan_mix(1.0 / self.sink_ambient_still_k_per_w, 1.0 / self.sink_ambient_moving_k_per_w, level, fan_n);
        let junction = sys.add_node(0.0);
        let case = sys.add_node(0.0);
        let sink = sys.add_node(sink_conductance);
        sys.edges
            .push((junction, case, 1.0 / self.junction_case_k_per_w));
        sys.edges.push((case, sink, 1.0 / self.case_sink_k_per_w));
        let links = board_nodes
            .iter()
            .zip(&self.board_links)
            .map(|(node, link)| {
                let conductance = 1.0 / link.resistance_k_per_w;
                sys.edges.push((case, *node, conductance));
                (*node, conductance)
            })
            .collect();
        Nodes {
            junction,
            case,
            sink,
            links,
            sink_conductance,
        }
    }
}

impl Nodes {
    pub fn result(&self, temperatures: &[f64], watts: f64) -> Value {
        let board_w: f64 = self
            .links
            .iter()
            .map(|(i, g)| g * (temperatures[self.case] - temperatures[*i]))
            .sum();
        let ambient_w = temperatures[self.sink] * self.sink_conductance;
        json!({
            "junctionRiseC": temperatures[self.junction], "caseRiseC": temperatures[self.case],
            "sinkRiseC": temperatures[self.sink], "boardCouplingW": board_w,
            "sinkToAmbientW": ambient_w, "sourceW": watts,
            "energyResidualW": watts - board_w - ambient_w,
            "boardLinks": self.links.iter().map(|(i,g)| json!({
                "boardRiseC": temperatures[*i],
                "caseToBoardW": g*(temperatures[self.case]-temperatures[*i])
            })).collect::<Vec<_>>(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    fn package() -> Package {
        Package {
            reference: "Q1".into(),
            junction_case_k_per_w: 0.5,
            case_sink_k_per_w: 1.0,
            sink_ambient_still_k_per_w: 3.0,
            sink_ambient_moving_k_per_w: 2.0,
            board_links: vec![],
            provenance: "Analytical test resistances, not physical qualification".into(),
        }
    }
    fn close(a: f64, b: f64) {
        assert!((a - b).abs() < 1e-8, "{a} != {b}");
    }
    #[test]
    fn isolated_series_path_has_exact_temperatures_and_conserves_power() {
        let p = package();
        let mut s = System::new(1, 1, 1);
        s.leak[0][0] = 1.0;
        let n = p.attach(&mut s, &[], 0.0, 0.5);
        let mut q = vec![0.0; s.n()];
        q[n.junction] = 2.0;
        let (t, _, res) = s.solve(&q, 1e-12, 100);
        assert!(res < 1e-10);
        close(t[n.sink], 6.0);
        close(t[n.case], 8.0);
        close(t[n.junction], 9.0);
        close(n.result(&t, 2.0)["energyResidualW"].as_f64().unwrap(), 0.0);
    }
    #[test]
    fn parallel_board_path_splits_power_without_double_injection() {
        let mut p = package();
        p.board_links.push(BoardLink {
            pad: "2".into(),
            at: [0.0, 0.0],
            layer: "F.Cu".into(),
            resistance_k_per_w: 2.0,
        });
        let mut s = System::new(1, 1, 1);
        s.leak[0][0] = 0.5;
        let n = p.attach(&mut s, &[0], 0.0, 0.5);
        let mut q = vec![0.0; s.n()];
        q[n.junction] = 2.0;
        let (t, _, _) = s.solve(&q, 1e-12, 100);
        let r = n.result(&t, 2.0);
        close(t[n.case], 4.0);
        close(t[n.junction], 5.0);
        close(t[0], 2.0);
        close(r["boardCouplingW"].as_f64().unwrap(), 1.0);
        close(r["sinkToAmbientW"].as_f64().unwrap(), 1.0);
    }
    #[test]
    fn hotter_board_can_send_heat_back_into_external_sink() {
        let mut p = package();
        p.board_links.push(BoardLink {
            pad: "2".into(),
            at: [0.0, 0.0],
            layer: "F.Cu".into(),
            resistance_k_per_w: 2.0,
        });
        let mut s = System::new(1, 1, 1);
        s.leak[0][0] = 0.5;
        let n = p.attach(&mut s, &[0], 0.0, 0.5);
        let mut q = vec![0.0; s.n()];
        q[0] = 10.0;
        q[n.junction] = 1.0;
        let (t, _, _) = s.solve(&q, 1e-12, 100);
        let r = n.result(&t, 1.0);
        assert!(r["boardCouplingW"].as_f64().unwrap() < 0.0);
        close(r["energyResidualW"].as_f64().unwrap(), 0.0);
        close(t[0] * 0.5 + r["sinkToAmbientW"].as_f64().unwrap(), 11.0);
    }
    #[test]
    fn invalid_or_untraceable_resistances_are_rejected() {
        for r in [0.0, -1.0, f64::NAN, f64::INFINITY] {
            let mut p = package();
            p.case_sink_k_per_w = r;
            assert!(p.validate().is_err());
        }
        let mut p = package();
        p.provenance.clear();
        assert!(p.validate().is_err());
    }
}