Skip to main content

openbnct_core/
systematic.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Systematic uncertainty propagation (`openbnct.systematic-uncertainty/0.1.0`).
4//!
5//! Monte Carlo uncertainties in a dose bundle are per-voxel *statistical*
6//! standard uncertainties — independent from voxel to voxel. Systematic
7//! uncertainties behave differently: one blood boron assay, one
8//! registration residual, one response calibration shifts *every* voxel
9//! together. This module records declared systematic sources, propagates
10//! them to per-voxel contributions, and combines them with the bundle's
11//! statistical uncertainty under the honest correlation model:
12//!
13//! - per voxel, sources are independent of one another → quadrature;
14//! - for a region mean, statistical σ averages down as
15//!   `sqrt(Σσ²)/N` while each systematic source contributes its *mean*
16//!   per-voxel σ (fully correlated across voxels), sources then combined
17//!   in quadrature.
18//!
19//! The report is a separate layer: the dose bundle's
20//! `absolute_standard_uncertainty` remains pure Monte Carlo, and nothing
21//! here relabels a systematic-augmented σ as statistical.
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::{ContentReference, GridGeometry, RegionMask, ValidationError};
27
28/// Current schema token for systematic-uncertainty reports.
29pub const SYSTEMATIC_UNCERTAINTY_SCHEMA: &str = "openbnct.systematic-uncertainty/0.1.0";
30
31/// Qualification asserted on every report.
32pub const SYSTEMATIC_UNCERTAINTY_QUALIFICATION: &str =
33    "systematic_uncertainty_research_only_not_clinical";
34
35/// A declared systematic uncertainty source. The variant is the audit
36/// trail — *what* was declared; the per-voxel contribution map is computed
37/// at apply time and summarized in [`SourceSummary`].
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "snake_case")]
40pub enum UncertaintySource {
41    /// Fractional uncertainty of an estimated B-10 concentration field
42    /// applied to the boron dose component:
43    /// `σ(v) = D_boron(v) · σ_B(v)/B(v)`. Voxels where the field
44    /// concentration is zero contribute zero (recorded in the summary).
45    BoronConcentration {
46        /// Content-bound `openbnct.boron-field` document.
47        field: ContentReference,
48    },
49    /// Translational positioning uncertainty: `σ(v) = |∇D_phys(v)|·σ_pos`,
50    /// the first-order dose shift under a rigid displacement. `∇D` is the
51    /// physical-total dose gradient by central differences.
52    Positioning {
53        /// Declared 1σ displacement magnitude in millimetres.
54        sigma_mm: f64,
55        /// Optional registration document the σ derives from (its RMS
56        /// landmark residual is the conventional estimate).
57        registration: Option<ContentReference>,
58    },
59    /// Declared relative 1σ on a named dose component — response
60    /// calibration, model-parameter, or loading uncertainty expressed
61    /// directly on the component: `σ(v) = rel · D_component(v)`.
62    RelativeComponent {
63        /// Component name matching `DoseComponent::name` (`boron`,
64        /// `nitrogen`, `hydrogen`, `photon`).
65        component: String,
66        /// Relative standard uncertainty, dimensionless.
67        relative_1sigma: f64,
68    },
69}
70
71/// Summary statistics for one source's realized per-voxel contribution.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct SourceSummary {
75    /// Source kind tag (matches the corresponding `UncertaintySource`).
76    pub kind: String,
77    /// Mean of the per-voxel contribution over all grid voxels.
78    pub mean_1sigma: f64,
79    /// Maximum per-voxel contribution.
80    pub max_1sigma: f64,
81    /// Voxels where the source could contribute but was degenerate —
82    /// e.g. a zero-concentration boron voxel — recorded, not hidden.
83    pub skipped_voxels: u64,
84}
85
86/// Region-mean uncertainty under the correlation model.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct RegionUncertainty {
90    pub region: String,
91    pub voxel_count: u64,
92    /// Region mean of the reported quantity.
93    pub mean_dose: f64,
94    /// σ of the region mean from voxelwise-independent Monte Carlo
95    /// statistics: `sqrt(Σσ_mc²)/N`.
96    pub monte_carlo_1sigma: Option<f64>,
97    /// σ of the region mean with every systematic source fully
98    /// correlated across voxels (quadrature across sources):
99    /// `sqrt(Σ_src mean_v(σ_src)²)`.
100    pub systematic_1sigma: f64,
101    /// `sqrt(mc² + systematic²)`.
102    pub combined_1sigma: Option<f64>,
103}
104
105/// A versioned systematic-uncertainty report over one dose quantity.
106#[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    /// The physical dose bundle the report propagates over.
113    pub dose_bundle: ContentReference,
114    /// The quantity the voxel results describe (e.g. `physical_total`).
115    pub quantity: String,
116    /// Declared sources, in evaluation order.
117    pub sources: Vec<UncertaintySource>,
118    /// Per-source contribution summaries, parallel to `sources`.
119    pub source_summaries: Vec<SourceSummary>,
120    /// Per-voxel quadrature-combined systematic σ, grid order, same unit
121    /// as the quantity.
122    pub systematic_1sigma: Vec<f64>,
123    /// Per-voxel `sqrt(σ_mc² + σ_sys²)`; absent when the bundle carries
124    /// no statistical uncertainty for the quantity.
125    pub combined_1sigma: Option<Vec<f64>>,
126    /// Region-mean results for declared masks.
127    pub regions: Vec<RegionUncertainty>,
128    pub qualification: String,
129    pub provenance_id: String,
130}
131
132/// Errors from systematic-uncertainty evaluation and validation.
133#[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    /// Structural validation; called by consumers.
147    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/// Per-voxel σ for a `relative_component` source: `σ(v) = rel·D(v)`.
236/// Non-finite or negative dose values are treated as zero contribution.
237#[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/// Per-voxel σ for the boron-field source: `σ(v) = D_b(v)·σ_B(v)/B(v)`
251/// for `B(v) > 0`, else 0. Returns the map plus the count of voxels
252/// skipped because the field concentration was zero.
253#[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/// Per-voxel positioning σ: `|∇D(v)|·σ_mm` by central differences on the
280/// grid (one-sided at faces). The gradient is taken along voxel axes —
281/// valid because `GridGeometry` direction is validated orthonormal, so
282/// the axis-gradient L2 norm equals the world-space magnitude under any
283/// rotation. `dose` is the physical-total volume in grid order.
284#[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/// Quadrature-combine per-voxel contribution maps of independent sources.
330#[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/// `sqrt(σ_mc² + σ_sys²)` per voxel; `None` entries in `mc` are treated
347/// as zero statistical contribution.
348#[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/// Region-mean uncertainty honoring the correlation model.
359///
360/// - Statistical σ averages down: `σ_mc(D̄) = sqrt(Σσ_mc(v)²)/N`.
361/// - Each systematic source is fully correlated across voxels:
362///   `σ_src(D̄) = mean_v σ_src(v)`; sources combine in quadrature.
363/// - Combined σ is the quadrature sum of the two.
364#[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/// Mean/max summary of a per-voxel contribution map.
417#[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); // 20% -> 1.6
465        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        // Linear ramp along x: D = i (per index), spacing 2 mm
473        // -> |dD/dx| = 0.5/mm interior, one-sided same at faces.
474        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        // Two voxels: dose 10 each, mc σ 0.1 each (independent),
492        // one systematic source contributing 2.0 each (correlated).
493        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        // independent: sqrt(0.01+0.01)/2 ≈ 0.0707
503        assert!((r.monte_carlo_1sigma.unwrap() - (0.02f64.sqrt() / 2.0)).abs() < 1e-12);
504        // correlated: mean of contributions = 2.0
505        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}