1use std::collections::BTreeSet;
25
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use crate::{
30 ComponentProfileReference, ContentReference, DoseComponent, DoseUnit, DoseVolume, GridGeometry,
31 PHYSICAL_DOSE_BUNDLE_SCHEMA, PhysicalDoseBundle, PhysicalTotalDoseVolume,
32 TotalUncertaintyMethod, ValidationError,
33};
34
35pub const COMPONENT_DOSE_INTERCHANGE_SCHEMA: &str = "openbnct.component-dose-interchange/0.1.0";
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct ExternalProducer {
42 pub system: String,
45 pub version: String,
47 pub normalization: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
55pub enum ExternalTotal {
56 Dedicated {
60 values: Vec<f64>,
61 absolute_standard_uncertainty: Option<Vec<f64>>,
62 },
63 ComponentSum,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct ComponentDoseInterchange {
74 #[serde(deserialize_with = "crate::deserialize_contract_id")]
75 pub schema_version: String,
76 pub case_id: String,
77 pub frame_of_reference_uid: Option<String>,
78 pub geometry: GridGeometry,
79 pub producer: ExternalProducer,
80 pub components: Vec<DoseVolume>,
83 pub component_profile: Option<ComponentProfileReference>,
86 pub response_set: Option<ContentReference>,
89 pub total: ExternalTotal,
90}
91
92impl ComponentDoseInterchange {
93 pub fn validate(&self) -> Result<(), InterchangeError> {
94 if !crate::schema_matches(&self.schema_version, COMPONENT_DOSE_INTERCHANGE_SCHEMA) {
95 return Err(InterchangeError::UnsupportedSchema(
96 self.schema_version.clone(),
97 ));
98 }
99 for (label, value) in [
100 ("case_id", self.case_id.as_str()),
101 ("producer.system", self.producer.system.as_str()),
102 ("producer.version", self.producer.version.as_str()),
103 (
104 "producer.normalization",
105 self.producer.normalization.as_str(),
106 ),
107 ] {
108 if value.trim().is_empty() {
109 return Err(InterchangeError::EmptyIdentifier(label));
110 }
111 }
112 let voxel_count = self
113 .geometry
114 .voxel_count()
115 .map_err(InterchangeError::Geometry)?;
116 if self.components.is_empty() {
117 return Err(InterchangeError::NoComponents);
118 }
119 let unit = self.components[0].unit;
120 let mut observed = BTreeSet::new();
121 for volume in &self.components {
122 if !observed.insert(volume.component) {
123 return Err(InterchangeError::DuplicateComponent(volume.component));
124 }
125 if volume.unit != unit {
126 return Err(InterchangeError::InconsistentUnits {
127 component: volume.component,
128 component_unit: volume.unit,
129 expected: unit,
130 });
131 }
132 if volume.values.len() != voxel_count {
133 return Err(InterchangeError::DoseLength {
134 component: volume.component,
135 expected: voxel_count,
136 actual: volume.values.len(),
137 });
138 }
139 if volume.values.iter().any(|v| !v.is_finite() || *v < 0.0) {
140 return Err(InterchangeError::InvalidDose(volume.component));
141 }
142 if let Some(sigma) = &volume.absolute_standard_uncertainty {
143 if sigma.len() != voxel_count {
144 return Err(InterchangeError::UncertaintyLength {
145 component: volume.component,
146 expected: voxel_count,
147 actual: sigma.len(),
148 });
149 }
150 if sigma.iter().any(|v| !v.is_finite() || *v < 0.0) {
151 return Err(InterchangeError::InvalidUncertainty(volume.component));
152 }
153 }
154 }
155 for required in DoseComponent::REQUIRED {
156 if !observed.contains(&required) {
157 return Err(InterchangeError::MissingComponent(required));
158 }
159 }
160 if let ExternalTotal::Dedicated {
161 values,
162 absolute_standard_uncertainty,
163 } = &self.total
164 {
165 if values.len() != voxel_count {
166 return Err(InterchangeError::TotalLength {
167 expected: voxel_count,
168 actual: values.len(),
169 });
170 }
171 if values.iter().any(|v| !v.is_finite() || *v < 0.0) {
172 return Err(InterchangeError::InvalidTotal);
173 }
174 if let Some(sigma) = absolute_standard_uncertainty {
175 if sigma.len() != voxel_count {
176 return Err(InterchangeError::TotalUncertaintyLength {
177 expected: voxel_count,
178 actual: sigma.len(),
179 });
180 }
181 if sigma.iter().any(|v| !v.is_finite() || *v < 0.0) {
182 return Err(InterchangeError::InvalidTotalUncertainty);
183 }
184 }
185 }
186 for (label, reference) in [
187 ("component_profile", &self.component_profile),
188 ("response_set", &self.response_set),
189 ] {
190 if let Some(reference) = reference {
191 reference
192 .validate()
193 .map_err(|_| InterchangeError::InvalidReference(label))?;
194 }
195 }
196 Ok(())
197 }
198
199 fn unit(&self) -> DoseUnit {
201 self.components[0].unit
202 }
203}
204
205pub fn grid_geometry_equivalent(a: &GridGeometry, b: &GridGeometry) -> bool {
210 if a.shape != b.shape {
211 return false;
212 }
213 let close = |x: f64, y: f64| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1e-12);
214 a.spacing_mm
215 .iter()
216 .zip(&b.spacing_mm)
217 .all(|(x, y)| close(*x, *y))
218 && a.origin_mm
219 .iter()
220 .zip(&b.origin_mm)
221 .all(|(x, y)| close(*x, *y))
222 && a.direction
223 .iter()
224 .zip(&b.direction)
225 .all(|(x, y)| close(*x, *y))
226}
227
228pub fn import_component_dose(
234 document: &ComponentDoseInterchange,
235 document_sha256: &str,
236) -> Result<PhysicalDoseBundle, InterchangeError> {
237 document.validate()?;
238 let voxel_count = document
239 .geometry
240 .voxel_count()
241 .map_err(InterchangeError::Geometry)?;
242 let unit = document.unit();
243 let physical_total = match &document.total {
244 ExternalTotal::Dedicated {
245 values,
246 absolute_standard_uncertainty,
247 } => PhysicalTotalDoseVolume {
248 unit,
249 values: values.clone(),
250 absolute_standard_uncertainty: absolute_standard_uncertainty.clone(),
251 uncertainty_method: if absolute_standard_uncertainty.is_some() {
252 TotalUncertaintyMethod::DedicatedEstimator
253 } else {
254 TotalUncertaintyMethod::Unavailable
255 },
256 },
257 ExternalTotal::ComponentSum => {
258 let mut values = vec![0.0_f64; voxel_count];
259 for volume in &document.components {
260 for (index, value) in values.iter_mut().enumerate() {
261 *value += volume.values[index];
262 }
263 }
264 if values.iter().any(|v| !v.is_finite()) {
265 return Err(InterchangeError::InvalidTotal);
266 }
267 PhysicalTotalDoseVolume {
268 unit,
269 values,
270 absolute_standard_uncertainty: None,
271 uncertainty_method: TotalUncertaintyMethod::Unavailable,
272 }
273 }
274 };
275 let fallback = || ContentReference {
276 id: format!("external:{}", document.producer.system),
277 sha256: document_sha256.to_owned(),
278 };
279 let bundle = PhysicalDoseBundle {
280 schema_version: PHYSICAL_DOSE_BUNDLE_SCHEMA.into(),
281 case_id: document.case_id.clone(),
282 frame_of_reference_uid: document.frame_of_reference_uid.clone(),
283 geometry: document.geometry.clone(),
284 component_profile: document.component_profile.clone().unwrap_or_else(fallback),
285 response_set: document.response_set.clone().unwrap_or_else(fallback),
286 components: document.components.clone(),
287 physical_total,
288 provenance_id: format!(
289 "interchange:{}:sha256:{}",
290 document.producer.system, document_sha256
291 ),
292 };
293 bundle.validate().map_err(InterchangeError::Bundle)?;
294 Ok(bundle)
295}
296
297#[derive(Debug, Error)]
298pub enum InterchangeError {
299 #[error("unsupported interchange schema {0:?}; expected {COMPONENT_DOSE_INTERCHANGE_SCHEMA:?}")]
300 UnsupportedSchema(String),
301 #[error("required identifier {0} is empty")]
302 EmptyIdentifier(&'static str),
303 #[error("interchange geometry: {0}")]
304 Geometry(ValidationError),
305 #[error("interchange document carries no components")]
306 NoComponents,
307 #[error("component {0:?} occurs more than once")]
308 DuplicateComponent(DoseComponent),
309 #[error("component {0:?} is required but absent")]
310 MissingComponent(DoseComponent),
311 #[error(
312 "component {component:?} uses {component_unit:?} while earlier components use {expected:?}"
313 )]
314 InconsistentUnits {
315 component: DoseComponent,
316 component_unit: DoseUnit,
317 expected: DoseUnit,
318 },
319 #[error("component {component:?} dose length {actual} != voxel count {expected}")]
320 DoseLength {
321 component: DoseComponent,
322 expected: usize,
323 actual: usize,
324 },
325 #[error("component {0:?} carries non-finite or negative dose")]
326 InvalidDose(DoseComponent),
327 #[error("component {component:?} uncertainty length {actual} != voxel count {expected}")]
328 UncertaintyLength {
329 component: DoseComponent,
330 expected: usize,
331 actual: usize,
332 },
333 #[error("component {0:?} carries non-finite or negative uncertainty")]
334 InvalidUncertainty(DoseComponent),
335 #[error("dedicated total length {actual} != voxel count {expected}")]
336 TotalLength { expected: usize, actual: usize },
337 #[error("dedicated total carries non-finite or negative dose")]
338 InvalidTotal,
339 #[error("dedicated total uncertainty length {actual} != voxel count {expected}")]
340 TotalUncertaintyLength { expected: usize, actual: usize },
341 #[error("dedicated total carries non-finite or negative uncertainty")]
342 InvalidTotalUncertainty,
343 #[error("producer-declared {0} is not a valid content reference")]
344 InvalidReference(&'static str),
345 #[error("imported bundle failed contract validation: {0}")]
346 Bundle(ValidationError),
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::DoseComponent;
353
354 fn geometry() -> GridGeometry {
355 GridGeometry {
356 shape: [2, 1, 1],
357 spacing_mm: [10.0, 10.0, 10.0],
358 origin_mm: [-5.0, 0.0, 0.0],
359 direction: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
360 }
361 }
362
363 fn component(component: DoseComponent, value: f64) -> DoseVolume {
364 DoseVolume {
365 component,
366 unit: DoseUnit::GrayPerSourceParticle,
367 values: vec![value, 2.0 * value],
368 absolute_standard_uncertainty: Some(vec![0.01 * value, 0.02 * value]),
369 }
370 }
371
372 fn document(total: ExternalTotal) -> ComponentDoseInterchange {
373 ComponentDoseInterchange {
374 schema_version: COMPONENT_DOSE_INTERCHANGE_SCHEMA.into(),
375 case_id: "external-case".into(),
376 frame_of_reference_uid: Some("9.8.7".into()),
377 geometry: geometry(),
378 producer: ExternalProducer {
379 system: "phits".into(),
380 version: "3.34".into(),
381 normalization: "per source particle; F6 kerma tallies".into(),
382 },
383 components: vec![
384 component(DoseComponent::Boron, 1.0e-12),
385 component(DoseComponent::Nitrogen, 2.0e-13),
386 component(DoseComponent::Hydrogen, 5.0e-13),
387 component(DoseComponent::Photon, 3.0e-12),
388 ],
389 component_profile: None,
390 response_set: None,
391 total,
392 }
393 }
394
395 #[test]
396 fn dedicated_total_imports_with_estimator_provenance() {
397 let doc = document(ExternalTotal::Dedicated {
398 values: vec![4.0e-12, 8.0e-12],
399 absolute_standard_uncertainty: Some(vec![4.0e-14, 8.0e-14]),
400 });
401 let bundle = import_component_dose(&doc, &"f".repeat(64)).unwrap();
402 assert_eq!(bundle.physical_total.values, vec![4.0e-12, 8.0e-12]);
403 assert_eq!(
404 bundle.physical_total.uncertainty_method,
405 TotalUncertaintyMethod::DedicatedEstimator
406 );
407 assert_eq!(bundle.component_profile.sha256, "f".repeat(64));
409 assert_eq!(bundle.response_set.id, "external:phits");
410 assert!(bundle.provenance_id.contains("interchange:phits:"));
411 assert_eq!(bundle.frame_of_reference_uid.as_deref(), Some("9.8.7"));
412 }
413
414 #[test]
415 fn component_sum_derives_total_without_claimed_uncertainty() {
416 let doc = document(ExternalTotal::ComponentSum);
417 let bundle = import_component_dose(&doc, &"f".repeat(64)).unwrap();
418 for (actual, expected) in bundle.physical_total.values.iter().zip([4.7e-12, 9.4e-12]) {
420 assert!((actual - expected).abs() / expected < 1.0e-12);
421 }
422 assert_eq!(bundle.physical_total.absolute_standard_uncertainty, None);
423 assert_eq!(
424 bundle.physical_total.uncertainty_method,
425 TotalUncertaintyMethod::Unavailable
426 );
427 }
428
429 #[test]
430 fn dedicated_total_without_sigma_marks_unavailable() {
431 let doc = document(ExternalTotal::Dedicated {
432 values: vec![4.0e-12, 8.0e-12],
433 absolute_standard_uncertainty: None,
434 });
435 let bundle = import_component_dose(&doc, &"f".repeat(64)).unwrap();
436 assert_eq!(
437 bundle.physical_total.uncertainty_method,
438 TotalUncertaintyMethod::Unavailable
439 );
440 }
441
442 #[test]
443 fn rejects_missing_component_bad_units_and_short_totals() {
444 let mut doc = document(ExternalTotal::ComponentSum);
445 doc.components.pop();
446 assert!(matches!(
447 import_component_dose(&doc, &"f".repeat(64)),
448 Err(InterchangeError::MissingComponent(DoseComponent::Photon))
449 ));
450
451 let mut doc = document(ExternalTotal::ComponentSum);
452 doc.components[1].unit = DoseUnit::Gray;
453 assert!(matches!(
454 import_component_dose(&doc, &"f".repeat(64)),
455 Err(InterchangeError::InconsistentUnits { .. })
456 ));
457
458 let mut doc = document(ExternalTotal::Dedicated {
459 values: vec![1.0],
460 absolute_standard_uncertainty: None,
461 });
462 doc.components[0].values = vec![-1.0, 1.0];
463 assert!(matches!(
464 import_component_dose(&doc, &"f".repeat(64)),
465 Err(InterchangeError::InvalidDose(DoseComponent::Boron))
466 ));
467
468 let mut doc = document(ExternalTotal::Dedicated {
469 values: vec![1.0],
470 absolute_standard_uncertainty: None,
471 });
472 doc.components[0].values = vec![1.0, 1.0];
473 assert!(matches!(
474 import_component_dose(&doc, &"f".repeat(64)),
475 Err(InterchangeError::TotalLength {
476 expected: 2,
477 actual: 1
478 })
479 ));
480 }
481}