use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{ContentReference, GridGeometry, RegionMask, ValidationError};
pub const SYSTEMATIC_UNCERTAINTY_SCHEMA: &str = "openbnct.systematic-uncertainty/0.1.0";
pub const SYSTEMATIC_UNCERTAINTY_QUALIFICATION: &str =
"systematic_uncertainty_research_only_not_clinical";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UncertaintySource {
BoronConcentration {
field: ContentReference,
},
Positioning {
sigma_mm: f64,
registration: Option<ContentReference>,
},
RelativeComponent {
component: String,
relative_1sigma: f64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceSummary {
pub kind: String,
pub mean_1sigma: f64,
pub max_1sigma: f64,
pub skipped_voxels: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegionUncertainty {
pub region: String,
pub voxel_count: u64,
pub mean_dose: f64,
pub monte_carlo_1sigma: Option<f64>,
pub systematic_1sigma: f64,
pub combined_1sigma: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SystematicUncertaintyReport {
#[serde(deserialize_with = "crate::deserialize_contract_id")]
pub schema_version: String,
pub id: String,
pub dose_bundle: ContentReference,
pub quantity: String,
pub sources: Vec<UncertaintySource>,
pub source_summaries: Vec<SourceSummary>,
pub systematic_1sigma: Vec<f64>,
pub combined_1sigma: Option<Vec<f64>>,
pub regions: Vec<RegionUncertainty>,
pub qualification: String,
pub provenance_id: String,
}
#[derive(Debug, Error)]
pub enum SystematicError {
#[error("unsupported systematic-uncertainty schema {0:?}")]
UnsupportedSchema(String),
#[error("invalid systematic-uncertainty report: {0}")]
Invalid(String),
#[error("invalid geometry: {0}")]
InvalidGeometry(#[from] ValidationError),
#[error("invalid content reference: {0}")]
InvalidContentReference(#[from] crate::ContentReferenceError),
}
impl SystematicUncertaintyReport {
pub fn validate(&self) -> Result<(), SystematicError> {
if !crate::schema_matches(&self.schema_version, SYSTEMATIC_UNCERTAINTY_SCHEMA) {
return Err(SystematicError::UnsupportedSchema(
self.schema_version.clone(),
));
}
if self.id.trim().is_empty() {
return Err(SystematicError::Invalid("report id is empty".into()));
}
self.dose_bundle.validate()?;
if self.quantity.trim().is_empty() {
return Err(SystematicError::Invalid("quantity is empty".into()));
}
if self.sources.is_empty() {
return Err(SystematicError::Invalid(
"at least one uncertainty source is required".into(),
));
}
if self.source_summaries.len() != self.sources.len() {
return Err(SystematicError::Invalid(format!(
"source_summaries length {} does not match sources length {}",
self.source_summaries.len(),
self.sources.len()
)));
}
for source in &self.sources {
match source {
UncertaintySource::BoronConcentration { field } => field.validate()?,
UncertaintySource::Positioning {
sigma_mm,
registration,
} => {
if !sigma_mm.is_finite() || *sigma_mm < 0.0 {
return Err(SystematicError::Invalid(
"positioning.sigma_mm must be a non-negative finite value".into(),
));
}
if let Some(reference) = registration {
reference.validate()?;
}
}
UncertaintySource::RelativeComponent {
component,
relative_1sigma,
} => {
if component.trim().is_empty() {
return Err(SystematicError::Invalid(
"relative_component.component is empty".into(),
));
}
if !relative_1sigma.is_finite() || *relative_1sigma < 0.0 {
return Err(SystematicError::Invalid(
"relative_component.relative_1sigma must be a non-negative finite value"
.into(),
));
}
}
}
}
let n = self.systematic_1sigma.len();
for (index, value) in self.systematic_1sigma.iter().enumerate() {
if !value.is_finite() || *value < 0.0 {
return Err(SystematicError::Invalid(format!(
"systematic_1sigma[{index}] must be non-negative and finite"
)));
}
}
if let Some(combined) = &self.combined_1sigma {
if combined.len() != n {
return Err(SystematicError::Invalid(
"combined_1sigma length does not match systematic_1sigma".into(),
));
}
for (index, value) in combined.iter().enumerate() {
if !value.is_finite() || *value < 0.0 {
return Err(SystematicError::Invalid(format!(
"combined_1sigma[{index}] must be non-negative and finite"
)));
}
}
}
if self.qualification.trim().is_empty() {
return Err(SystematicError::Invalid("qualification is empty".into()));
}
Ok(())
}
}
#[must_use]
pub fn relative_component_sigma(dose: &[f64], relative_1sigma: f64) -> Vec<f64> {
dose.iter()
.map(|d| {
if d.is_finite() && *d > 0.0 {
relative_1sigma * d
} else {
0.0
}
})
.collect()
}
#[must_use]
pub fn boron_field_sigma(
boron_dose: &[f64],
field_values: &[f64],
field_sigma: &[f64],
) -> (Vec<f64>, u64) {
let mut skipped = 0u64;
let map = boron_dose
.iter()
.enumerate()
.map(|(i, d)| {
let b = field_values.get(i).copied().unwrap_or(0.0);
let sigma_b = field_sigma.get(i).copied().unwrap_or(0.0);
if b > 0.0 && d.is_finite() && *d > 0.0 {
d * sigma_b / b
} else {
if b <= 0.0 && sigma_b > 0.0 {
skipped += 1;
}
0.0
}
})
.collect();
(map, skipped)
}
#[must_use]
pub fn positioning_sigma(dose: &[f64], geometry: &GridGeometry, sigma_mm: f64) -> Vec<f64> {
let (nx, ny, nz) = (
geometry.shape[0] as usize,
geometry.shape[1] as usize,
geometry.shape[2] as usize,
);
let mut out = vec![0.0; dose.len()];
let gradient_term = |axis: usize, i: usize, j: usize, k: usize| -> f64 {
let (di, dj, dk) = match axis {
0 => (1i64, 0i64, 0i64),
1 => (0i64, 1i64, 0i64),
_ => (0i64, 0i64, 1i64),
};
let extent = [nx, ny, nz][axis];
let at = |i: i64, j: i64, k: i64| -> f64 {
dose[(i as usize) + nx * (j as usize) + nx * ny * (k as usize)]
};
let (i, j, k) = (i as i64, j as i64, k as i64);
let coord = [i, j, k][axis];
let deriv = if coord > 0 && coord + 1 < extent as i64 {
(at(i + di, j + dj, k + dk) - at(i - di, j - dj, k - dk))
/ (2.0 * geometry.spacing_mm[axis])
} else if coord + 1 < extent as i64 {
(at(i + di, j + dj, k + dk) - at(i, j, k)) / geometry.spacing_mm[axis]
} else if coord > 0 {
(at(i, j, k) - at(i - di, j - dj, k - dk)) / geometry.spacing_mm[axis]
} else {
0.0
};
deriv.abs()
};
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
let g2 = gradient_term(0, i, j, k).powi(2)
+ gradient_term(1, i, j, k).powi(2)
+ gradient_term(2, i, j, k).powi(2);
out[i + nx * j + nx * ny * k] = g2.sqrt() * sigma_mm;
}
}
}
out
}
#[must_use]
pub fn combine_voxel_sigma(maps: &[Vec<f64>]) -> Vec<f64> {
let n = maps.first().map_or(0, Vec::len);
let mut out = vec![0.0; n];
for map in maps {
debug_assert_eq!(map.len(), n);
for (o, v) in out.iter_mut().zip(map.iter()) {
*o += v * v;
}
}
for o in out.iter_mut() {
*o = o.sqrt();
}
out
}
#[must_use]
pub fn combine_total_sigma(mc: Option<&[f64]>, systematic: &[f64]) -> Option<Vec<f64>> {
mc.map(|mc| {
mc.iter()
.zip(systematic.iter())
.map(|(m, s)| m.mul_add(*m, s * s).sqrt())
.collect()
})
}
#[must_use]
pub fn region_uncertainty(
region: &str,
dose: &[f64],
mc_sigma: Option<&[f64]>,
source_maps: &[Vec<f64>],
mask: &RegionMask,
) -> RegionUncertainty {
let indices: Vec<usize> = mask
.voxels
.iter()
.enumerate()
.filter_map(|(i, included)| included.then_some(i))
.collect();
let n = indices.len().max(1) as f64;
let mean_dose = indices
.iter()
.map(|&i| dose.get(i).copied().unwrap_or(0.0))
.sum::<f64>()
/ n;
let monte_carlo_1sigma = mc_sigma.map(|mc| {
(indices
.iter()
.map(|&i| mc.get(i).copied().unwrap_or(0.0).powi(2))
.sum::<f64>())
.sqrt()
/ n
});
let systematic_1sigma = source_maps
.iter()
.map(|map| {
indices
.iter()
.map(|&i| map.get(i).copied().unwrap_or(0.0))
.sum::<f64>()
/ n
})
.map(|mean_source| mean_source * mean_source)
.sum::<f64>()
.sqrt();
let combined_1sigma =
monte_carlo_1sigma.map(|mc| mc.mul_add(mc, systematic_1sigma * systematic_1sigma).sqrt());
RegionUncertainty {
region: region.into(),
voxel_count: indices.len() as u64,
mean_dose,
monte_carlo_1sigma,
systematic_1sigma,
combined_1sigma,
}
}
#[must_use]
pub fn summarize_source(kind: &str, map: &[f64], skipped_voxels: u64) -> SourceSummary {
let (mean, max) = if map.is_empty() {
(0.0, 0.0)
} else {
(
map.iter().sum::<f64>() / map.len() as f64,
map.iter().copied().fold(0.0_f64, f64::max),
)
};
SourceSummary {
kind: kind.into(),
mean_1sigma: mean,
max_1sigma: max,
skipped_voxels,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn geometry() -> GridGeometry {
GridGeometry {
shape: [4, 4, 4],
spacing_mm: [2.0, 2.0, 2.0],
origin_mm: [-4.0, -4.0, -4.0],
direction: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
}
}
#[test]
fn relative_sigma_scales_dose() {
let dose = vec![10.0, 0.0, -1.0, f64::NAN];
let map = relative_component_sigma(&dose, 0.1);
assert_eq!(map[0], 1.0);
assert_eq!(map[1], 0.0);
assert_eq!(map[2], 0.0);
assert_eq!(map[3], 0.0);
}
#[test]
fn boron_sigma_uses_fractional_field_uncertainty() {
let dose = vec![8.0, 8.0, 8.0];
let b = vec![40.0, 0.0, 20.0];
let sb = vec![8.0, 2.0, 4.0];
let (map, skipped) = boron_field_sigma(&dose, &b, &sb);
assert!((map[0] - 8.0 * 8.0 / 40.0).abs() < 1e-12); assert_eq!(map[1], 0.0);
assert_eq!(skipped, 1);
assert!((map[2] - 8.0 * 4.0 / 20.0).abs() < 1e-12);
}
#[test]
fn positioning_sigma_tracks_dose_gradient() {
let (nx, ny, nz) = (4usize, 4usize, 4usize);
let mut dose = vec![0.0; nx * ny * nz];
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
dose[i + nx * j + nx * ny * k] = i as f64;
}
}
}
let map = positioning_sigma(&dose, &geometry(), 3.0);
for &v in &map {
assert!((v - 1.5).abs() < 1e-12, "{v}");
}
}
#[test]
fn region_sigma_honors_correlation() {
let dose = vec![10.0, 10.0];
let mc = vec![0.1, 0.1];
let sys = vec![vec![2.0, 2.0]];
let mask = RegionMask {
name: "all".into(),
voxels: vec![true, true],
};
let r = region_uncertainty("all", &dose, Some(&mc), &sys, &mask);
assert!((r.mean_dose - 10.0).abs() < 1e-12);
assert!((r.monte_carlo_1sigma.unwrap() - (0.02f64.sqrt() / 2.0)).abs() < 1e-12);
assert!((r.systematic_1sigma - 2.0).abs() < 1e-12);
let want = (0.02f64 / 4.0 + 4.0).sqrt();
assert!((r.combined_1sigma.unwrap() - want).abs() < 1e-12);
}
#[test]
fn combine_helpers_behave() {
let maps = vec![vec![3.0, 0.0], vec![4.0, 1.0]];
assert_eq!(combine_voxel_sigma(&maps), vec![5.0, 1.0]);
let total = combine_total_sigma(Some(&[0.0, 2.0]), &[5.0, 1.0]).unwrap();
assert_eq!(total, vec![5.0, 5.0f64.sqrt()]);
assert!(combine_total_sigma(None, &[1.0]).is_none());
}
}