1#![forbid(unsafe_code)]
4
5mod exposure;
6mod external_dose;
7mod interchange;
8mod registration;
9mod stats;
10mod systematic;
11
12use std::collections::BTreeSet;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub use exposure::{
18 BoundFileReference, EXPOSURE_PLAN_SCHEMA, Exposure, ExposureCovariance, ExposurePlan,
19 ExposurePlanError, WeightBasis, accumulate_exposures,
20};
21pub use external_dose::{
22 EXTERNAL_DOSE_SCHEMA, ExternalDoseBundle, ExternalDoseDocument, ExternalDoseError,
23 ExternalDoseQuantity, ExternalFractionation, ResampleError, ResampleMethod,
24 import_external_dose, resample_trilinear,
25};
26pub use interchange::{
27 COMPONENT_DOSE_INTERCHANGE_SCHEMA, ComponentDoseInterchange, ExternalProducer, ExternalTotal,
28 InterchangeError, grid_geometry_equivalent, import_component_dose,
29};
30pub use registration::{
31 LandmarkPair, REGISTRATION_SCHEMA, Registration, RegistrationError, RegistrationMethod,
32 RigidTransform, declared_registration, fit_landmark_transform, landmark_registration,
33};
34pub use stats::{
35 dose_covering_percent, equivalent_uniform_dose, masked_values, mean, volume_at_least,
36};
37pub use systematic::{
38 RegionUncertainty, SYSTEMATIC_UNCERTAINTY_QUALIFICATION, SYSTEMATIC_UNCERTAINTY_SCHEMA,
39 SourceSummary, SystematicError, SystematicUncertaintyReport, UncertaintySource,
40 boron_field_sigma, combine_total_sigma, combine_voxel_sigma, positioning_sigma,
41 region_uncertainty, relative_component_sigma, summarize_source,
42};
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct GridGeometry {
52 pub shape: [u32; 3],
53 pub spacing_mm: [f64; 3],
54 pub origin_mm: [f64; 3],
55 pub direction: [f64; 9],
56}
57
58impl GridGeometry {
59 pub fn voxel_count(&self) -> Result<usize, ValidationError> {
60 if self.shape.contains(&0) {
61 return Err(ValidationError::EmptyGrid);
62 }
63 if self
64 .spacing_mm
65 .iter()
66 .any(|value| !value.is_finite() || *value <= 0.0)
67 {
68 return Err(ValidationError::InvalidSpacing);
69 }
70 if self.origin_mm.iter().any(|value| !value.is_finite())
71 || self.direction.iter().any(|value| !value.is_finite())
72 {
73 return Err(ValidationError::NonFiniteGeometry);
74 }
75 let axes = [
76 [self.direction[0], self.direction[3], self.direction[6]],
77 [self.direction[1], self.direction[4], self.direction[7]],
78 [self.direction[2], self.direction[5], self.direction[8]],
79 ];
80 let dot = |left: [f64; 3], right: [f64; 3]| {
81 left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
82 };
83 let determinant = self.direction[0]
84 * (self.direction[4] * self.direction[8] - self.direction[5] * self.direction[7])
85 - self.direction[1]
86 * (self.direction[3] * self.direction[8] - self.direction[5] * self.direction[6])
87 + self.direction[2]
88 * (self.direction[3] * self.direction[7] - self.direction[4] * self.direction[6]);
89 const TOLERANCE: f64 = 1.0e-6;
90 if axes
91 .iter()
92 .any(|axis| (dot(*axis, *axis) - 1.0).abs() > TOLERANCE)
93 || dot(axes[0], axes[1]).abs() > TOLERANCE
94 || dot(axes[0], axes[2]).abs() > TOLERANCE
95 || dot(axes[1], axes[2]).abs() > TOLERANCE
96 || (determinant - 1.0).abs() > TOLERANCE
97 {
98 return Err(ValidationError::InvalidDirection);
99 }
100
101 self.shape.iter().try_fold(1_usize, |count, extent| {
102 count
103 .checked_mul(*extent as usize)
104 .ok_or(ValidationError::GridTooLarge)
105 })
106 }
107
108 pub fn voxel_center_lps_mm(&self, voxel: [u32; 3]) -> Result<[f64; 3], ValidationError> {
110 self.voxel_count()?;
111 if voxel
112 .into_iter()
113 .zip(self.shape)
114 .any(|(index, extent)| index >= extent)
115 {
116 return Err(ValidationError::VoxelOutOfBounds {
117 voxel,
118 shape: self.shape,
119 });
120 }
121 let local = [
122 f64::from(voxel[0]) * self.spacing_mm[0],
123 f64::from(voxel[1]) * self.spacing_mm[1],
124 f64::from(voxel[2]) * self.spacing_mm[2],
125 ];
126 Ok([
127 self.origin_mm[0]
128 + self.direction[0].mul_add(
129 local[0],
130 self.direction[1].mul_add(local[1], self.direction[2] * local[2]),
131 ),
132 self.origin_mm[1]
133 + self.direction[3].mul_add(
134 local[0],
135 self.direction[4].mul_add(local[1], self.direction[5] * local[2]),
136 ),
137 self.origin_mm[2]
138 + self.direction[6].mul_add(
139 local[0],
140 self.direction[7].mul_add(local[1], self.direction[8] * local[2]),
141 ),
142 ])
143 }
144
145 pub fn bounding_box_lps_mm(&self) -> Result<([f64; 3], [f64; 3]), ValidationError> {
150 self.voxel_count()?;
151 let mut minimum = self.origin_mm;
152 let mut maximum = self.origin_mm;
153 for corner in 0..8 {
156 let local = [
157 (if corner & 1 == 0 {
158 -0.5
159 } else {
160 self.shape[0] as f64 - 0.5
161 }) * self.spacing_mm[0],
162 (if corner & 2 == 0 {
163 -0.5
164 } else {
165 self.shape[1] as f64 - 0.5
166 }) * self.spacing_mm[1],
167 (if corner & 4 == 0 {
168 -0.5
169 } else {
170 self.shape[2] as f64 - 0.5
171 }) * self.spacing_mm[2],
172 ];
173 for axis in 0..3 {
174 let value = self.origin_mm[axis]
175 + self.direction[axis * 3].mul_add(
176 local[0],
177 self.direction[axis * 3 + 1]
178 .mul_add(local[1], self.direction[axis * 3 + 2] * local[2]),
179 );
180 minimum[axis] = minimum[axis].min(value);
181 maximum[axis] = maximum[axis].max(value);
182 }
183 }
184 Ok((minimum, maximum))
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum DoseComponent {
192 Boron,
193 Nitrogen,
194 Hydrogen,
195 Photon,
196}
197
198impl DoseComponent {
199 pub const REQUIRED: [Self; 4] = [Self::Boron, Self::Nitrogen, Self::Hydrogen, Self::Photon];
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum DoseUnit {
205 Gray,
206 GrayPerSourceParticle,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct ContentReference {
213 pub id: String,
214 pub sha256: String,
215}
216
217impl ContentReference {
218 pub fn validate(&self) -> Result<(), ContentReferenceError> {
219 if self.id.trim().is_empty() {
220 return Err(ContentReferenceError::EmptyId);
221 }
222 if !is_canonical_sha256(&self.sha256) {
223 return Err(ContentReferenceError::InvalidSha256);
224 }
225 Ok(())
226 }
227}
228
229pub type ComponentProfileReference = ContentReference;
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct DoseVolume {
235 pub component: DoseComponent,
236 pub unit: DoseUnit,
237 pub values: Vec<f64>,
238 pub absolute_standard_uncertainty: Option<Vec<f64>>,
240}
241
242impl DoseVolume {
243 pub fn relative_standard_uncertainty(
248 &self,
249 voxel_index: usize,
250 ) -> Result<Option<f64>, ValidationError> {
251 let mean = self
252 .values
253 .get(voxel_index)
254 .ok_or(ValidationError::DoseIndexOutOfBounds {
255 index: voxel_index,
256 length: self.values.len(),
257 })?;
258 let Some(uncertainty) = &self.absolute_standard_uncertainty else {
259 return Ok(None);
260 };
261 let absolute = uncertainty
262 .get(voxel_index)
263 .ok_or(ValidationError::UncertaintyLength {
264 component: self.component,
265 expected: self.values.len(),
266 actual: uncertainty.len(),
267 })?;
268 if *mean == 0.0 {
269 Ok(None)
270 } else {
271 Ok(Some(*absolute / *mean))
272 }
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum TotalUncertaintyMethod {
279 DedicatedEstimator,
281 BatchCovariance,
283 Unavailable,
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct PhysicalTotalDoseVolume {
290 pub unit: DoseUnit,
291 pub values: Vec<f64>,
292 pub absolute_standard_uncertainty: Option<Vec<f64>>,
293 pub uncertainty_method: TotalUncertaintyMethod,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(deny_unknown_fields)]
299pub struct RegionMask {
300 pub name: String,
301 pub voxels: Vec<bool>,
302}
303
304impl RegionMask {
305 #[must_use]
307 pub fn included_voxel_count(&self) -> usize {
308 self.voxels.iter().filter(|voxel| **voxel).count()
309 }
310
311 pub fn subtract(
317 &self,
318 other: &RegionMask,
319 name: impl Into<String>,
320 ) -> Result<RegionMask, ValidationError> {
321 self.combine(other, name, |a, b| a && !b)
322 }
323
324 pub fn union(
326 &self,
327 other: &RegionMask,
328 name: impl Into<String>,
329 ) -> Result<RegionMask, ValidationError> {
330 self.combine(other, name, |a, b| a || b)
331 }
332
333 pub fn intersection(
335 &self,
336 other: &RegionMask,
337 name: impl Into<String>,
338 ) -> Result<RegionMask, ValidationError> {
339 self.combine(other, name, |a, b| a && b)
340 }
341
342 fn combine(
343 &self,
344 other: &RegionMask,
345 name: impl Into<String>,
346 op: impl Fn(bool, bool) -> bool,
347 ) -> Result<RegionMask, ValidationError> {
348 if self.voxels.len() != other.voxels.len() {
349 return Err(ValidationError::MaskVoxelCountMismatch {
350 name: self.name.clone(),
351 other: other.name.clone(),
352 expected: self.voxels.len(),
353 actual: other.voxels.len(),
354 });
355 }
356 let voxels = self
357 .voxels
358 .iter()
359 .copied()
360 .zip(other.voxels.iter().copied())
361 .map(|(a, b)| op(a, b))
362 .collect::<Vec<_>>();
363 let mask = RegionMask {
364 name: name.into(),
365 voxels,
366 };
367 if mask.included_voxel_count() == 0 {
368 return Err(ValidationError::EmptyMask(mask.name));
369 }
370 Ok(mask)
371 }
372
373 pub fn summarize(&self, values: &[f64]) -> Result<MaskDoseSummary, ValidationError> {
377 if values.len() != self.voxels.len() {
378 return Err(ValidationError::MaskValuesLength {
379 mask: self.name.clone(),
380 mask_voxels: self.voxels.len(),
381 values: values.len(),
382 });
383 }
384 let included = self
385 .voxels
386 .iter()
387 .copied()
388 .zip(values.iter().copied())
389 .filter_map(|(inside, value)| inside.then_some(value));
390 let mut sum = 0.0;
391 let mut maximum = f64::NEG_INFINITY;
392 let mut count = 0_usize;
393 for value in included {
394 if !value.is_finite() {
395 return Err(ValidationError::NonFiniteMaskValue {
396 mask: self.name.clone(),
397 });
398 }
399 sum += value;
400 maximum = maximum.max(value);
401 count += 1;
402 }
403 if count == 0 {
404 return Err(ValidationError::EmptyMask(self.name.clone()));
405 }
406 Ok(MaskDoseSummary {
407 voxel_count: count,
408 mean: sum / count as f64,
409 maximum,
410 })
411 }
412
413 pub fn centroid_lps_mm(&self, geometry: &GridGeometry) -> Result<[f64; 3], ValidationError> {
418 let total = geometry.voxel_count()?;
419 if self.voxels.len() != total {
420 return Err(ValidationError::MaskVoxelCountMismatch {
421 name: self.name.clone(),
422 other: "geometry".into(),
423 expected: total,
424 actual: self.voxels.len(),
425 });
426 }
427 let nx = geometry.shape[0] as usize;
428 let ny = geometry.shape[1] as usize;
429 let mut centroid = [0.0_f64; 3];
430 let mut count = 0_usize;
431 for (index, inside) in self.voxels.iter().copied().enumerate() {
432 if !inside {
433 continue;
434 }
435 let voxel = [
436 (index % nx) as u32,
437 ((index % (nx * ny)) / nx) as u32,
438 (index / (nx * ny)) as u32,
439 ];
440 let center = geometry.voxel_center_lps_mm(voxel)?;
441 for axis in 0..3 {
442 centroid[axis] += center[axis];
443 }
444 count += 1;
445 }
446 if count == 0 {
447 return Err(ValidationError::EmptyMask(self.name.clone()));
448 }
449 for axis in &mut centroid {
450 *axis /= count as f64;
451 }
452 Ok(centroid)
453 }
454}
455
456#[derive(Debug, Clone, Copy, PartialEq)]
458pub struct MaskDoseSummary {
459 pub voxel_count: usize,
460 pub mean: f64,
461 pub maximum: f64,
462}
463
464pub const SCHEMA_PREFIX: &str = "openbnct.";
466
467pub const LEGACY_SCHEMA_PREFIX: &str = "nctforge.";
471
472pub const LEGACY_TOOL_PREFIX: &str = "nctforge-";
475
476pub fn normalize_contract_id(id: &str) -> String {
480 if let Some(rest) = id.strip_prefix(LEGACY_SCHEMA_PREFIX) {
481 return format!("{SCHEMA_PREFIX}{rest}");
482 }
483 if let Some(rest) = id.strip_prefix(LEGACY_TOOL_PREFIX) {
484 return format!("openbnct-{rest}");
485 }
486 id.to_string()
487}
488
489pub fn schema_matches(actual: &str, expected: &str) -> bool {
494 normalize_contract_id(actual) == normalize_contract_id(expected)
495}
496
497pub fn deserialize_contract_id<'de, D>(deserializer: D) -> Result<String, D::Error>
501where
502 D: serde::Deserializer<'de>,
503{
504 Ok(normalize_contract_id(&String::deserialize(deserializer)?))
505}
506
507pub const PHYSICAL_DOSE_BUNDLE_SCHEMA: &str = "openbnct.physical-dose-bundle/0.2.0";
509
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
511#[serde(deny_unknown_fields)]
512pub struct PhysicalDoseBundle {
513 #[serde(deserialize_with = "crate::deserialize_contract_id")]
514 pub schema_version: String,
515 pub case_id: String,
516 pub frame_of_reference_uid: Option<String>,
517 pub geometry: GridGeometry,
518 pub component_profile: ComponentProfileReference,
519 pub response_set: ContentReference,
521 pub components: Vec<DoseVolume>,
522 pub physical_total: PhysicalTotalDoseVolume,
524 pub provenance_id: String,
526}
527
528impl PhysicalDoseBundle {
529 pub fn validate(&self) -> Result<(), ValidationError> {
530 let voxel_count = self.geometry.voxel_count()?;
531 for (label, value) in [
532 ("schema_version", self.schema_version.as_str()),
533 ("case_id", self.case_id.as_str()),
534 ("provenance_id", self.provenance_id.as_str()),
535 ] {
536 if value.trim().is_empty() {
537 return Err(ValidationError::EmptyIdentifier(label));
538 }
539 }
540 self.component_profile
541 .validate()
542 .map_err(|_| ValidationError::InvalidContentReference("component_profile"))?;
543 self.response_set
544 .validate()
545 .map_err(|_| ValidationError::InvalidContentReference("response_set"))?;
546
547 let mut observed = BTreeSet::new();
548
549 for volume in &self.components {
550 if !observed.insert(volume.component) {
551 return Err(ValidationError::DuplicateComponent(volume.component));
552 }
553 if volume.values.len() != voxel_count {
554 return Err(ValidationError::DoseLength {
555 component: volume.component,
556 expected: voxel_count,
557 actual: volume.values.len(),
558 });
559 }
560 if volume
561 .values
562 .iter()
563 .any(|value| !value.is_finite() || *value < 0.0)
564 {
565 return Err(ValidationError::InvalidDose(volume.component));
566 }
567 if volume.unit != self.physical_total.unit {
568 return Err(ValidationError::DoseUnitMismatch {
569 component: volume.component,
570 component_unit: volume.unit,
571 total_unit: self.physical_total.unit,
572 });
573 }
574 if let Some(uncertainty) = &volume.absolute_standard_uncertainty {
575 if uncertainty.len() != voxel_count {
576 return Err(ValidationError::UncertaintyLength {
577 component: volume.component,
578 expected: voxel_count,
579 actual: uncertainty.len(),
580 });
581 }
582 if uncertainty
583 .iter()
584 .any(|value| !value.is_finite() || *value < 0.0)
585 {
586 return Err(ValidationError::InvalidUncertainty(volume.component));
587 }
588 }
589 }
590
591 for required in DoseComponent::REQUIRED {
592 if !observed.contains(&required) {
593 return Err(ValidationError::MissingComponent(required));
594 }
595 }
596
597 if self.physical_total.values.len() != voxel_count {
598 return Err(ValidationError::TotalDoseLength {
599 expected: voxel_count,
600 actual: self.physical_total.values.len(),
601 });
602 }
603 if self
604 .physical_total
605 .values
606 .iter()
607 .any(|value| !value.is_finite() || *value < 0.0)
608 {
609 return Err(ValidationError::InvalidTotalDose);
610 }
611 if let Some(uncertainty) = &self.physical_total.absolute_standard_uncertainty {
612 if uncertainty.len() != voxel_count {
613 return Err(ValidationError::TotalUncertaintyLength {
614 expected: voxel_count,
615 actual: uncertainty.len(),
616 });
617 }
618 if uncertainty
619 .iter()
620 .any(|value| !value.is_finite() || *value < 0.0)
621 {
622 return Err(ValidationError::InvalidTotalUncertainty);
623 }
624 }
625 match (
626 self.physical_total.absolute_standard_uncertainty.is_some(),
627 self.physical_total.uncertainty_method,
628 ) {
629 (false, TotalUncertaintyMethod::Unavailable)
630 | (true, TotalUncertaintyMethod::DedicatedEstimator)
631 | (true, TotalUncertaintyMethod::BatchCovariance) => {}
632 _ => return Err(ValidationError::InconsistentTotalUncertainty),
633 }
634
635 Ok(())
636 }
637}
638
639fn is_canonical_sha256(value: &str) -> bool {
640 value.len() == 64
641 && value
642 .bytes()
643 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
644}
645
646#[derive(Debug, Error, PartialEq)]
647pub enum ValidationError {
648 #[error("voxel grid contains an empty dimension")]
649 EmptyGrid,
650 #[error("voxel spacing must be finite and greater than zero")]
651 InvalidSpacing,
652 #[error("voxel geometry contains a non-finite value")]
653 NonFiniteGeometry,
654 #[error("voxel direction matrix must be right-handed and orthonormal")]
655 InvalidDirection,
656 #[error("voxel count overflows the addressable platform size")]
657 GridTooLarge,
658 #[error("voxel {voxel:?} is outside grid shape {shape:?}")]
659 VoxelOutOfBounds { voxel: [u32; 3], shape: [u32; 3] },
660 #[error("physical dose component {0:?} is missing")]
661 MissingComponent(DoseComponent),
662 #[error("physical dose component {0:?} occurs more than once")]
663 DuplicateComponent(DoseComponent),
664 #[error("{component:?} has {actual} dose values; expected {expected}")]
665 DoseLength {
666 component: DoseComponent,
667 expected: usize,
668 actual: usize,
669 },
670 #[error("{0:?} contains a negative or non-finite physical dose")]
671 InvalidDose(DoseComponent),
672 #[error("{component:?} has {actual} uncertainty values; expected {expected}")]
673 UncertaintyLength {
674 component: DoseComponent,
675 expected: usize,
676 actual: usize,
677 },
678 #[error("{0:?} contains a negative or non-finite uncertainty")]
679 InvalidUncertainty(DoseComponent),
680 #[error("dose index {index} is outside volume length {length}")]
681 DoseIndexOutOfBounds { index: usize, length: usize },
682 #[error("required identifier {0} is empty")]
683 EmptyIdentifier(&'static str),
684 #[error("{0} must have a nonempty ID and canonical lowercase SHA-256 digest")]
685 InvalidContentReference(&'static str),
686 #[error("{component:?} uses {component_unit:?}, but the physical total uses {total_unit:?}")]
687 DoseUnitMismatch {
688 component: DoseComponent,
689 component_unit: DoseUnit,
690 total_unit: DoseUnit,
691 },
692 #[error("physical total has {actual} dose values; expected {expected}")]
693 TotalDoseLength { expected: usize, actual: usize },
694 #[error("physical total contains a negative or non-finite dose")]
695 InvalidTotalDose,
696 #[error("physical total has {actual} uncertainty values; expected {expected}")]
697 TotalUncertaintyLength { expected: usize, actual: usize },
698 #[error("physical total contains a negative or non-finite uncertainty")]
699 InvalidTotalUncertainty,
700 #[error("physical-total uncertainty and its method are inconsistent")]
701 InconsistentTotalUncertainty,
702 #[error(
703 "mask {name:?} has {actual} voxels but mask {other:?} has {expected}; masks must share one voxel count"
704 )]
705 MaskVoxelCountMismatch {
706 name: String,
707 other: String,
708 expected: usize,
709 actual: usize,
710 },
711 #[error("mask {0:?} selects no voxels")]
712 EmptyMask(String),
713 #[error("threshold window [{minimum}, {maximum}] must be finite with minimum <= maximum")]
714 InvalidThresholdWindow { minimum: f64, maximum: f64 },
715 #[error("mask {mask:?} covers {mask_voxels} voxels but the values array has {values} entries")]
716 MaskValuesLength {
717 mask: String,
718 mask_voxels: usize,
719 values: usize,
720 },
721 #[error("mask {mask:?} contains a non-finite value")]
722 NonFiniteMaskValue { mask: String },
723 #[error("dose selection {mask:?} contains a negative or non-finite value at voxel {index}")]
724 InvalidMaskedDose { mask: String, index: usize },
725 #[error("dose statistic {name} is invalid: {reason}")]
726 InvalidStatistic { name: &'static str, reason: String },
727}
728
729#[derive(Debug, Error, PartialEq, Eq)]
730pub enum ContentReferenceError {
731 #[error("content reference ID is empty")]
732 EmptyId,
733 #[error("content reference SHA-256 must be 64 lowercase hexadecimal characters")]
734 InvalidSha256,
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 #[test]
742 fn legacy_nctforge_contract_ids_match_current_schemas() {
743 assert!(schema_matches(
744 "nctforge.physical-dose-bundle/0.2.0",
745 PHYSICAL_DOSE_BUNDLE_SCHEMA
746 ));
747 assert!(schema_matches(
748 PHYSICAL_DOSE_BUNDLE_SCHEMA,
749 "nctforge.physical-dose-bundle/0.2.0"
750 ));
751 assert!(!schema_matches(
752 "nctforge.physical-dose-bundle/0.1.0",
753 PHYSICAL_DOSE_BUNDLE_SCHEMA
754 ));
755 assert!(!schema_matches(
756 "other.physical-dose-bundle/0.2.0",
757 PHYSICAL_DOSE_BUNDLE_SCHEMA
758 ));
759 assert_eq!(
760 normalize_contract_id("nctforge.registration/0.1.0"),
761 "openbnct.registration/0.1.0"
762 );
763 assert_eq!(
764 normalize_contract_id("openbnct.registration/0.1.0"),
765 "openbnct.registration/0.1.0"
766 );
767 }
768
769 #[test]
770 fn legacy_schema_version_is_accepted_on_read() {
771 let mut bundle = valid_bundle();
772 bundle.schema_version = "nctforge.physical-dose-bundle/0.2.0".into();
773 assert!(bundle.validate().is_ok());
774 }
775
776 fn geometry() -> GridGeometry {
777 GridGeometry {
778 shape: [2, 2, 1],
779 spacing_mm: [1.0, 1.0, 2.0],
780 origin_mm: [0.0; 3],
781 direction: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
782 }
783 }
784
785 fn volume(component: DoseComponent) -> DoseVolume {
786 DoseVolume {
787 component,
788 unit: DoseUnit::GrayPerSourceParticle,
789 values: vec![1.0; 4],
790 absolute_standard_uncertainty: Some(vec![0.1; 4]),
791 }
792 }
793
794 fn valid_bundle() -> PhysicalDoseBundle {
795 PhysicalDoseBundle {
796 schema_version: PHYSICAL_DOSE_BUNDLE_SCHEMA.into(),
797 case_id: "synthetic".into(),
798 frame_of_reference_uid: None,
799 geometry: geometry(),
800 component_profile: ComponentProfileReference {
801 id: "openbnct.macroscopic-absorbed-dose.v1".into(),
802 sha256: "a".repeat(64),
803 },
804 response_set: ContentReference {
805 id: "openbnct.synthetic-response-set.v1".into(),
806 sha256: "b".repeat(64),
807 },
808 components: DoseComponent::REQUIRED.into_iter().map(volume).collect(),
809 physical_total: PhysicalTotalDoseVolume {
810 unit: DoseUnit::GrayPerSourceParticle,
811 values: vec![4.0; 4],
812 absolute_standard_uncertainty: Some(vec![0.2; 4]),
813 uncertainty_method: TotalUncertaintyMethod::DedicatedEstimator,
814 },
815 provenance_id: "manifest-sha256:synthetic".into(),
816 }
817 }
818
819 #[test]
820 fn rejects_incomplete_component_bundle() {
821 let mut bundle = valid_bundle();
822 bundle.components.clear();
823
824 assert_eq!(
825 bundle.validate(),
826 Err(ValidationError::MissingComponent(DoseComponent::Boron))
827 );
828 }
829
830 #[test]
831 fn rejects_non_orthonormal_grid_direction() {
832 let mut invalid = geometry();
833 invalid.direction[4] = 2.0;
834 assert_eq!(
835 invalid.voxel_count(),
836 Err(ValidationError::InvalidDirection)
837 );
838 }
839
840 #[test]
841 fn validates_complete_physical_dose_contract() {
842 assert_eq!(valid_bundle().validate(), Ok(()));
843 }
844
845 #[test]
846 fn serializes_only_canonical_component_names() {
847 assert_eq!(
848 serde_json::to_string(&DoseComponent::Hydrogen).unwrap(),
849 "\"hydrogen\""
850 );
851 assert!(serde_json::from_str::<DoseComponent>("\"hydrogen_recoil\"").is_err());
852 }
853
854 #[test]
855 fn derives_relative_uncertainty_but_not_for_zero_mean() {
856 let volume = DoseVolume {
857 component: DoseComponent::Boron,
858 unit: DoseUnit::Gray,
859 values: vec![0.0, 2.0],
860 absolute_standard_uncertainty: Some(vec![0.1, 0.2]),
861 };
862
863 assert_eq!(volume.relative_standard_uncertainty(0), Ok(None));
864 let relative = volume.relative_standard_uncertainty(1).unwrap().unwrap();
865 assert!((relative - 0.1).abs() < f64::EPSILON);
866 }
867
868 #[test]
869 fn rejects_mixed_component_and_total_units() {
870 let mut bundle = valid_bundle();
871 bundle.components[0].unit = DoseUnit::Gray;
872
873 assert_eq!(
874 bundle.validate(),
875 Err(ValidationError::DoseUnitMismatch {
876 component: DoseComponent::Boron,
877 component_unit: DoseUnit::Gray,
878 total_unit: DoseUnit::GrayPerSourceParticle,
879 })
880 );
881 }
882
883 #[test]
884 fn requires_consistent_total_uncertainty_state() {
885 let mut bundle = valid_bundle();
886 bundle.physical_total.uncertainty_method = TotalUncertaintyMethod::Unavailable;
887
888 assert_eq!(
889 bundle.validate(),
890 Err(ValidationError::InconsistentTotalUncertainty)
891 );
892 }
893
894 #[test]
895 fn rejects_noncanonical_component_profile_hash() {
896 let mut bundle = valid_bundle();
897 bundle.component_profile.sha256 = "A".repeat(64);
898
899 assert_eq!(
900 bundle.validate(),
901 Err(ValidationError::InvalidContentReference(
902 "component_profile"
903 ))
904 );
905 }
906
907 #[test]
908 fn rejects_noncanonical_response_set_hash() {
909 let mut bundle = valid_bundle();
910 bundle.response_set.sha256 = "B".repeat(64);
911
912 assert_eq!(
913 bundle.validate(),
914 Err(ValidationError::InvalidContentReference("response_set"))
915 );
916 }
917
918 fn mask(name: &str, voxels: &[bool]) -> RegionMask {
919 RegionMask {
920 name: name.into(),
921 voxels: voxels.to_vec(),
922 }
923 }
924
925 #[test]
926 fn mask_subtract_removes_shared_voxels() {
927 let organ = mask("ORGAN", &[true, true, true, true]);
928 let tumor = mask("TUMOR", &[false, true, true, false]);
929
930 let limited = organ.subtract(&tumor, "ORGAN-T").unwrap();
931
932 assert_eq!(limited.name, "ORGAN-T");
933 assert_eq!(limited.voxels, vec![true, false, false, true]);
934 assert_eq!(limited.included_voxel_count(), 2);
935 }
936
937 #[test]
938 fn mask_union_and_intersection() {
939 let a = mask("A", &[true, true, false, false]);
940 let b = mask("B", &[false, true, true, false]);
941
942 assert_eq!(
943 a.union(&b, "U").unwrap().voxels,
944 vec![true, true, true, false]
945 );
946 assert_eq!(
947 a.intersection(&b, "I").unwrap().voxels,
948 vec![false, true, false, false]
949 );
950 }
951
952 #[test]
953 fn mask_ops_reject_frame_mismatch() {
954 let a = mask("A", &[true; 4]);
955 let b = mask("B", &[true; 8]);
956
957 assert_eq!(
958 a.union(&b, "U"),
959 Err(ValidationError::MaskVoxelCountMismatch {
960 name: "A".into(),
961 other: "B".into(),
962 expected: 4,
963 actual: 8,
964 })
965 );
966 }
967
968 #[test]
969 fn mask_ops_reject_empty_result() {
970 let a = mask("A", &[true, true, false, false]);
971 let b = mask("B", &[false, false, true, true]);
972
973 assert_eq!(
974 a.intersection(&b, "I"),
975 Err(ValidationError::EmptyMask("I".into()))
976 );
977 assert_eq!(
978 a.subtract(&a.clone(), "E"),
979 Err(ValidationError::EmptyMask("E".into()))
980 );
981 }
982}