1use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::{ContentReference, GridGeometry, RegionMask, ValidationError};
27
28pub const SYSTEMATIC_UNCERTAINTY_SCHEMA: &str = "openbnct.systematic-uncertainty/0.1.0";
30
31pub const SYSTEMATIC_UNCERTAINTY_QUALIFICATION: &str =
33 "systematic_uncertainty_research_only_not_clinical";
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "snake_case")]
40pub enum UncertaintySource {
41 BoronConcentration {
46 field: ContentReference,
48 },
49 Positioning {
53 sigma_mm: f64,
55 registration: Option<ContentReference>,
58 },
59 RelativeComponent {
63 component: String,
66 relative_1sigma: f64,
68 },
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct SourceSummary {
75 pub kind: String,
77 pub mean_1sigma: f64,
79 pub max_1sigma: f64,
81 pub skipped_voxels: u64,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct RegionUncertainty {
90 pub region: String,
91 pub voxel_count: u64,
92 pub mean_dose: f64,
94 pub monte_carlo_1sigma: Option<f64>,
97 pub systematic_1sigma: f64,
101 pub combined_1sigma: Option<f64>,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct SystematicUncertaintyReport {
109 #[serde(deserialize_with = "crate::deserialize_contract_id")]
110 pub schema_version: String,
111 pub id: String,
112 pub dose_bundle: ContentReference,
114 pub quantity: String,
116 pub sources: Vec<UncertaintySource>,
118 pub source_summaries: Vec<SourceSummary>,
120 pub systematic_1sigma: Vec<f64>,
123 pub combined_1sigma: Option<Vec<f64>>,
126 pub regions: Vec<RegionUncertainty>,
128 pub qualification: String,
129 pub provenance_id: String,
130}
131
132#[derive(Debug, Error)]
134pub enum SystematicError {
135 #[error("unsupported systematic-uncertainty schema {0:?}")]
136 UnsupportedSchema(String),
137 #[error("invalid systematic-uncertainty report: {0}")]
138 Invalid(String),
139 #[error("invalid geometry: {0}")]
140 InvalidGeometry(#[from] ValidationError),
141 #[error("invalid content reference: {0}")]
142 InvalidContentReference(#[from] crate::ContentReferenceError),
143}
144
145impl SystematicUncertaintyReport {
146 pub fn validate(&self) -> Result<(), SystematicError> {
148 if !crate::schema_matches(&self.schema_version, SYSTEMATIC_UNCERTAINTY_SCHEMA) {
149 return Err(SystematicError::UnsupportedSchema(
150 self.schema_version.clone(),
151 ));
152 }
153 if self.id.trim().is_empty() {
154 return Err(SystematicError::Invalid("report id is empty".into()));
155 }
156 self.dose_bundle.validate()?;
157 if self.quantity.trim().is_empty() {
158 return Err(SystematicError::Invalid("quantity is empty".into()));
159 }
160 if self.sources.is_empty() {
161 return Err(SystematicError::Invalid(
162 "at least one uncertainty source is required".into(),
163 ));
164 }
165 if self.source_summaries.len() != self.sources.len() {
166 return Err(SystematicError::Invalid(format!(
167 "source_summaries length {} does not match sources length {}",
168 self.source_summaries.len(),
169 self.sources.len()
170 )));
171 }
172 for source in &self.sources {
173 match source {
174 UncertaintySource::BoronConcentration { field } => field.validate()?,
175 UncertaintySource::Positioning {
176 sigma_mm,
177 registration,
178 } => {
179 if !sigma_mm.is_finite() || *sigma_mm < 0.0 {
180 return Err(SystematicError::Invalid(
181 "positioning.sigma_mm must be a non-negative finite value".into(),
182 ));
183 }
184 if let Some(reference) = registration {
185 reference.validate()?;
186 }
187 }
188 UncertaintySource::RelativeComponent {
189 component,
190 relative_1sigma,
191 } => {
192 if component.trim().is_empty() {
193 return Err(SystematicError::Invalid(
194 "relative_component.component is empty".into(),
195 ));
196 }
197 if !relative_1sigma.is_finite() || *relative_1sigma < 0.0 {
198 return Err(SystematicError::Invalid(
199 "relative_component.relative_1sigma must be a non-negative finite value"
200 .into(),
201 ));
202 }
203 }
204 }
205 }
206 let n = self.systematic_1sigma.len();
207 for (index, value) in self.systematic_1sigma.iter().enumerate() {
208 if !value.is_finite() || *value < 0.0 {
209 return Err(SystematicError::Invalid(format!(
210 "systematic_1sigma[{index}] must be non-negative and finite"
211 )));
212 }
213 }
214 if let Some(combined) = &self.combined_1sigma {
215 if combined.len() != n {
216 return Err(SystematicError::Invalid(
217 "combined_1sigma length does not match systematic_1sigma".into(),
218 ));
219 }
220 for (index, value) in combined.iter().enumerate() {
221 if !value.is_finite() || *value < 0.0 {
222 return Err(SystematicError::Invalid(format!(
223 "combined_1sigma[{index}] must be non-negative and finite"
224 )));
225 }
226 }
227 }
228 if self.qualification.trim().is_empty() {
229 return Err(SystematicError::Invalid("qualification is empty".into()));
230 }
231 Ok(())
232 }
233}
234
235#[must_use]
238pub fn relative_component_sigma(dose: &[f64], relative_1sigma: f64) -> Vec<f64> {
239 dose.iter()
240 .map(|d| {
241 if d.is_finite() && *d > 0.0 {
242 relative_1sigma * d
243 } else {
244 0.0
245 }
246 })
247 .collect()
248}
249
250#[must_use]
254pub fn boron_field_sigma(
255 boron_dose: &[f64],
256 field_values: &[f64],
257 field_sigma: &[f64],
258) -> (Vec<f64>, u64) {
259 let mut skipped = 0u64;
260 let map = boron_dose
261 .iter()
262 .enumerate()
263 .map(|(i, d)| {
264 let b = field_values.get(i).copied().unwrap_or(0.0);
265 let sigma_b = field_sigma.get(i).copied().unwrap_or(0.0);
266 if b > 0.0 && d.is_finite() && *d > 0.0 {
267 d * sigma_b / b
268 } else {
269 if b <= 0.0 && sigma_b > 0.0 {
270 skipped += 1;
271 }
272 0.0
273 }
274 })
275 .collect();
276 (map, skipped)
277}
278
279#[must_use]
285pub fn positioning_sigma(dose: &[f64], geometry: &GridGeometry, sigma_mm: f64) -> Vec<f64> {
286 let (nx, ny, nz) = (
287 geometry.shape[0] as usize,
288 geometry.shape[1] as usize,
289 geometry.shape[2] as usize,
290 );
291 let mut out = vec![0.0; dose.len()];
292 let gradient_term = |axis: usize, i: usize, j: usize, k: usize| -> f64 {
293 let (di, dj, dk) = match axis {
294 0 => (1i64, 0i64, 0i64),
295 1 => (0i64, 1i64, 0i64),
296 _ => (0i64, 0i64, 1i64),
297 };
298 let extent = [nx, ny, nz][axis];
299 let at = |i: i64, j: i64, k: i64| -> f64 {
300 dose[(i as usize) + nx * (j as usize) + nx * ny * (k as usize)]
301 };
302 let (i, j, k) = (i as i64, j as i64, k as i64);
303 let coord = [i, j, k][axis];
304 let deriv = if coord > 0 && coord + 1 < extent as i64 {
305 (at(i + di, j + dj, k + dk) - at(i - di, j - dj, k - dk))
306 / (2.0 * geometry.spacing_mm[axis])
307 } else if coord + 1 < extent as i64 {
308 (at(i + di, j + dj, k + dk) - at(i, j, k)) / geometry.spacing_mm[axis]
309 } else if coord > 0 {
310 (at(i, j, k) - at(i - di, j - dj, k - dk)) / geometry.spacing_mm[axis]
311 } else {
312 0.0
313 };
314 deriv.abs()
315 };
316 for k in 0..nz {
317 for j in 0..ny {
318 for i in 0..nx {
319 let g2 = gradient_term(0, i, j, k).powi(2)
320 + gradient_term(1, i, j, k).powi(2)
321 + gradient_term(2, i, j, k).powi(2);
322 out[i + nx * j + nx * ny * k] = g2.sqrt() * sigma_mm;
323 }
324 }
325 }
326 out
327}
328
329#[must_use]
331pub fn combine_voxel_sigma(maps: &[Vec<f64>]) -> Vec<f64> {
332 let n = maps.first().map_or(0, Vec::len);
333 let mut out = vec![0.0; n];
334 for map in maps {
335 debug_assert_eq!(map.len(), n);
336 for (o, v) in out.iter_mut().zip(map.iter()) {
337 *o += v * v;
338 }
339 }
340 for o in out.iter_mut() {
341 *o = o.sqrt();
342 }
343 out
344}
345
346#[must_use]
349pub fn combine_total_sigma(mc: Option<&[f64]>, systematic: &[f64]) -> Option<Vec<f64>> {
350 mc.map(|mc| {
351 mc.iter()
352 .zip(systematic.iter())
353 .map(|(m, s)| m.mul_add(*m, s * s).sqrt())
354 .collect()
355 })
356}
357
358#[must_use]
365pub fn region_uncertainty(
366 region: &str,
367 dose: &[f64],
368 mc_sigma: Option<&[f64]>,
369 source_maps: &[Vec<f64>],
370 mask: &RegionMask,
371) -> RegionUncertainty {
372 let indices: Vec<usize> = mask
373 .voxels
374 .iter()
375 .enumerate()
376 .filter_map(|(i, included)| included.then_some(i))
377 .collect();
378 let n = indices.len().max(1) as f64;
379 let mean_dose = indices
380 .iter()
381 .map(|&i| dose.get(i).copied().unwrap_or(0.0))
382 .sum::<f64>()
383 / n;
384 let monte_carlo_1sigma = mc_sigma.map(|mc| {
385 (indices
386 .iter()
387 .map(|&i| mc.get(i).copied().unwrap_or(0.0).powi(2))
388 .sum::<f64>())
389 .sqrt()
390 / n
391 });
392 let systematic_1sigma = source_maps
393 .iter()
394 .map(|map| {
395 indices
396 .iter()
397 .map(|&i| map.get(i).copied().unwrap_or(0.0))
398 .sum::<f64>()
399 / n
400 })
401 .map(|mean_source| mean_source * mean_source)
402 .sum::<f64>()
403 .sqrt();
404 let combined_1sigma =
405 monte_carlo_1sigma.map(|mc| mc.mul_add(mc, systematic_1sigma * systematic_1sigma).sqrt());
406 RegionUncertainty {
407 region: region.into(),
408 voxel_count: indices.len() as u64,
409 mean_dose,
410 monte_carlo_1sigma,
411 systematic_1sigma,
412 combined_1sigma,
413 }
414}
415
416#[must_use]
418pub fn summarize_source(kind: &str, map: &[f64], skipped_voxels: u64) -> SourceSummary {
419 let (mean, max) = if map.is_empty() {
420 (0.0, 0.0)
421 } else {
422 (
423 map.iter().sum::<f64>() / map.len() as f64,
424 map.iter().copied().fold(0.0_f64, f64::max),
425 )
426 };
427 SourceSummary {
428 kind: kind.into(),
429 mean_1sigma: mean,
430 max_1sigma: max,
431 skipped_voxels,
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 fn geometry() -> GridGeometry {
440 GridGeometry {
441 shape: [4, 4, 4],
442 spacing_mm: [2.0, 2.0, 2.0],
443 origin_mm: [-4.0, -4.0, -4.0],
444 direction: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
445 }
446 }
447
448 #[test]
449 fn relative_sigma_scales_dose() {
450 let dose = vec![10.0, 0.0, -1.0, f64::NAN];
451 let map = relative_component_sigma(&dose, 0.1);
452 assert_eq!(map[0], 1.0);
453 assert_eq!(map[1], 0.0);
454 assert_eq!(map[2], 0.0);
455 assert_eq!(map[3], 0.0);
456 }
457
458 #[test]
459 fn boron_sigma_uses_fractional_field_uncertainty() {
460 let dose = vec![8.0, 8.0, 8.0];
461 let b = vec![40.0, 0.0, 20.0];
462 let sb = vec![8.0, 2.0, 4.0];
463 let (map, skipped) = boron_field_sigma(&dose, &b, &sb);
464 assert!((map[0] - 8.0 * 8.0 / 40.0).abs() < 1e-12); assert_eq!(map[1], 0.0);
466 assert_eq!(skipped, 1);
467 assert!((map[2] - 8.0 * 4.0 / 20.0).abs() < 1e-12);
468 }
469
470 #[test]
471 fn positioning_sigma_tracks_dose_gradient() {
472 let (nx, ny, nz) = (4usize, 4usize, 4usize);
475 let mut dose = vec![0.0; nx * ny * nz];
476 for k in 0..nz {
477 for j in 0..ny {
478 for i in 0..nx {
479 dose[i + nx * j + nx * ny * k] = i as f64;
480 }
481 }
482 }
483 let map = positioning_sigma(&dose, &geometry(), 3.0);
484 for &v in &map {
485 assert!((v - 1.5).abs() < 1e-12, "{v}");
486 }
487 }
488
489 #[test]
490 fn region_sigma_honors_correlation() {
491 let dose = vec![10.0, 10.0];
494 let mc = vec![0.1, 0.1];
495 let sys = vec![vec![2.0, 2.0]];
496 let mask = RegionMask {
497 name: "all".into(),
498 voxels: vec![true, true],
499 };
500 let r = region_uncertainty("all", &dose, Some(&mc), &sys, &mask);
501 assert!((r.mean_dose - 10.0).abs() < 1e-12);
502 assert!((r.monte_carlo_1sigma.unwrap() - (0.02f64.sqrt() / 2.0)).abs() < 1e-12);
504 assert!((r.systematic_1sigma - 2.0).abs() < 1e-12);
506 let want = (0.02f64 / 4.0 + 4.0).sqrt();
507 assert!((r.combined_1sigma.unwrap() - want).abs() < 1e-12);
508 }
509
510 #[test]
511 fn combine_helpers_behave() {
512 let maps = vec![vec![3.0, 0.0], vec![4.0, 1.0]];
513 assert_eq!(combine_voxel_sigma(&maps), vec![5.0, 1.0]);
514 let total = combine_total_sigma(Some(&[0.0, 2.0]), &[5.0, 1.0]).unwrap();
515 assert_eq!(total, vec![5.0, 5.0f64.sqrt()]);
516 assert!(combine_total_sigma(None, &[1.0]).is_none());
517 }
518}