alice-physics 2.2.0

Deterministic physics engine: 128-bit fixed-point rigid bodies with bit-exact, replayable results, typed observation and rollback for search and planning, plus FEM, CFD and 3D-print analysis modules
Documentation
//! Warp Risk Analysis for 3D Printed Parts (Cooling Shrinkage → Curl / Peel)
//!
//! Phase C4 of the ALICE-Physics completeness project. Given a printed part's
//! **footprint** (build-plate contact area and maximum in-plane dimension),
//! its **material** (shrinkage ratio + Young's modulus) and the print/chamber
//! conditions, estimates the risk of warping — the layer 1 detaching or the
//! whole part curling as the top layers cool and shrink more than the bottom.
//!
//! # Physical model
//!
//! The warping mechanism is a "differential contraction" problem: layers
//! printed at high temperature cool and shrink, but they are constrained by
//! previously-solidified material below them. The result is in-plane residual
//! stress that peaks at the corners of the footprint (largest lever arm).
//!
//! Approximate peak warp force per unit width:
//! `F_warp ≈ E · α · ΔT · h`
//! where `E` = Young's modulus, `α` = linear shrinkage, `ΔT` = temperature
//! difference between extrusion and chamber, `h` = height at which the load
//! acts (proportional to layer thickness).
//!
//! The warping curl radius scales inversely with footprint size, so larger
//! parts warp more. The risk score combines:
//!
//! - `Footprint area (mm²)` — larger area → more integrated stress.
//! - `Footprint max dimension (mm)` — larger span → longer curl lever.
//! - `Material shrinkage ratio` — higher → worse.
//! - `ΔT` between print temperature and chamber temperature.
//!
//! Empirical fit to the case
//! "280×250×5 mm PLA plate on an unheated bed":
//! this scenario returns a `Critical` category (score > 0.75).
//!
//! # References
//!
//! - Wang et al., "A model research for prototype warp deformation in the
//!   FDM process", Int. J. Adv. Manuf. Technol. 33 (2007).
//! - PrusaSlicer knowledge base, "Warping".
//! - Bambu Lab X1C manual, chapter on chamber heating for ABS/ASA.

use crate::filament_db::MaterialProperties;
use crate::math::Fix128;

// ============================================================================
// Footprint input
// ============================================================================

/// Geometry of the part's first-layer footprint.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Footprint {
    /// Contact area on the build plate (mm²).
    pub area_mm2: Fix128,
    /// Longest dimension in the XY plane (mm). Used as the "lever arm".
    pub max_dimension_mm: Fix128,
}

impl Footprint {
    /// Convenience constructor for a rectangular footprint.
    #[must_use]
    pub fn rectangle(width_mm: Fix128, height_mm: Fix128) -> Self {
        let area = width_mm * height_mm;
        let max = if width_mm > height_mm {
            width_mm
        } else {
            height_mm
        };
        Self {
            area_mm2: area,
            max_dimension_mm: max,
        }
    }
}

// ============================================================================
// Environmental conditions
// ============================================================================

/// Print & chamber temperature conditions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EnvConditions {
    /// Print / extrusion temperature (°C).
    pub print_temp_c: Fix128,
    /// Chamber (enclosure) temperature (°C). Room temp 20 for open printers.
    pub chamber_temp_c: Fix128,
    /// Build-plate temperature (°C). PLA typical 60, ABS typical 100.
    pub bed_temp_c: Fix128,
}

impl EnvConditions {
    /// Open-air PLA typical (bed 60, no enclosure).
    #[must_use]
    pub fn open_air_pla() -> Self {
        Self {
            print_temp_c: Fix128::from_int(200),
            chamber_temp_c: Fix128::from_int(20),
            bed_temp_c: Fix128::from_int(60),
        }
    }

    /// Bambu X1C enclosure ABS/ASA typical.
    #[must_use]
    pub fn enclosed_abs() -> Self {
        Self {
            print_temp_c: Fix128::from_int(240),
            chamber_temp_c: Fix128::from_int(55),
            bed_temp_c: Fix128::from_int(100),
        }
    }
}

// ============================================================================
// Risk categorization & report
// ============================================================================

/// Coarse risk categorisation for at-a-glance display.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WarpRiskCategory {
    /// Score < 0.25 — safe under normal conditions.
    Low,
    /// 0.25 ≤ score < 0.5 — brim / raft recommended.
    Medium,
    /// 0.5 ≤ score < 0.75 — enclosure or lower shrinkage material recommended.
    High,
    /// Score ≥ 0.75 — reduce footprint, use different material, or expect failure.
    Critical,
}

/// Warp analysis output.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WarpRiskReport {
    /// Normalised risk score in `[0, 1]` (1 = maximum risk observed).
    pub score: Fix128,
    /// Categorised risk level.
    pub category: WarpRiskCategory,
    /// Effective in-plane residual force per unit width (N/mm).
    /// Useful as an engineering figure alongside the abstract score.
    pub warp_force_per_mm: Fix128,
    /// Recommended remedy string (const &str, ergonomic for UIs).
    pub recommendation: &'static str,
}

