Skip to main content

openbnct_core/
interchange.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Transport-neutral component-dose interchange.
4//!
5//! `openbnct.component-dose-interchange/0.1.0` is the published document any
6//! external transport pipeline (MCNP, PHITS, Geant4, custom tools) emits to
7//! hand NCTForge a component-resolved voxel dose. It carries everything the
8//! authoritative `openbnct.physical-dose-bundle/0.2.0` contract needs while
9//! keeping the producer's normalization and estimator semantics explicit:
10//!
11//! - geometry reuses the transport-neutral [`GridGeometry`];
12//! - components reuse [`DoseVolume`] (all four [`DoseComponent`] kinds are
13//!   required, in the bundle's `i + nx*j + nx*ny*k` grid order);
14//! - `producer` names the external system, its version, and how tallies
15//!   were normalized and folded;
16//! - `total` is either a producer-tallied dedicated total or an explicit
17//!   request to derive the total as the component sum — in which case no
18//!   total uncertainty is claimed, because the importer cannot know the
19//!   producer's component covariance;
20//! - `component_profile`/`response_set` are producer-declared content
21//!   references; when absent the importer binds the interchange document's
22//!   own SHA-256 so provenance never silently dangles.
23
24use 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
35/// Schema identifier carried by every interchange document.
36pub const COMPONENT_DOSE_INTERCHANGE_SCHEMA: &str = "openbnct.component-dose-interchange/0.1.0";
37
38/// The external system that produced the dose and how it normalized it.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct ExternalProducer {
42    /// Transport system name — for example `mcnp`, `phits`, `geant4`, or a
43    /// custom pipeline identifier.
44    pub system: String,
45    /// Producer version string (free text; not parsed).
46    pub version: String,
47    /// How the tallies were normalized and scored — for example the source
48    /// particle count, folding tables, or estimator chain used.
49    pub normalization: String,
50}
51
52/// How the bundle's `physical_total` is determined on import.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
55pub enum ExternalTotal {
56    /// The producer tallied the physical total directly. `values` is
57    /// required; `absolute_standard_uncertainty` is optional and, when
58    /// present, is recorded with `dedicated_estimator` method provenance.
59    Dedicated {
60        values: Vec<f64>,
61        absolute_standard_uncertainty: Option<Vec<f64>>,
62    },
63    /// Derive the total as the sum of the four components. The importer
64    /// never claims a total uncertainty here: component covariance is the
65    /// producer's domain knowledge, so `unavailable` is the honest record.
66    ComponentSum,
67}
68
69/// The interchange document: everything needed to construct a
70/// `PhysicalDoseBundle` without loss of meaning.
71#[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    /// Component dose volumes in grid order. Exactly the four
81    /// `DoseComponent::REQUIRED` kinds must appear, once each.
82    pub components: Vec<DoseVolume>,
83    /// Producer-declared component-definition reference; absent binds the
84    /// document's own hash on import.
85    pub component_profile: Option<ComponentProfileReference>,
86    /// Producer-declared response/folding reference; absent binds the
87    /// document's own hash on import.
88    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    /// The single unit shared by every component (checked by `validate`).
200    fn unit(&self) -> DoseUnit {
201        self.components[0].unit
202    }
203}
204
205/// Loose grid comparison for producers that print identical meshes at finite
206/// precision: exact shape equality plus a tight relative tolerance on spacing,
207/// origin, and direction. Importers must use this rather than `==` on f64
208/// fields, and must never resample disagreeing meshes into agreement.
209pub 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
228/// Import an interchange document into a validated `PhysicalDoseBundle`.
229///
230/// `document_sha256` is the SHA-256 of the interchange document's bytes; it
231/// anchors the output's `provenance_id` and stands in for any
232/// producer-declared references that were left absent.
233pub 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        // Absent producer references bind the document's own hash.
408        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        // 1e-12 + 2e-13 + 5e-13 + 3e-12 = 4.7e-12 per unit value.
419        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}