Files
rustytorch/demos/bioheat-shared/src/fields.rs
T
2026-03-04 00:08:42 +00:00

382 lines
11 KiB
Rust

//! Field types for 3D temperature and ablation zone data
use crate::geometry::{BoundingBox3D, Point3D};
use serde::{Deserialize, Serialize};
/// 3D temperature field on a regular grid
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemperatureField {
/// Grid resolution (nx, ny, nz)
pub resolution: (usize, usize, usize),
/// Temperature values in row-major order [nz][ny][nx] in °C
pub values: Vec<f32>,
/// Physical bounds of the domain
pub bounds: BoundingBox3D,
}
impl TemperatureField {
/// Create a uniform temperature field
#[must_use]
pub fn uniform(resolution: (usize, usize, usize), bounds: BoundingBox3D, value: f32) -> Self {
let n = resolution.0 * resolution.1 * resolution.2;
Self {
resolution,
values: vec![value; n],
bounds,
}
}
/// Create field at body temperature (37°C)
#[must_use]
pub fn body_temperature(resolution: (usize, usize, usize), bounds: BoundingBox3D) -> Self {
Self::uniform(resolution, bounds, 37.0)
}
/// Get temperature at grid index (i, j, k) where i=x, j=y, k=z
#[must_use]
pub fn at(&self, i: usize, j: usize, k: usize) -> f32 {
let (nx, ny, _nz) = self.resolution;
self.values[k * ny * nx + j * nx + i]
}
/// Set temperature at grid index
pub fn set(&mut self, i: usize, j: usize, k: usize, value: f32) {
let (nx, ny, _nz) = self.resolution;
self.values[k * ny * nx + j * nx + i] = value;
}
/// Get grid spacing in each dimension
#[must_use]
pub fn spacing(&self) -> Point3D {
let (nx, ny, nz) = self.resolution;
let size = self.bounds.size();
Point3D::new(
size.x / (nx - 1).max(1) as f32,
size.y / (ny - 1).max(1) as f32,
size.z / (nz - 1).max(1) as f32,
)
}
/// Get physical coordinates for grid index
#[must_use]
pub fn coords_at(&self, i: usize, j: usize, k: usize) -> Point3D {
let spacing = self.spacing();
Point3D::new(
self.bounds.min.x + i as f32 * spacing.x,
self.bounds.min.y + j as f32 * spacing.y,
self.bounds.min.z + k as f32 * spacing.z,
)
}
/// Get min and max temperature values
#[must_use]
pub fn min_max(&self) -> (f32, f32) {
let min = self.values.iter().copied().fold(f32::INFINITY, f32::min);
let max = self
.values
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max);
(min, max)
}
/// Get maximum temperature
#[must_use]
pub fn max_temperature(&self) -> f32 {
self.values
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max)
}
/// Total number of grid points
#[must_use]
pub fn len(&self) -> usize {
self.resolution.0 * self.resolution.1 * self.resolution.2
}
/// Check if empty
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
/// Count voxels above ablation threshold (60°C)
#[must_use]
pub fn ablated_voxel_count(&self) -> usize {
self.values.iter().filter(|&&t| t >= 60.0).count()
}
/// Calculate ablated volume in m³
#[must_use]
pub fn ablated_volume(&self) -> f32 {
let spacing = self.spacing();
let voxel_volume = spacing.x * spacing.y * spacing.z;
self.ablated_voxel_count() as f32 * voxel_volume
}
}
/// Axis for 2D slice extraction
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SliceAxis {
/// Slice perpendicular to X axis (sagittal in medical imaging)
X,
/// Slice perpendicular to Y axis (coronal in medical imaging)
Y,
/// Slice perpendicular to Z axis (axial/transverse in medical imaging)
#[default]
Z,
}
impl SliceAxis {
/// Get display name
#[must_use]
pub fn display_name(&self) -> &'static str {
match self {
Self::X => "Sagittal (YZ)",
Self::Y => "Coronal (XZ)",
Self::Z => "Axial (XY)",
}
}
}
/// 2D slice of temperature data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SliceData {
/// Axis perpendicular to the slice
pub axis: SliceAxis,
/// Position along the axis (in meters, physical coordinates)
pub position: f32,
/// Index along the axis (grid index)
pub index: usize,
/// 2D resolution (width, height)
pub resolution: (usize, usize),
/// Temperature values in row-major order [height][width]
pub values: Vec<f32>,
/// Physical bounds of the 2D slice
pub bounds_2d: (f32, f32, f32, f32), // (min_u, max_u, min_v, max_v)
}
impl SliceData {
/// Extract a slice from a 3D temperature field
#[must_use]
pub fn from_field(field: &TemperatureField, axis: SliceAxis, index: usize) -> Self {
let (nx, ny, nz) = field.resolution;
let (resolution, values, position, bounds_2d) = match axis {
SliceAxis::X => {
// YZ slice at x=index
let i = index.min(nx - 1);
let mut vals = Vec::with_capacity(ny * nz);
for k in 0..nz {
for j in 0..ny {
vals.push(field.at(i, j, k));
}
}
let pos = field.coords_at(i, 0, 0).x;
let b = (
field.bounds.min.y,
field.bounds.max.y,
field.bounds.min.z,
field.bounds.max.z,
);
((ny, nz), vals, pos, b)
}
SliceAxis::Y => {
// XZ slice at y=index
let j = index.min(ny - 1);
let mut vals = Vec::with_capacity(nx * nz);
for k in 0..nz {
for i in 0..nx {
vals.push(field.at(i, j, k));
}
}
let pos = field.coords_at(0, j, 0).y;
let b = (
field.bounds.min.x,
field.bounds.max.x,
field.bounds.min.z,
field.bounds.max.z,
);
((nx, nz), vals, pos, b)
}
SliceAxis::Z => {
// XY slice at z=index
let k = index.min(nz - 1);
let mut vals = Vec::with_capacity(nx * ny);
for j in 0..ny {
for i in 0..nx {
vals.push(field.at(i, j, k));
}
}
let pos = field.coords_at(0, 0, k).z;
let b = (
field.bounds.min.x,
field.bounds.max.x,
field.bounds.min.y,
field.bounds.max.y,
);
((nx, ny), vals, pos, b)
}
};
Self {
axis,
position,
index,
resolution,
values,
bounds_2d,
}
}
/// Get min and max values in the slice
#[must_use]
pub fn min_max(&self) -> (f32, f32) {
let min = self.values.iter().copied().fold(f32::INFINITY, f32::min);
let max = self
.values
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max);
(min, max)
}
}
/// Ablation zone represented as an isosurface mesh
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AblationZone {
/// Vertices of the isosurface mesh (x, y, z triplets, in meters)
pub vertices: Vec<f32>,
/// Triangle indices (3 indices per triangle)
pub indices: Vec<u32>,
/// Volume of ablated tissue (m³)
pub volume: f32,
/// Volume in mm³ (more clinically useful)
pub volume_mm3: f32,
/// Threshold temperature used (typically 60°C)
pub threshold: f32,
/// Approximate dimensions (width, height, depth) in meters
pub dimensions: (f32, f32, f32),
}
impl AblationZone {
/// Create an empty ablation zone (no tissue ablated yet)
#[must_use]
pub fn empty() -> Self {
Self {
vertices: Vec::new(),
indices: Vec::new(),
volume: 0.0,
volume_mm3: 0.0,
threshold: 60.0,
dimensions: (0.0, 0.0, 0.0),
}
}
/// Create from volume measurement only (no mesh)
#[must_use]
pub fn from_volume(volume_m3: f32, threshold: f32) -> Self {
Self {
vertices: Vec::new(),
indices: Vec::new(),
volume: volume_m3,
volume_mm3: volume_m3 * 1e9, // Convert m³ to mm³
threshold,
dimensions: (0.0, 0.0, 0.0),
}
}
/// Number of triangles in the mesh
#[must_use]
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// Number of vertices
#[must_use]
pub fn vertex_count(&self) -> usize {
self.vertices.len() / 3
}
/// Check if there is any ablation
#[must_use]
pub fn is_empty(&self) -> bool {
self.volume < 1e-12 // Less than 1 nanoliter
}
}
impl Default for AblationZone {
fn default() -> Self {
Self::empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_bounds() -> BoundingBox3D {
BoundingBox3D::from_dimensions(0.1, 0.1, 0.1)
}
#[test]
fn test_temperature_field_uniform() {
let field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0);
assert_eq!(field.len(), 1000);
assert!((field.at(5, 5, 5) - 37.0).abs() < 1e-6);
}
#[test]
fn test_temperature_field_set() {
let mut field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0);
field.set(5, 5, 5, 100.0);
assert!((field.at(5, 5, 5) - 100.0).abs() < 1e-6);
assert!((field.at(0, 0, 0) - 37.0).abs() < 1e-6);
}
#[test]
fn test_temperature_field_spacing() {
let field = TemperatureField::uniform((11, 11, 11), test_bounds(), 37.0);
let spacing = field.spacing();
assert!((spacing.x - 0.01).abs() < 1e-6);
assert!((spacing.y - 0.01).abs() < 1e-6);
assert!((spacing.z - 0.01).abs() < 1e-6);
}
#[test]
fn test_ablated_volume() {
let mut field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0);
// Heat up a single voxel
field.set(5, 5, 5, 70.0);
assert_eq!(field.ablated_voxel_count(), 1);
}
#[test]
fn test_slice_extraction() {
let mut field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0);
field.set(5, 5, 5, 100.0);
// Z slice at k=5 should contain the hot spot
let slice = SliceData::from_field(&field, SliceAxis::Z, 5);
assert_eq!(slice.resolution, (10, 10));
let (min, max) = slice.min_max();
assert!((max - 100.0).abs() < 1e-6);
assert!((min - 37.0).abs() < 1e-6);
}
#[test]
fn test_ablation_zone_empty() {
let zone = AblationZone::empty();
assert!(zone.is_empty());
assert_eq!(zone.triangle_count(), 0);
}
#[test]
fn test_ablation_zone_from_volume() {
let volume_m3 = 1e-6; // 1 mL = 1 cm³ = 1e-6 m³
let zone = AblationZone::from_volume(volume_m3, 60.0);
assert!((zone.volume_mm3 - 1000.0).abs() < 1e-6); // 1000 mm³
}
}