#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::cost::point_distance_sq;
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ThermalConfig {
pub haz_radius: f64,
pub cooling_time_constant: f64,
pub critical_heat: f64,
pub penalty_weight: f64,
pub enabled: bool,
}
impl Default for ThermalConfig {
fn default() -> Self {
Self {
haz_radius: 3.0,
cooling_time_constant: 15.0,
critical_heat: 1.0,
penalty_weight: 50.0,
enabled: false,
}
}
}
#[inline]
pub fn heat_contribution(
dist_sq: f64,
elapsed_time: f64,
haz_radius: f64,
cooling_time: f64,
) -> f64 {
let sigma_sq = haz_radius * haz_radius;
let spatial = (-dist_sq / (2.0 * sigma_sq)).exp();
let temporal = (-elapsed_time / cooling_time).exp();
spatial * temporal
}
pub fn thermal_penalty(
pierce_points: &[(f64, f64)],
cut_times: &[f64],
config: &ThermalConfig,
) -> f64 {
if !config.enabled || pierce_points.len() < 2 {
return 0.0;
}
let n = pierce_points.len();
let mut total_penalty = 0.0;
for i in 1..n {
let mut accumulated_heat = 0.0;
for j in 0..i {
let dist_sq = point_distance_sq(pierce_points[i], pierce_points[j]);
let elapsed = cut_times[i] - cut_times[j];
accumulated_heat += heat_contribution(
dist_sq,
elapsed,
config.haz_radius,
config.cooling_time_constant,
);
}
if accumulated_heat > config.critical_heat {
let excess = accumulated_heat - config.critical_heat;
total_penalty += config.penalty_weight * excess * excess;
}
}
total_penalty
}
pub fn estimate_cut_times(
pierce_points: &[(f64, f64)],
perimeters: &[f64],
rapid_speed: f64,
cut_speed: f64,
home: (f64, f64),
) -> Vec<f64> {
if pierce_points.is_empty() {
return Vec::new();
}
let n = pierce_points.len();
let mut times = Vec::with_capacity(n);
let mut current_time = 0.0;
let rapid_dist = point_distance_sq(home, pierce_points[0]).sqrt();
current_time += rapid_dist / rapid_speed;
times.push(current_time);
for i in 1..n {
if i - 1 < perimeters.len() {
current_time += perimeters[i - 1] / cut_speed;
}
let rapid_dist = point_distance_sq(pierce_points[i - 1], pierce_points[i]).sqrt();
current_time += rapid_dist / rapid_speed;
times.push(current_time);
}
times
}
pub fn sequence_thermal_penalty(
pierce_points: &[(f64, f64)],
perimeters: &[f64],
rapid_speed: f64,
cut_speed: f64,
home: (f64, f64),
config: &ThermalConfig,
) -> f64 {
if !config.enabled {
return 0.0;
}
let times = estimate_cut_times(pierce_points, perimeters, rapid_speed, cut_speed, home);
thermal_penalty(pierce_points, ×, config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_heat_contribution_at_zero_distance() {
let heat = heat_contribution(0.0, 0.0, 3.0, 15.0);
assert!((heat - 1.0).abs() < 1e-10);
}
#[test]
fn test_heat_contribution_decays_with_distance() {
let near = heat_contribution(1.0, 0.0, 3.0, 15.0);
let far = heat_contribution(100.0, 0.0, 3.0, 15.0);
assert!(near > far, "Heat should decay with distance");
assert!(far < 0.01, "Far heat should be negligible");
}
#[test]
fn test_heat_contribution_decays_with_time() {
let fresh = heat_contribution(0.0, 0.0, 3.0, 15.0);
let aged = heat_contribution(0.0, 60.0, 3.0, 15.0);
assert!(fresh > aged, "Heat should decay with time");
assert!(aged < 0.1, "Old heat should be small");
}
#[test]
fn test_disabled_returns_zero() {
let config = ThermalConfig::default(); let penalty = thermal_penalty(&[(0.0, 0.0), (1.0, 0.0)], &[0.0, 0.1], &config);
assert_eq!(penalty, 0.0);
}
#[test]
fn test_distant_cuts_no_penalty() {
let config = ThermalConfig {
enabled: true,
haz_radius: 3.0,
critical_heat: 0.5,
..ThermalConfig::default()
};
let points = vec![(0.0, 0.0), (100.0, 100.0)];
let times = vec![0.0, 10.0];
let penalty = thermal_penalty(&points, ×, &config);
assert!(
penalty < 1e-10,
"Distant cuts should have no thermal penalty"
);
}
#[test]
fn test_close_cuts_penalty() {
let config = ThermalConfig {
enabled: true,
haz_radius: 3.0,
cooling_time_constant: 15.0,
critical_heat: 0.3,
penalty_weight: 10.0,
};
let points = vec![(0.0, 0.0), (1.0, 0.0)];
let times = vec![0.0, 0.1];
let penalty = thermal_penalty(&points, ×, &config);
assert!(penalty > 0.0, "Close rapid cuts should incur penalty");
}
#[test]
fn test_estimate_cut_times() {
let pierce = vec![(10.0, 0.0), (20.0, 0.0)];
let perimeters = vec![40.0, 40.0];
let times = estimate_cut_times(&pierce, &perimeters, 1000.0, 100.0, (0.0, 0.0));
assert_eq!(times.len(), 2);
assert!((times[0] - 0.01).abs() < 1e-6);
assert!((times[1] - 0.42).abs() < 1e-6);
}
#[test]
fn test_sequence_thermal_penalty_disabled() {
let config = ThermalConfig::default();
let penalty = sequence_thermal_penalty(
&[(0.0, 0.0), (1.0, 0.0)],
&[40.0, 40.0],
1000.0,
100.0,
(0.0, 0.0),
&config,
);
assert_eq!(penalty, 0.0);
}
#[test]
fn test_more_cooling_less_penalty() {
let config = ThermalConfig {
enabled: true,
haz_radius: 5.0,
cooling_time_constant: 10.0,
critical_heat: 0.1,
penalty_weight: 10.0,
};
let fast_penalty = thermal_penalty(&[(0.0, 0.0), (2.0, 0.0)], &[0.0, 0.01], &config);
let slow_penalty = thermal_penalty(&[(0.0, 0.0), (2.0, 0.0)], &[0.0, 60.0], &config);
assert!(
fast_penalty > slow_penalty,
"Fast cuts should have more penalty than slow"
);
}
#[test]
fn test_default_config() {
let config = ThermalConfig::default();
assert!(!config.enabled);
assert!((config.haz_radius - 3.0).abs() < 1e-10);
assert!((config.cooling_time_constant - 15.0).abs() < 1e-10);
assert!((config.critical_heat - 1.0).abs() < 1e-10);
assert!((config.penalty_weight - 50.0).abs() < 1e-10);
}
#[test]
fn test_empty_sequence() {
let config = ThermalConfig {
enabled: true,
..ThermalConfig::default()
};
let penalty = thermal_penalty(&[], &[], &config);
assert_eq!(penalty, 0.0);
let times = estimate_cut_times(&[], &[], 1000.0, 100.0, (0.0, 0.0));
assert!(times.is_empty());
}
}