// ============================================================================
// Analysis
// ============================================================================

/// Compute the warping risk score.
///
// LIMITATION(COV-AM-035): Score formula (empirical, fit to typical FDM outcomes):
/// Score formula (empirical, fit to typical FDM outcomes):
///
/// `score = clamp01(k · α · E_norm · A_norm · L_norm · ΔT_norm)`
///
/// with:
/// - `α`  = shrinkage ratio (dimensionless, 0.002-0.020 typical).
/// - `E_norm`  = Young's modulus / 5 GPa (soft materials warp less).
/// - `A_norm`  = area / 50 000 mm² (reference: 250×200 print bed).
/// - `L_norm`  = max_dim / 300 mm.
/// - `ΔT_norm` = (T_print − T_chamber) / 200 °C (typical PLA=180, ABS=185).
/// - `k`  = 500 (empirical fit, calibrated so the documented 280×250 mm cases
///   score `Critical`; `k = 40` would score that PLA plate only 0.066).
#[must_use]
pub fn analyze_warp_risk(
    footprint: &Footprint,
    material: &MaterialProperties,
    env: &EnvConditions,
) -> WarpRiskReport {
    let alpha = material.shrinkage_ratio;
    let e_gpa = material.youngs_modulus_gpa;
    let e_norm = e_gpa / Fix128::from_int(5);
    let a_norm = footprint.area_mm2 / Fix128::from_int(50_000);
    let l_norm = footprint.max_dimension_mm / Fix128::from_int(300);
    let dt = env.print_temp_c - env.chamber_temp_c;
    let dt_norm = dt / Fix128::from_int(200);

    // Empirical fit constant. Calibrated so the ALICE-Bamboo docs
    // "280×250 ABS on open bed" and "280×250 PLA on unheated bed" both fall
    // into the Critical (score ≥ 0.75) category, matching lab experience.
    let k = Fix128::from_int(500);
    let raw = k * alpha * e_norm * a_norm * l_norm * dt_norm;
    let score = if raw < Fix128::ZERO {
        Fix128::ZERO
    } else if raw > Fix128::ONE {
        Fix128::ONE
    } else {
        raw
    };

    let category = if score < Fix128::from_ratio(25, 100) {
        WarpRiskCategory::Low
    } else if score < Fix128::from_ratio(50, 100) {
        WarpRiskCategory::Medium
    } else if score < Fix128::from_ratio(75, 100) {
        WarpRiskCategory::High
    } else {
        WarpRiskCategory::Critical
    };

    let recommendation = match category {
        WarpRiskCategory::Low => "No mitigation required.",
        WarpRiskCategory::Medium => "Add a brim (5-10mm) or raft for adhesion insurance.",
        WarpRiskCategory::High => "Use an enclosure, heated bed >= 80 C, or switch to lower-shrinkage material (PETG / PLA).",
        WarpRiskCategory::Critical => "High failure risk: split into smaller parts, switch material to PLA, or use a heated chamber (>= 50 C).",
    };

    // LIMITATION(COV-AM-036): Engineering figure: F/mm ≈ E · α · ΔT · h (h taken as 1mm reference)
    // Engineering figure: F/mm ≈ E · α · ΔT · h (h taken as 1mm reference)
    let e_mpa = e_gpa * Fix128::from_int(1000);
    let warp_force_per_mm = e_mpa * alpha * dt;

    WarpRiskReport {
        score,
        category,
        warp_force_per_mm,
        recommendation,
    }
}

