use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use arrow::array::{Array, Float32Array, Float64Array, Int32Array, Int64Array};
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
static Z_TO_SYMBOL: [&str; 119] = [
"", "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S",
"Cl", "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge",
"As", "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd",
"In", "Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd",
"Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg",
"Tl", "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm",
"Bk", "Cf", "Es", "Fm", "Md", "No", "Lr", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn",
"Nh", "Fl", "Mc", "Lv", "Ts", "Og",
];
pub fn z_to_symbol(z: u32) -> Option<&'static str> {
if z == 0 || z as usize >= Z_TO_SYMBOL.len() {
None
} else {
Some(Z_TO_SYMBOL[z as usize])
}
}
#[derive(Debug, Clone)]
pub struct AbundanceEntry {
pub z: u32,
pub a: u32,
pub symbol: String,
pub abundance: f64,
pub atomic_mass: f64,
}
#[derive(Clone)]
pub struct AbundancesDb {
data: HashMap<u32, Vec<AbundanceEntry>>,
}
unsafe impl Send for AbundancesDb {}
unsafe impl Sync for AbundancesDb {}
impl AbundancesDb {
pub fn open(data_dir: impl AsRef<Path>) -> crate::Result<Self> {
let path = data_dir.as_ref().join("abundances.parquet");
let file = fs::File::open(&path)?;
Self::parse(file)
}
pub fn from_bytes(data: &[u8]) -> crate::Result<Self> {
Self::parse(bytes::Bytes::from(data.to_vec()))
}
fn parse(
reader_source: impl parquet::file::reader::ChunkReader + 'static,
) -> crate::Result<Self> {
let mut data: HashMap<u32, Vec<AbundanceEntry>> = HashMap::new();
let reader = ParquetRecordBatchReaderBuilder::try_new(reader_source)?.build()?;
for batch in reader {
let batch = batch?;
let z_col = batch
.column_by_name("Z")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let a_col = batch
.column_by_name("A")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let sym_col_ref = batch.column_by_name("symbol");
let sym_values = sym_col_ref.and_then(|c| crate::interp::as_string_array(c));
let ab_col = batch
.column_by_name("abundance")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let mass_col = batch
.column_by_name("atomic_mass")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
if let (Some(z), Some(a), Some(sym), Some(ab), Some(mass)) =
(z_col, a_col, sym_values, ab_col, mass_col)
{
#[allow(clippy::needless_range_loop)]
for i in 0..batch.num_rows() {
let entry = AbundanceEntry {
z: z.value(i) as u32,
a: a.value(i) as u32,
symbol: sym[i].unwrap_or("").to_string(),
abundance: ab.value(i),
atomic_mass: mass.value(i),
};
data.entry(entry.z).or_default().push(entry);
}
}
}
Ok(Self { data })
}
pub fn isotopes(&self, z: u32) -> &[AbundanceEntry] {
self.data.get(&z).map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn abundance(&self, z: u32, a: u32) -> f64 {
self.isotopes(z)
.iter()
.find(|e| e.a == a)
.map(|e| e.abundance)
.unwrap_or(0.0)
}
}
#[derive(Debug, Clone)]
pub struct DecayEntry {
pub z: u32,
pub a: u32,
pub state: String,
pub half_life_s: Option<f64>,
pub decay_mode: String,
pub daughter_z: Option<u32>,
pub daughter_a: Option<u32>,
pub daughter_state: String,
pub branching: f64,
}
#[derive(Clone)]
pub struct DecayDb {
data: HashMap<(u32, u32), Vec<DecayEntry>>,
}
unsafe impl Send for DecayDb {}
unsafe impl Sync for DecayDb {}
impl DecayDb {
pub fn open(data_dir: impl AsRef<Path>) -> crate::Result<Self> {
let path = data_dir.as_ref().join("decay.parquet");
let file = fs::File::open(&path)?;
Self::parse(file)
}
pub fn from_bytes(data: &[u8]) -> crate::Result<Self> {
Self::parse(bytes::Bytes::from(data.to_vec()))
}
fn parse(
reader_source: impl parquet::file::reader::ChunkReader + 'static,
) -> crate::Result<Self> {
let mut data: HashMap<(u32, u32), Vec<DecayEntry>> = HashMap::new();
let reader = ParquetRecordBatchReaderBuilder::try_new(reader_source)?.build()?;
for batch in reader {
let batch = batch?;
let z_col = batch
.column_by_name("Z")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let a_col = batch
.column_by_name("A")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let state_col_ref = batch.column_by_name("state");
let state_values = state_col_ref.and_then(|c| crate::interp::as_string_array(c));
let hl_col = batch
.column_by_name("half_life_s")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let mode_col_ref = batch.column_by_name("decay_mode");
let mode_values = mode_col_ref.and_then(|c| crate::interp::as_string_array(c));
let dz_col = batch
.column_by_name("daughter_Z")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let da_col = batch
.column_by_name("daughter_A")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let ds_col_ref = batch.column_by_name("daughter_state");
let ds_values = ds_col_ref.and_then(|c| crate::interp::as_string_array(c));
let br_col = batch
.column_by_name("branching")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
if let (
Some(z),
Some(a),
Some(state),
Some(hl),
Some(mode),
Some(dz),
Some(da),
Some(ds),
Some(br),
) = (
z_col,
a_col,
state_values,
hl_col,
mode_values,
dz_col,
da_col,
ds_values,
br_col,
) {
#[allow(clippy::needless_range_loop)]
for i in 0..batch.num_rows() {
let z_val = z.value(i) as u32;
let a_val = a.value(i) as u32;
let entry = DecayEntry {
z: z_val,
a: a_val,
state: state[i].unwrap_or("").to_string(),
half_life_s: if hl.is_null(i) {
None
} else {
Some(hl.value(i))
},
decay_mode: mode[i].unwrap_or("").to_string(),
daughter_z: if dz.is_null(i) {
None
} else {
Some(dz.value(i) as u32)
},
daughter_a: if da.is_null(i) {
None
} else {
Some(da.value(i) as u32)
},
daughter_state: ds[i].unwrap_or("").to_string(),
branching: br.value(i),
};
data.entry((z_val, a_val)).or_default().push(entry);
}
}
}
Ok(Self { data })
}
pub fn modes(&self, z: u32, a: u32, state: &str) -> Vec<&DecayEntry> {
self.data
.get(&(z, a))
.map(|entries| entries.iter().filter(|e| e.state == state).collect())
.unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct DoseConstant {
pub k: f64,
pub source: String,
}
#[derive(Clone)]
pub struct DoseDb {
data: HashMap<(u32, u32, String), DoseConstant>,
}
unsafe impl Send for DoseDb {}
unsafe impl Sync for DoseDb {}
impl DoseDb {
pub fn open(data_dir: impl AsRef<Path>) -> crate::Result<Self> {
let path = data_dir.as_ref().join("dose_constants.parquet");
let file = fs::File::open(&path)?;
Self::parse(file)
}
pub fn from_bytes(data: &[u8]) -> crate::Result<Self> {
Self::parse(bytes::Bytes::from(data.to_vec()))
}
fn parse(
reader_source: impl parquet::file::reader::ChunkReader + 'static,
) -> crate::Result<Self> {
let mut data: HashMap<(u32, u32, String), DoseConstant> = HashMap::new();
let reader = ParquetRecordBatchReaderBuilder::try_new(reader_source)?.build()?;
for batch in reader {
let batch = batch?;
let z_col = batch
.column_by_name("Z")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let a_col = batch
.column_by_name("A")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>());
let state_col_ref = batch.column_by_name("state");
let state_values = state_col_ref.and_then(|c| crate::interp::as_string_array(c));
let k_col = batch
.column_by_name("k_uSv_m2_MBq_h")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let src_col_ref = batch.column_by_name("source");
let src_values = src_col_ref.and_then(|c| crate::interp::as_string_array(c));
if let (Some(z), Some(a), Some(state), Some(k)) = (z_col, a_col, state_values, k_col) {
#[allow(clippy::needless_range_loop)]
for i in 0..batch.num_rows() {
let source = src_values
.as_ref()
.and_then(|s| s[i])
.unwrap_or("")
.to_string();
data.insert(
(
z.value(i) as u32,
a.value(i) as u32,
state[i].unwrap_or("").to_string(),
),
DoseConstant {
k: k.value(i),
source,
},
);
}
}
}
Ok(Self { data })
}
pub fn dose_constant(&self, z: u32, a: u32, state: &str) -> Option<&DoseConstant> {
self.data.get(&(z, a, state.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct Emission {
pub rad_type: String,
pub energy_kev: f64,
pub intensity: f32,
pub shell: Option<String>,
pub icc_total: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct CoincidenceEntry {
pub z: u32,
pub a: u32,
pub parent_state: String,
pub parent_decay_mode: Option<String>,
pub daughter_ex_kev: Option<f64>,
pub parent_level_kev: Option<f64>,
pub intermediate_level_kev: Option<f64>,
pub final_level_kev: Option<f64>,
pub emission1: Emission,
pub emission2: Emission,
pub pair_intensity: f32,
}
#[derive(Debug, Clone, Default)]
pub struct CoincidenceFilter {
pub parent_state: Option<String>,
pub parent_decay_mode: Option<String>,
pub emission1_rad_type: Option<String>,
pub emission2_rad_type: Option<String>,
pub min_intensity: f32,
}
pub struct CoincidencesDb {
coinc_dir: PathBuf,
cache: RwLock<HashMap<u32, Arc<Vec<CoincidenceEntry>>>>,
}
unsafe impl Send for CoincidencesDb {}
unsafe impl Sync for CoincidencesDb {}
impl CoincidencesDb {
pub fn open(data_dir: impl AsRef<Path>) -> crate::Result<Self> {
let coinc_dir = data_dir.as_ref().join("ensdf").join("coincidences");
if !coinc_dir.is_dir() {
return Err(crate::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("coincidences directory not found: {}", coinc_dir.display()),
)));
}
Ok(Self {
coinc_dir,
cache: RwLock::new(HashMap::new()),
})
}
pub fn pairs_for_element(&self, z: u32) -> crate::Result<Arc<Vec<CoincidenceEntry>>> {
if let Some(cached) = self.cache.read().unwrap().get(&z) {
return Ok(Arc::clone(cached));
}
let symbol = z_to_symbol(z).ok_or_else(|| {
crate::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown Z={z}"),
))
})?;
let path = self.coinc_dir.join(format!("{symbol}.parquet"));
let entries = Self::load_file(&path)?;
let arc = Arc::new(entries);
self.cache.write().unwrap().insert(z, Arc::clone(&arc));
Ok(arc)
}
pub fn from_element_bytes(
&self,
z: u32,
data: &[u8],
) -> crate::Result<Arc<Vec<CoincidenceEntry>>> {
let bytes = bytes::Bytes::from(data.to_vec());
let entries = Self::parse_reader(bytes)?;
let arc = Arc::new(entries);
self.cache.write().unwrap().insert(z, Arc::clone(&arc));
Ok(arc)
}
fn load_file(path: &Path) -> crate::Result<Vec<CoincidenceEntry>> {
let file = fs::File::open(path)?;
Self::parse_reader(file)
}
fn parse_reader(
reader_source: impl parquet::file::reader::ChunkReader + 'static,
) -> crate::Result<Vec<CoincidenceEntry>> {
let mut out: Vec<CoincidenceEntry> = Vec::new();
let reader = ParquetRecordBatchReaderBuilder::try_new(reader_source)?.build()?;
for batch in reader {
let batch = batch?;
let z_col = batch
.column_by_name("Z")
.and_then(|c| c.as_any().downcast_ref::<Int64Array>());
let a_col = batch
.column_by_name("A")
.and_then(|c| c.as_any().downcast_ref::<Int64Array>());
let parent_state_ref = batch.column_by_name("parent_state");
let parent_state = parent_state_ref.and_then(|c| crate::interp::as_string_array(c));
let parent_decay_mode_ref = batch.column_by_name("parent_decay_mode");
let parent_decay_mode =
parent_decay_mode_ref.and_then(|c| crate::interp::as_string_array(c));
let daughter_ex_col = batch
.column_by_name("daughter_ex_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let parent_lvl_col = batch
.column_by_name("parent_level_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let inter_lvl_col = batch
.column_by_name("intermediate_level_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let final_lvl_col = batch
.column_by_name("final_level_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let pair_intensity_col = batch
.column_by_name("pair_intensity")
.and_then(|c| c.as_any().downcast_ref::<Float32Array>());
let e1_rad_type_ref = batch.column_by_name("emission1_rad_type");
let e1_rad_type = e1_rad_type_ref.and_then(|c| crate::interp::as_string_array(c));
let e1_energy_col = batch
.column_by_name("emission1_energy_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let e1_intensity_col = batch
.column_by_name("emission1_intensity")
.and_then(|c| c.as_any().downcast_ref::<Float32Array>());
let e1_shell_ref = batch.column_by_name("emission1_shell");
let e1_shell = e1_shell_ref.and_then(|c| crate::interp::as_string_array(c));
let e2_rad_type_ref = batch.column_by_name("emission2_rad_type");
let e2_rad_type = e2_rad_type_ref.and_then(|c| crate::interp::as_string_array(c));
let e2_energy_col = batch
.column_by_name("emission2_energy_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let e2_intensity_col = batch
.column_by_name("emission2_intensity")
.and_then(|c| c.as_any().downcast_ref::<Float32Array>());
let e2_shell_ref = batch.column_by_name("emission2_shell");
let e2_shell = e2_shell_ref.and_then(|c| crate::interp::as_string_array(c));
let g1_icc_col = batch
.column_by_name("gamma1_icc_total")
.and_then(|c| c.as_any().downcast_ref::<Float32Array>());
let g2_icc_col = batch
.column_by_name("gamma2_icc_total")
.and_then(|c| c.as_any().downcast_ref::<Float32Array>());
let (
Some(z),
Some(a),
Some(ps),
Some(pi),
Some(e1rt),
Some(e1e),
Some(e1i),
Some(e2rt),
Some(e2e),
Some(e2i),
) = (
z_col,
a_col,
parent_state.as_ref(),
pair_intensity_col,
e1_rad_type.as_ref(),
e1_energy_col,
e1_intensity_col,
e2_rad_type.as_ref(),
e2_energy_col,
e2_intensity_col,
)
else {
continue;
};
for i in 0..batch.num_rows() {
let z_val = z.value(i) as u32;
let a_val = a.value(i) as u32;
let entry = CoincidenceEntry {
z: z_val,
a: a_val,
parent_state: ps[i].unwrap_or("").to_string(),
parent_decay_mode: parent_decay_mode
.as_ref()
.and_then(|c| c[i].map(|s| s.to_string())),
daughter_ex_kev: daughter_ex_col.and_then(|c| {
if c.is_null(i) {
None
} else {
Some(c.value(i))
}
}),
parent_level_kev: parent_lvl_col.and_then(|c| {
if c.is_null(i) {
None
} else {
Some(c.value(i))
}
}),
intermediate_level_kev: inter_lvl_col.and_then(|c| {
if c.is_null(i) {
None
} else {
Some(c.value(i))
}
}),
final_level_kev: final_lvl_col.and_then(|c| {
if c.is_null(i) {
None
} else {
Some(c.value(i))
}
}),
emission1: Emission {
rad_type: e1rt[i].unwrap_or("").to_string(),
energy_kev: e1e.value(i),
intensity: e1i.value(i),
shell: e1_shell.as_ref().and_then(|c| c[i].map(|s| s.to_string())),
icc_total: g1_icc_col.and_then(|c| {
if c.is_null(i) {
None
} else {
Some(c.value(i))
}
}),
},
emission2: Emission {
rad_type: e2rt[i].unwrap_or("").to_string(),
energy_kev: e2e.value(i),
intensity: e2i.value(i),
shell: e2_shell.as_ref().and_then(|c| c[i].map(|s| s.to_string())),
icc_total: g2_icc_col.and_then(|c| {
if c.is_null(i) {
None
} else {
Some(c.value(i))
}
}),
},
pair_intensity: pi.value(i),
};
out.push(entry);
}
}
Ok(out)
}
pub fn pairs(&self, z: u32, a: u32) -> crate::Result<Vec<CoincidenceEntry>> {
Ok(self
.pairs_for_element(z)?
.iter()
.filter(|e| e.a == a)
.cloned()
.collect())
}
}
impl CoincidencesDb {
pub fn pairs_filtered(
&self,
z: u32,
a: u32,
f: &CoincidenceFilter,
) -> crate::Result<Vec<CoincidenceEntry>> {
Ok(self
.pairs_for_element(z)?
.iter()
.filter(|e| e.a == a)
.filter(|e| f.matches(e))
.cloned()
.collect())
}
}
impl CoincidenceFilter {
fn matches(&self, e: &CoincidenceEntry) -> bool {
if let Some(s) = &self.parent_state {
if &e.parent_state != s {
return false;
}
}
if let Some(m) = &self.parent_decay_mode {
if e.parent_decay_mode.as_deref() != Some(m.as_str()) {
return false;
}
}
if let Some(r) = &self.emission1_rad_type {
if &e.emission1.rad_type != r {
return false;
}
}
if let Some(r) = &self.emission2_rad_type {
if &e.emission2.rad_type != r {
return false;
}
}
e.pair_intensity > self.min_intensity
}
}
#[derive(Debug, Clone)]
pub struct EmissionEntry {
pub z: u32,
pub a: u32,
pub state: String,
pub rad_type: String,
pub energy_kev: f64,
pub intensity_pct: f64,
pub decay_mode: Option<String>,
pub rad_subtype: Option<String>,
pub icc_total: Option<f32>,
pub vacancy_shell: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GammaCandidate {
pub z: u32,
pub a: u32,
pub state: String,
pub energy_kev: f64,
pub intensity_pct: f64,
pub delta_kev: f64,
}
#[derive(Debug, Clone, Copy)]
struct GammaIndexEntry {
energy_kev: f64,
intensity_pct: f64,
z: u16,
a: u16,
state_idx: u8,
}
pub struct RadiationDb {
rad_dir: PathBuf,
cache: RwLock<HashMap<u32, Arc<Vec<EmissionEntry>>>>,
gamma_index: OnceLock<Vec<GammaIndexEntry>>,
gamma_index_build_lock: Mutex<()>,
}
unsafe impl Send for RadiationDb {}
unsafe impl Sync for RadiationDb {}
impl RadiationDb {
pub fn open(data_dir: impl AsRef<Path>) -> crate::Result<Self> {
let rad_dir = data_dir.as_ref().join("ensdf").join("radiation");
if !rad_dir.is_dir() {
return Err(crate::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("radiation directory not found: {}", rad_dir.display()),
)));
}
Ok(Self {
rad_dir,
cache: RwLock::new(HashMap::new()),
gamma_index: OnceLock::new(),
gamma_index_build_lock: Mutex::new(()),
})
}
pub fn emissions_for_element(&self, z: u32) -> crate::Result<Arc<Vec<EmissionEntry>>> {
if let Some(cached) = self.cache.read().unwrap().get(&z) {
return Ok(Arc::clone(cached));
}
let symbol = z_to_symbol(z).ok_or_else(|| {
crate::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown Z={z}"),
))
})?;
let path = self.rad_dir.join(format!("{symbol}.parquet"));
let entries = Self::load_file(&path)?;
let arc = Arc::new(entries);
self.cache.write().unwrap().insert(z, Arc::clone(&arc));
Ok(arc)
}
pub fn from_element_bytes(
&self,
z: u32,
data: &[u8],
) -> crate::Result<Arc<Vec<EmissionEntry>>> {
let bytes = bytes::Bytes::from(data.to_vec());
let entries = Self::parse_reader(bytes)?;
let arc = Arc::new(entries);
self.cache.write().unwrap().insert(z, Arc::clone(&arc));
Ok(arc)
}
fn load_file(path: &Path) -> crate::Result<Vec<EmissionEntry>> {
let file = fs::File::open(path)?;
Self::parse_reader(file)
}
fn parse_reader(
reader_source: impl parquet::file::reader::ChunkReader + 'static,
) -> crate::Result<Vec<EmissionEntry>> {
let mut out: Vec<EmissionEntry> = Vec::new();
let reader = ParquetRecordBatchReaderBuilder::try_new(reader_source)?.build()?;
for batch in reader {
let batch = batch?;
let z_col = batch
.column_by_name("Z")
.and_then(|c| c.as_any().downcast_ref::<Int64Array>());
let a_col = batch
.column_by_name("A")
.and_then(|c| c.as_any().downcast_ref::<Int64Array>());
let state_ref = batch.column_by_name("state");
let state = state_ref.and_then(|c| crate::interp::as_string_array(c));
let rad_type_ref = batch.column_by_name("rad_type");
let rad_type = rad_type_ref.and_then(|c| crate::interp::as_string_array(c));
let energy_col = batch
.column_by_name("energy_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let intensity_col = batch
.column_by_name("intensity_pct")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let decay_mode_ref = batch.column_by_name("decay_mode");
let decay_mode = decay_mode_ref.and_then(|c| crate::interp::as_string_array(c));
let rad_subtype_ref = batch.column_by_name("rad_subtype");
let rad_subtype = rad_subtype_ref.and_then(|c| crate::interp::as_string_array(c));
let icc_col = batch
.column_by_name("icc_total")
.and_then(|c| c.as_any().downcast_ref::<Float32Array>());
let vacancy_ref = batch.column_by_name("vacancy_shell");
let vacancy = vacancy_ref.and_then(|c| crate::interp::as_string_array(c));
let (Some(z), Some(a), Some(st), Some(rt), Some(en), Some(it)) = (
z_col,
a_col,
state.as_ref(),
rad_type.as_ref(),
energy_col,
intensity_col,
) else {
continue;
};
for i in 0..batch.num_rows() {
let z_val = z.value(i) as u32;
let a_val = a.value(i) as u32;
let state_val = st[i].unwrap_or("").to_string();
let rt_val = rt[i].unwrap_or("").to_string();
out.push(EmissionEntry {
z: z_val,
a: a_val,
state: state_val,
rad_type: rt_val,
energy_kev: en.value(i),
intensity_pct: it.value(i),
decay_mode: decay_mode
.as_ref()
.and_then(|c| c[i].map(|s| s.to_string())),
rad_subtype: rad_subtype
.as_ref()
.and_then(|c| c[i].map(|s| s.to_string())),
icc_total: icc_col
.and_then(|c| if c.is_null(i) { None } else { Some(c.value(i)) }),
vacancy_shell: vacancy.as_ref().and_then(|c| c[i].map(|s| s.to_string())),
});
}
}
Ok(out)
}
pub fn emissions(&self, z: u32, a: u32, state: &str) -> crate::Result<Vec<EmissionEntry>> {
Ok(self
.emissions_for_element(z)?
.iter()
.filter(|e| e.a == a && e.state == state)
.cloned()
.collect())
}
pub fn emissions_filtered(
&self,
z: u32,
a: u32,
state: &str,
rad_type: Option<&str>,
min_intensity_pct: f64,
) -> crate::Result<Vec<EmissionEntry>> {
Ok(self
.emissions_for_element(z)?
.iter()
.filter(|e| e.a == a && e.state == state)
.filter(|e| {
if let Some(rt) = rad_type {
if e.rad_type != rt {
return false;
}
}
e.intensity_pct >= min_intensity_pct
})
.cloned()
.collect())
}
pub fn identify_gamma(
&self,
energy_kev: f64,
tolerance_kev: f64,
min_intensity_pct: f64,
) -> crate::Result<Vec<GammaCandidate>> {
let index = self.gamma_index_or_build()?;
let lo = energy_kev - tolerance_kev;
let hi = energy_kev + tolerance_kev;
let start = index.partition_point(|e| e.energy_kev < lo);
let end = index.partition_point(|e| e.energy_kev <= hi);
let mut out: Vec<GammaCandidate> = index[start..end]
.iter()
.filter(|e| e.intensity_pct >= min_intensity_pct)
.map(|e| GammaCandidate {
z: e.z as u32,
a: e.a as u32,
state: match e.state_idx {
0 => String::new(),
1 => "m".to_string(),
2 => "m2".to_string(),
_ => "?".to_string(),
},
energy_kev: e.energy_kev,
intensity_pct: e.intensity_pct,
delta_kev: e.energy_kev - energy_kev,
})
.collect();
out.sort_by(|a, b| {
a.delta_kev
.abs()
.partial_cmp(&b.delta_kev.abs())
.unwrap()
.then(b.intensity_pct.partial_cmp(&a.intensity_pct).unwrap())
});
Ok(out)
}
fn gamma_index_or_build(&self) -> crate::Result<&[GammaIndexEntry]> {
if let Some(idx) = self.gamma_index.get() {
return Ok(idx);
}
let _guard = self.gamma_index_build_lock.lock().unwrap();
if let Some(idx) = self.gamma_index.get() {
return Ok(idx);
}
let built = self.build_gamma_index()?;
let _ = self.gamma_index.set(built);
Ok(self.gamma_index.get().expect("gamma_index just set"))
}
fn build_gamma_index(&self) -> crate::Result<Vec<GammaIndexEntry>> {
let mut idx: Vec<GammaIndexEntry> = Vec::new();
for entry in fs::read_dir(&self.rad_dir)? {
let path = entry?.path();
if path.extension().is_none_or(|e| e != "parquet") {
continue;
}
Self::extend_gamma_index_from_file(&path, &mut idx)?;
}
idx.sort_by(|a, b| a.energy_kev.partial_cmp(&b.energy_kev).unwrap());
Ok(idx)
}
fn extend_gamma_index_from_file(
path: &Path,
idx: &mut Vec<GammaIndexEntry>,
) -> crate::Result<()> {
let file = fs::File::open(path)?;
let reader = ParquetRecordBatchReaderBuilder::try_new(file)?.build()?;
for batch in reader {
let batch = batch?;
let z_col = batch
.column_by_name("Z")
.and_then(|c| c.as_any().downcast_ref::<Int64Array>());
let a_col = batch
.column_by_name("A")
.and_then(|c| c.as_any().downcast_ref::<Int64Array>());
let state_ref = batch.column_by_name("state");
let state = state_ref.and_then(|c| crate::interp::as_string_array(c));
let rad_type_ref = batch.column_by_name("rad_type");
let rad_type = rad_type_ref.and_then(|c| crate::interp::as_string_array(c));
let energy_col = batch
.column_by_name("energy_keV")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let intensity_col = batch
.column_by_name("intensity_pct")
.and_then(|c| c.as_any().downcast_ref::<Float64Array>());
let (Some(z), Some(a), Some(st), Some(rt), Some(en), Some(it)) = (
z_col,
a_col,
state.as_ref(),
rad_type.as_ref(),
energy_col,
intensity_col,
) else {
continue;
};
for i in 0..batch.num_rows() {
if rt[i] != Some("gamma") {
continue;
}
let state_idx = match st[i].unwrap_or("") {
"" => 0u8,
"m" => 1,
"m2" => 2,
_ => 255,
};
idx.push(GammaIndexEntry {
energy_kev: en.value(i),
intensity_pct: it.value(i),
z: z.value(i) as u16,
a: a.value(i) as u16,
state_idx,
});
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("..")
.join("data")
.join("meta")
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn abundances_cu_isotopes() {
let db = AbundancesDb::open(meta_dir()).unwrap();
let isotopes = db.isotopes(29); let stable: Vec<_> = isotopes.iter().filter(|e| e.abundance > 0.0).collect();
assert_eq!(stable.len(), 2, "Cu stable isotopes: {}", stable.len());
let total: f64 = stable.iter().map(|e| e.abundance).sum();
assert!((total - 1.0).abs() < 1e-3, "Cu abundance sum: {total}");
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn decay_cu64_beta() {
let db = DecayDb::open(meta_dir()).unwrap();
let modes = db.modes(29, 64, ""); assert!(!modes.is_empty(), "Cu-64 should have decay modes");
let has_beta = modes.iter().any(|m| m.decay_mode.starts_with("beta"));
assert!(has_beta, "Cu-64 should have beta decay");
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn coincidences_co60_beta_gamma_pair_intensity() {
let db = CoincidencesDb::open(meta_dir()).unwrap();
let pairs = db.pairs(28, 60).unwrap();
let hit = pairs.iter().find(|e| {
e.emission1.rad_type == "beta"
&& (e.emission2.energy_kev - 1173.0).abs() < 2.0
&& e.parent_decay_mode.as_deref() == Some("beta-")
});
let hit = hit.expect("Co-60 β⁻ ⊗ 1173 keV γ pair must exist");
assert!(
(hit.pair_intensity - 0.9986).abs() < 0.003,
"Co-60 β-γ pair_intensity: got {}, want ≈ 0.9986",
hit.pair_intensity
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn coincidences_y86_kshell_xray_gamma() {
let db = CoincidencesDb::open(meta_dir()).unwrap();
let pairs = db.pairs(38, 86).unwrap();
let k_xray_gamma = pairs.iter().any(|e| {
e.emission1.rad_type == "xray"
&& e.emission1.shell.as_deref() == Some("K")
&& e.parent_decay_mode.as_deref() == Some("KshellEC")
&& (e.emission2.energy_kev - 1077.0).abs() < 2.0
});
assert!(k_xray_gamma, "Y-86 K X-ray ⊗ 1077 keV γ pair must exist");
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn coincidences_co60_gamma_gamma_preserved() {
let db = CoincidencesDb::open(meta_dir()).unwrap();
let pairs = db.pairs(28, 60).unwrap();
let canonical = pairs.iter().any(|e| {
e.emission1.rad_type == "gamma"
&& e.emission2.rad_type == "gamma"
&& (((e.emission1.energy_kev - 1173.0).abs() < 2.0
&& (e.emission2.energy_kev - 1333.0).abs() < 2.0)
|| ((e.emission1.energy_kev - 1333.0).abs() < 2.0
&& (e.emission2.energy_kev - 1173.0).abs() < 2.0))
});
assert!(
canonical,
"Co-60 1173/1333 γ-γ cascade pair must be preserved"
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn coincidences_sr90_y90_pure_beta_no_mixed() {
let db = CoincidencesDb::open(meta_dir()).unwrap();
let filter = CoincidenceFilter {
parent_decay_mode: Some("beta-".to_string()),
..Default::default()
};
let mixed: Vec<_> = db
.pairs_filtered(39, 90, &filter)
.unwrap()
.into_iter()
.filter(|e| e.emission1.rad_type != "gamma")
.collect();
assert!(
mixed.is_empty(),
"Sr-90 → Y-90 pure β⁻ must produce zero mixed pairs (got {})",
mixed.len()
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn radiation_emissions_ni60_has_co60_decay_gammas() {
let db = RadiationDb::open(meta_dir()).unwrap();
let lines = db.emissions(28, 60, "").unwrap();
assert!(!lines.is_empty(), "Ni-60 radiation lines must exist");
let has_1173 = lines
.iter()
.any(|e| e.rad_type == "gamma" && (e.energy_kev - 1173.2).abs() < 0.5);
let has_1333 = lines
.iter()
.any(|e| e.rad_type == "gamma" && (e.energy_kev - 1332.5).abs() < 0.5);
assert!(
has_1173 && has_1333,
"Co-60 β⁻ → Ni-60 cascade must emit 1173 + 1333 keV γ"
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn radiation_identify_gamma_1173() {
let db = RadiationDb::open(meta_dir()).unwrap();
let candidates = db.identify_gamma(1173.2, 1.0, 5.0).unwrap(); let found_ni60 = candidates.iter().any(|c| c.z == 28 && c.a == 60);
assert!(
found_ni60,
"identify_gamma(1173.2) must include Ni-60 (the daughter of Co-60 β⁻) (got {} candidates)",
candidates.len()
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn dose_i131_positive() {
let db = DoseDb::open(meta_dir()).unwrap();
let dc = db.dose_constant(53, 131, ""); assert!(dc.is_some(), "I-131 should have a dose constant");
let dc = dc.unwrap();
assert!(dc.k > 0.0, "I-131 dose constant should be positive");
assert_eq!(dc.source, "ensdf", "I-131 source should be 'ensdf'");
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn abundances_from_bytes_matches_open() {
let path = meta_dir().join("abundances.parquet");
let db_file = AbundancesDb::open(meta_dir()).unwrap();
let data = std::fs::read(&path).unwrap();
let db_bytes = AbundancesDb::from_bytes(&data).unwrap();
let ab_file = db_file.abundance(29, 63);
let ab_bytes = db_bytes.abundance(29, 63);
assert!(
(ab_file - ab_bytes).abs() < 1e-12,
"Cu-63 abundance mismatch: {ab_file} vs {ab_bytes}"
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn decay_from_bytes_matches_open() {
let path = meta_dir().join("decay.parquet");
let db_file = DecayDb::open(meta_dir()).unwrap();
let data = std::fs::read(&path).unwrap();
let db_bytes = DecayDb::from_bytes(&data).unwrap();
let modes_file = db_file.modes(29, 64, "");
let modes_bytes = db_bytes.modes(29, 64, "");
assert_eq!(
modes_file.len(),
modes_bytes.len(),
"Cu-64 decay mode count mismatch"
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn dose_from_bytes_matches_open() {
let path = meta_dir().join("dose_constants.parquet");
let db_file = DoseDb::open(meta_dir()).unwrap();
let data = std::fs::read(&path).unwrap();
let db_bytes = DoseDb::from_bytes(&data).unwrap();
let dc_file = db_file.dose_constant(53, 131, "").unwrap();
let dc_bytes = db_bytes.dose_constant(53, 131, "").unwrap();
assert!(
(dc_file.k - dc_bytes.k).abs() < 1e-12,
"I-131 dose constant mismatch: {} vs {}",
dc_file.k,
dc_bytes.k
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn coincidences_from_element_bytes() {
let db = CoincidencesDb::open(meta_dir()).unwrap();
let pairs_file = db.pairs(28, 60).unwrap();
let path = meta_dir()
.join("ensdf")
.join("coincidences")
.join("Ni.parquet");
let data = std::fs::read(&path).unwrap();
let db2 = CoincidencesDb::open(meta_dir()).unwrap();
db2.from_element_bytes(28, &data).unwrap();
let pairs_bytes = db2.pairs(28, 60).unwrap();
assert_eq!(
pairs_file.len(),
pairs_bytes.len(),
"Ni-60 coincidence pair count mismatch"
);
}
#[test]
#[ignore = "requires nucl-parquet data files"]
fn radiation_from_element_bytes() {
let db = RadiationDb::open(meta_dir()).unwrap();
let lines_file = db.emissions(28, 60, "").unwrap();
let path = meta_dir()
.join("ensdf")
.join("radiation")
.join("Ni.parquet");
let data = std::fs::read(&path).unwrap();
let db2 = RadiationDb::open(meta_dir()).unwrap();
db2.from_element_bytes(28, &data).unwrap();
let lines_bytes = db2.emissions(28, 60, "").unwrap();
assert_eq!(
lines_file.len(),
lines_bytes.len(),
"Ni-60 emission line count mismatch"
);
}
}