// ============================================================================
// Tests
// ============================================================================

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

    #[test]
    fn footprint_rectangle_stores_max_dim() {
        let f = Footprint::rectangle(Fix128::from_int(100), Fix128::from_int(200));
        assert_eq!(f.area_mm2, Fix128::from_int(20_000));
        assert_eq!(f.max_dimension_mm, Fix128::from_int(200));
    }

    #[test]
    fn footprint_rectangle_picks_longer_side() {
        let f = Footprint::rectangle(Fix128::from_int(150), Fix128::from_int(100));
        assert_eq!(f.max_dimension_mm, Fix128::from_int(150));
    }

    #[test]
    fn small_pla_part_is_low_risk() {
        // 30×30mm PLA cube on open-air printer — should be Low risk
        let f = Footprint::rectangle(Fix128::from_int(30), Fix128::from_int(30));
        let report = analyze_warp_risk(
            &f,
            &MaterialProperties::pla(),
            &EnvConditions::open_air_pla(),
        );
        assert_eq!(report.category, WarpRiskCategory::Low);
        assert!(report.score < Fix128::from_ratio(25, 100));
    }

    #[test]
    fn large_abs_part_open_air_is_critical() {
        // Large ABS part on open printer — Critical case (documented case)
        let f = Footprint::rectangle(Fix128::from_int(280), Fix128::from_int(250));
        // Use ABS shrinkage 0.8% and Ambient 20°C
        let env = EnvConditions {
            print_temp_c: Fix128::from_int(240),
            chamber_temp_c: Fix128::from_int(20),
            bed_temp_c: Fix128::from_int(90),
        };
        let report = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
        assert_eq!(report.category, WarpRiskCategory::Critical);
        assert!(report.score >= Fix128::from_ratio(75, 100));
    }

    #[test]
    fn enclosure_lowers_abs_risk() {
        let f = Footprint::rectangle(Fix128::from_int(200), Fix128::from_int(200));
        let open = EnvConditions {
            print_temp_c: Fix128::from_int(240),
            chamber_temp_c: Fix128::from_int(20),
            bed_temp_c: Fix128::from_int(90),
        };
        let enclosed = EnvConditions::enclosed_abs();
        let r_open = analyze_warp_risk(&f, &MaterialProperties::abs(), &open);
        let r_enc = analyze_warp_risk(&f, &MaterialProperties::abs(), &enclosed);
        assert!(r_enc.score < r_open.score);
    }

    #[test]
    fn pla_lower_risk_than_abs_same_footprint() {
        let f = Footprint::rectangle(Fix128::from_int(150), Fix128::from_int(150));
        let r_pla = analyze_warp_risk(
            &f,
            &MaterialProperties::pla(),
            &EnvConditions::open_air_pla(),
        );
        let r_abs = analyze_warp_risk(
            &f,
            &MaterialProperties::abs(),
            &EnvConditions {
                print_temp_c: Fix128::from_int(240),
                chamber_temp_c: Fix128::from_int(20),
                bed_temp_c: Fix128::from_int(90),
            },
        );
        assert!(r_pla.score < r_abs.score);
    }

    #[test]
    fn score_clamped_between_zero_and_one() {
        let f = Footprint::rectangle(Fix128::from_int(5000), Fix128::from_int(5000));
        let env = EnvConditions {
            print_temp_c: Fix128::from_int(500),
            chamber_temp_c: Fix128::from_int(20),
            bed_temp_c: Fix128::from_int(90),
        };
        // Extreme case — should saturate at 1.0
        let report = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
        assert!(report.score <= Fix128::ONE);
        assert!(report.score >= Fix128::ZERO);
    }

    #[test]
    fn category_thresholds_correct() {
        // Sanity: constructing a report with a known score gives the right
        // category. We can't set the score directly, so choose inputs.
        let f = Footprint::rectangle(Fix128::from_int(200), Fix128::from_int(150));
        let env = EnvConditions {
            print_temp_c: Fix128::from_int(240),
            chamber_temp_c: Fix128::from_int(30),
            bed_temp_c: Fix128::from_int(90),
        };
        let r = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
        // Score should be significant but not saturated
        assert!(r.score > Fix128::ZERO);
        assert!(r.score < Fix128::ONE);
    }

    #[test]
    fn recommendation_scales_with_category() {
        let low = Footprint::rectangle(Fix128::from_int(20), Fix128::from_int(20));
        let critical = Footprint::rectangle(Fix128::from_int(300), Fix128::from_int(280));
        let env_bad = EnvConditions {
            print_temp_c: Fix128::from_int(240),
            chamber_temp_c: Fix128::from_int(20),
            bed_temp_c: Fix128::from_int(60),
        };
        let r_low = analyze_warp_risk(
            &low,
            &MaterialProperties::pla(),
            &EnvConditions::open_air_pla(),
        );
        let r_crit = analyze_warp_risk(&critical, &MaterialProperties::abs(), &env_bad);
        assert!(r_low.recommendation.contains("No mitigation"));
        assert!(
            r_crit.recommendation.contains("failure risk")
                || r_crit.recommendation.contains("switch")
        );
    }

    #[test]
    fn warp_force_per_mm_positive_for_normal_conditions() {
        let f = Footprint::rectangle(Fix128::from_int(100), Fix128::from_int(100));
        let r = analyze_warp_risk(
            &f,
            &MaterialProperties::pla(),
            &EnvConditions::open_air_pla(),
        );
        assert!(r.warp_force_per_mm > Fix128::ZERO);
    }

    #[test]
    fn env_presets_reasonable() {
        let pla = EnvConditions::open_air_pla();
        let abs = EnvConditions::enclosed_abs();
        assert_eq!(pla.print_temp_c, Fix128::from_int(200));
        assert_eq!(abs.chamber_temp_c, Fix128::from_int(55));
        assert!(abs.print_temp_c > pla.print_temp_c);
    }

    #[test]
    fn petg_lower_warp_risk_than_abs() {
        let f = Footprint::rectangle(Fix128::from_int(200), Fix128::from_int(150));
        let env = EnvConditions {
            print_temp_c: Fix128::from_int(240),
            chamber_temp_c: Fix128::from_int(20),
            bed_temp_c: Fix128::from_int(80),
        };
        let r_petg = analyze_warp_risk(&f, &MaterialProperties::petg(), &env);
        let r_abs = analyze_warp_risk(&f, &MaterialProperties::abs(), &env);
        assert!(r_petg.score < r_abs.score);
    }
}