1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3#![allow(clippy::empty_docs)]
4#![doc = ""]
5use std::path::{Path, PathBuf};
6
7use rusqlite::{Connection, OptionalExtension, params_from_iter, types::Value};
8use thiserror::Error;
9
10mod database;
11mod models;
12pub use models::*;
13
14pub const LATEST_EDITION: &str = "2025";
16
17pub type PdgResult<T> = Result<T, PdgError>;
19
20#[derive(Error, Debug)]
22pub enum PdgError {
23 #[error(transparent)]
25 SqliteError(#[from] rusqlite::Error),
26 #[error(transparent)]
28 Io(#[from] std::io::Error),
29 #[error("PDG database cache directory is unavailable")]
31 CacheDirectoryUnavailable,
32 #[error("PDG database is not cached at {0:?} and downloads are disabled")]
34 OfflineDatabaseMissing(PathBuf),
35 #[error("failed to download PDG database: {0}")]
37 Download(String),
38 #[error(
40 "PDG database size mismatch for {path:?}: expected {expected} bytes, got {actual} bytes"
41 )]
42 DatabaseSizeMismatch {
43 path: PathBuf,
45 expected: u64,
47 actual: u64,
49 },
50 #[error("PDG database checksum mismatch for {path:?}: expected {expected}, got {actual}")]
52 DatabaseChecksumMismatch {
53 path: PathBuf,
55 expected: &'static str,
57 actual: String,
59 },
60 #[error("Failed to parse ValueType: {0}")]
62 ParseValueType(String),
63 #[error("Failed to parse LimitType: {0}")]
65 ParseLimitType(String),
66 #[error("Failed to parse DataType: {0}")]
68 ParseDataType(String),
69 #[error(transparent)]
71 QuantumNumberConversion(#[from] QuantumNumberConversionError),
72 #[error("Custom error: {0}")]
74 Custom(String),
75}
76
77#[derive(Debug)]
97pub struct Pdg {
98 conn: Connection,
99}
100
101impl Pdg {
102 const PARTICLE_COLUMNS: &'static str = "pdgparticle.pdgid, name, pdgid.description, cc_type, pdgid.flags, mcid, charge, quantum_i, quantum_g, quantum_j, quantum_p, quantum_c";
103 const PARTICLE_JOIN: &'static str =
104 "JOIN pdgid ON pdgid.pdgid = pdgparticle.pdgid AND pdgid.data_type = 'PART'";
105
106 pub fn open() -> PdgResult<Self> {
121 Self::open_path(database::ensure_database()?)
122 }
123
124 pub fn open_cached() -> PdgResult<Self> {
135 Self::open_path(database::cached_database()?)
136 }
137
138 pub fn open_path(path: impl AsRef<Path>) -> PdgResult<Self> {
148 let conn = Connection::open(path)?;
149 let pdg = Self { conn };
150 pdg.initialize_text_search()?;
151 Ok(pdg)
152 }
153
154 pub fn ensure_database() -> PdgResult<PathBuf> {
165 database::ensure_database()
166 }
167
168 pub fn cached_database_path() -> PdgResult<PathBuf> {
178 database::cached_database_path()
179 }
180
181 #[must_use]
187 pub const fn db(&self) -> &Connection {
188 &self.conn
189 }
190
191 fn initialize_text_search(&self) -> PdgResult<()> {
192 self.conn.execute_batch(
193 "CREATE VIRTUAL TABLE temp.pdg_text_search USING fts5(
194 body,
195 source UNINDEXED,
196 pdgid UNINDEXED,
197 text_type UNINDEXED,
198 sort UNINDEXED,
199 tokenize = 'unicode61'
200 );
201 INSERT INTO pdg_text_search(body, source, pdgid, text_type, sort)
202 SELECT description, 'description', pdgid, NULL, sort
203 FROM pdgid
204 WHERE description != '';
205 INSERT INTO pdg_text_search(body, source, pdgid, text_type, sort)
206 SELECT text, 'text', pdgid, type, sort
207 FROM pdgtext
208 WHERE text IS NOT NULL AND text != '';
209 INSERT INTO pdg_text_search(body, source, pdgid, text_type, sort)
210 SELECT text, 'footnote', pdgid, NULL, footnote_index
211 FROM pdgfootnote
212 WHERE text IS NOT NULL AND text != '';",
213 )?;
214 Ok(())
215 }
216
217 pub fn particle(&self, name: impl Into<String>) -> PdgResult<Option<PdgParticle<'_>>> {
226 let name = name.into();
227 let sql = format!(
228 "SELECT {} FROM pdgparticle {} WHERE name = ?1",
229 Self::PARTICLE_COLUMNS,
230 Self::PARTICLE_JOIN
231 );
232 let mut stmt = self.conn.prepare(&sql)?;
233 Ok(stmt
234 .query_row([&name], |row| PdgParticle::from_row(self, row))
235 .optional()?)
236 }
237
238 pub fn particle_by_pdgid(
244 &self,
245 pdgid: impl Into<String>,
246 ) -> PdgResult<Option<PdgParticle<'_>>> {
247 let pdgid = pdgid.into();
248 let sql = format!(
249 "SELECT {} FROM pdgparticle {} WHERE upper(pdgparticle.pdgid) = upper(?1)",
250 Self::PARTICLE_COLUMNS,
251 Self::PARTICLE_JOIN
252 );
253 let mut stmt = self.conn.prepare(&sql)?;
254 Ok(stmt
255 .query_row([&pdgid], |row| PdgParticle::from_row(self, row))
256 .optional()?)
257 }
258
259 pub fn pdgid(&self, pdgid: impl Into<String>) -> PdgResult<Option<PdgIdEntry>> {
268 let pdgid = pdgid.into();
269 let mut stmt = self.conn.prepare(
270 "SELECT id, pdgid, parent_pdgid, description, mode_number, data_type, flags, year_added, sort
271 FROM pdgid
272 WHERE upper(pdgid) = upper(?1)",
273 )?;
274 Ok(stmt
275 .query_row([&pdgid], |row| PdgIdEntry::try_from(row))
276 .optional()?)
277 }
278
279 pub fn search_text(&self, query: impl Into<String>) -> PdgResult<Vec<TextSearchResult>> {
304 let Some(query) = fts_query(&query.into()) else {
305 return Ok(Vec::new());
306 };
307
308 let mut stmt = self.conn.prepare(
309 "SELECT
310 pdgid,
311 source,
312 text_type,
313 sort,
314 body,
315 snippet(pdg_text_search, 0, '[', ']', '...', 24),
316 bm25(pdg_text_search)
317 FROM pdg_text_search
318 WHERE pdg_text_search MATCH ?1
319 ORDER BY bm25(pdg_text_search), source, sort",
320 )?;
321 Ok(stmt
322 .query_map([&query], |row| {
323 let pdgid = row.get::<_, PdgId>(0)?;
324 let source = row.get::<_, String>(1)?;
325 let text_type = row.get::<_, Option<String>>(2)?;
326 let sort = row.get::<_, Option<isize>>(3)?;
327 let text = row.get::<_, String>(4)?;
328 let snippet = row.get::<_, String>(5)?;
329 let score = row.get::<_, f64>(6)?;
330 let (source, pdg_text) = match source.as_str() {
331 "text" => {
332 let text_type = text_type.unwrap_or_default();
333 let sort = sort.unwrap_or_default();
334 (
335 TextSearchSource::Text {
336 text_type: text_type.clone(),
337 sort,
338 },
339 Some(PdgText {
340 pdgid: pdgid.clone(),
341 text_type,
342 text: Some(text.clone()),
343 sort,
344 }),
345 )
346 }
347 "footnote" => (
348 TextSearchSource::Footnote {
349 index: sort.unwrap_or_default(),
350 },
351 None,
352 ),
353 _ => (TextSearchSource::Description, None),
354 };
355 Ok(TextSearchResult {
356 pdgid,
357 source,
358 text,
359 snippet,
360 score,
361 pdg_text,
362 })
363 })?
364 .collect::<Result<Vec<_>, _>>()?)
365 }
366
367 pub fn mcid(&self, mcid: isize) -> PdgResult<Option<PdgParticle<'_>>> {
373 let sql = format!(
374 "SELECT {} FROM pdgparticle {} WHERE mcid = ?1",
375 Self::PARTICLE_COLUMNS,
376 Self::PARTICLE_JOIN
377 );
378 let mut stmt = self.conn.prepare(&sql)?;
379 Ok(stmt
380 .query_row([&mcid], |row| PdgParticle::from_row(self, row))
381 .optional()?)
382 }
383
384 #[allow(clippy::too_many_lines)]
385 pub fn search_particles(&self, query: ParticleSearchQuery) -> PdgResult<Vec<PdgParticle<'_>>> {
410 let mut sql = format!(
411 "SELECT {} FROM pdgparticle {} WHERE 1 = 1",
412 Self::PARTICLE_COLUMNS,
413 Self::PARTICLE_JOIN
414 );
415 let mut params = Vec::new();
416 let mass_range = query.mass_range_mev;
417 let width_range = query.width_range_mev;
418 let lifetime_range = query.lifetime_range_seconds;
419 let decays_to = query.decays_to.clone();
420 let decays_from = query.decays_from.clone();
421 let decay_state_expansion = query.decay_state_expansion;
422
423 if let Some(name_contains) = query.name_contains {
424 sql.push_str(" AND name LIKE '%' || ? || '%'");
425 params.push(Value::Text(name_contains));
426 }
427
428 if let Some(particle_class) = query.particle_class {
429 sql.push_str(" AND pdgid.flags = ?");
430 params.push(Value::Text(particle_class.to_code().to_string()));
431 }
432
433 if let Some(particle_type) = query.particle_type {
434 sql.push_str(" AND cc_type = ?");
435 params.push(Value::Text(particle_type.to_code().to_string()));
436 }
437
438 if let Some(charge) = query.charge {
439 sql.push_str(" AND ABS(charge - ?) < 1e-12");
440 params.push(Value::Real(charge.as_f64()));
441 }
442
443 Self::push_quantum_filter(&mut sql, &mut params, "quantum_i", query.isospin);
444 Self::push_quantum_filter(&mut sql, &mut params, "quantum_g", query.g_parity);
445 Self::push_quantum_filter(&mut sql, &mut params, "quantum_j", query.angular_momentum);
446 Self::push_quantum_filter(&mut sql, &mut params, "quantum_p", query.parity);
447 Self::push_quantum_filter(&mut sql, &mut params, "quantum_c", query.charge_conjugation);
448
449 self.push_decay_filters(
450 &mut sql,
451 &mut params,
452 decays_to.states.clone(),
453 true,
454 decay_state_expansion,
455 )?;
456 self.push_decay_filters(
457 &mut sql,
458 &mut params,
459 decays_from,
460 false,
461 decay_state_expansion,
462 )?;
463
464 sql.push_str(" ORDER BY pdgparticle.pdgid, name");
465 let mut stmt = self.conn.prepare(&sql)?;
466 let particles = stmt
467 .query_map(params_from_iter(params), |row| {
468 PdgParticle::from_row(self, row)
469 })?
470 .collect::<Result<Vec<_>, _>>()?;
471
472 let mass_entries = if mass_range.is_some() {
473 Some(self.property_entries_by_parent(DataType::Mass)?)
474 } else {
475 None
476 };
477 let width_entries = if width_range.is_some() {
478 Some(self.property_entries_by_parent(DataType::FullWidth)?)
479 } else {
480 None
481 };
482 let lifetime_entries = if lifetime_range.is_some() {
483 Some(self.property_entries_by_parent(DataType::Lifetime)?)
484 } else {
485 None
486 };
487
488 let mut filtered_particles = Vec::new();
489 for particle in particles {
490 if !matches_data_range(
491 mass_entries.as_ref(),
492 &particle.pdgid,
493 mass_range,
494 Unit::Mev,
495 ) || !matches_data_range(
496 width_entries.as_ref(),
497 &particle.pdgid,
498 width_range,
499 Unit::Mev,
500 ) || !matches_data_range(
501 lifetime_entries.as_ref(),
502 &particle.pdgid,
503 lifetime_range,
504 Unit::Seconds,
505 ) {
506 continue;
507 }
508
509 if decays_to.mode == DecayMatchMode::Exact
510 && !decays_to.states.is_empty()
511 && !self.particle_matches_exact_decay(
512 &particle.pdgid,
513 &decays_to.states,
514 decay_state_expansion,
515 )?
516 {
517 continue;
518 }
519
520 filtered_particles.push(particle);
521 }
522
523 Ok(filtered_particles)
524 }
525
526 pub fn item(&self, name: impl Into<String>) -> PdgResult<Option<PdgItem<'_>>> {
535 let name = name.into();
536 let mut stmt = self
537 .conn
538 .prepare("SELECT name, item_type FROM pdgitem WHERE name = ?1")?;
539 Ok(stmt
540 .query_row([&name], |row| PdgItem::from_row(self, row))
541 .optional()?)
542 }
543
544 pub fn item_children(&self, name: impl Into<String>) -> PdgResult<Vec<PdgItemChild<'_>>> {
551 let name = name.into();
552 let child_items = {
553 let mut stmt = self.conn.prepare(
554 "SELECT child.name, child.item_type, pdgitem_map.sort FROM pdgitem_map JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id JOIN pdgitem child ON child.id = pdgitem_map.target_id WHERE parent.name = ?1 ORDER BY pdgitem_map.sort",
555 )?;
556 stmt.query_map([&name], |row| {
557 Ok((PdgItem::from_row(self, row)?, row.get::<_, isize>(2)?))
558 })?
559 .collect::<Result<Vec<_>, _>>()?
560 };
561
562 child_items
563 .into_iter()
564 .map(|(item, sort)| {
565 let particle = match &item.item_type {
566 PdgItemType::Particle => self.particle(&item.name)?,
567 _ => None,
568 };
569 Ok(PdgItemChild {
570 item,
571 sort,
572 particle,
573 })
574 })
575 .collect()
576 }
577
578 pub fn item_parents(&self, name: impl Into<String>) -> PdgResult<Vec<PdgItem<'_>>> {
584 let name = name.into();
585 let mut stmt = self.conn.prepare(
586 "SELECT parent.name, parent.item_type FROM pdgitem_map JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id JOIN pdgitem child ON child.id = pdgitem_map.target_id WHERE child.name = ?1 ORDER BY parent.item_type, parent.name",
587 )?;
588 Ok(stmt
589 .query_map([&name], |row| PdgItem::from_row(self, row))?
590 .collect::<Result<Vec<_>, _>>()?)
591 }
592
593 pub fn children_for_pdgid(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgIdEntry>> {
599 let pdgid = pdgid.into();
600 let mut stmt = self.conn.prepare(
601 "SELECT id, pdgid, parent_pdgid, description, mode_number, data_type, flags, year_added, sort
602 FROM pdgid
603 WHERE upper(parent_pdgid) = upper(?1)
604 ORDER BY sort, pdgid",
605 )?;
606 Ok(stmt
607 .query_map([&pdgid], |row| PdgIdEntry::try_from(row))?
608 .collect::<Result<Vec<_>, _>>()?)
609 }
610
611 pub fn mapped_entries_for_pdgid(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgIdEntry>> {
617 let pdgid = pdgid.into();
618 let mut stmt = self.conn.prepare(
619 "SELECT target.id, target.pdgid, target.parent_pdgid, target.description, target.mode_number, target.data_type, target.flags, target.year_added, target.sort
620 FROM pdgid_map
621 JOIN pdgid target ON target.id = pdgid_map.target_id
622 WHERE upper(pdgid_map.source) = upper(?1)
623 ORDER BY pdgid_map.sort, target.pdgid",
624 )?;
625 Ok(stmt
626 .query_map([&pdgid], |row| PdgIdEntry::try_from(row))?
627 .collect::<Result<Vec<_>, _>>()?)
628 }
629
630 pub fn data_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<DataEntry<'_>>> {
636 let pdgid = pdgid.into();
637 let sql = format!(
638 "SELECT {} FROM pdgdata WHERE upper(pdgid) = upper(?1) AND edition = ?2 ORDER BY sort",
639 DataEntry::COLUMNS
640 );
641 let mut stmt = self.conn.prepare(&sql)?;
642 Ok(stmt
643 .query_map([&pdgid, LATEST_EDITION], |row| {
644 DataEntry::from_row(self, row)
645 })?
646 .collect::<Result<Vec<_>, _>>()?)
647 }
648
649 pub fn texts_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgText>> {
655 let pdgid = pdgid.into();
656 let mut stmt = self.conn.prepare(
657 "SELECT pdgid, type, text, sort FROM pdgtext WHERE pdgid = ?1 ORDER BY sort",
658 )?;
659 Ok(stmt
660 .query_map([&pdgid], |row| PdgText::try_from(row))?
661 .collect::<Result<Vec<_>, _>>()?)
662 }
663
664 pub fn footnotes_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgFootnote>> {
670 let pdgid = pdgid.into();
671 let mut stmt = self.conn.prepare(
672 "SELECT pdgid, footnote_index, text, changebar FROM pdgfootnote WHERE pdgid = ?1 ORDER BY footnote_index",
673 )?;
674 Ok(stmt
675 .query_map([&pdgid], |row| PdgFootnote::try_from(row))?
676 .collect::<Result<Vec<_>, _>>()?)
677 }
678
679 pub fn measurements_for(&self, pdgid: impl Into<String>) -> PdgResult<Vec<PdgMeasurement>> {
686 let pdgid = pdgid.into();
687 let mut stmt = self.conn.prepare(
688 "SELECT pdgmeasurement.id, pdgmeasurement.pdgid, event_count, confidence_level, place, technique, charge, changebar, comment, sort, document_id, publication_name, publication_year, doi, inspire_id, title FROM pdgmeasurement JOIN pdgreference ON pdgreference.id = pdgmeasurement.pdgreference_id WHERE pdgmeasurement.pdgid = ?1 ORDER BY sort",
689 )?;
690 let mut measurements = stmt
691 .query_map([&pdgid], |row| PdgMeasurement::try_from(row))?
692 .collect::<Result<Vec<_>, _>>()?;
693
694 let mut value_stmt = self.conn.prepare(
695 "SELECT column_name, value_text, unit_text, display_value_text, display_power_of_ten, display_in_percent, limit_type, used_in_average, used_in_fit, value, error_positive, error_negative, stat_error_positive, stat_error_negative, syst_error_positive, syst_error_negative, sort FROM pdgmeasurement_values WHERE pdgmeasurement_id = ?1 ORDER BY sort",
696 )?;
697 let mut footnote_stmt = self.conn.prepare(
698 "SELECT pdgfootnote.pdgid, footnote_index, text, changebar FROM pdgmeasurement_footnote JOIN pdgfootnote ON pdgfootnote.id = pdgmeasurement_footnote.pdgfootnote_id WHERE pdgmeasurement_id = ?1 ORDER BY footnote_index",
699 )?;
700 for measurement in &mut measurements {
701 measurement.values = value_stmt
702 .query_map([measurement.id], |row| PdgMeasurementValue::try_from(row))?
703 .collect::<Result<Vec<_>, _>>()?;
704 measurement.footnotes = footnote_stmt
705 .query_map([measurement.id], |row| PdgFootnote::try_from(row))?
706 .collect::<Result<Vec<_>, _>>()?;
707 }
708
709 Ok(measurements)
710 }
711
712 fn push_decay_filters(
713 &self,
714 sql: &mut String,
715 params: &mut Vec<Value>,
716 states: Vec<String>,
717 is_outgoing: bool,
718 expansion: DecayStateExpansion,
719 ) -> PdgResult<()> {
720 if states.is_empty() {
721 return Ok(());
722 }
723
724 sql.push_str(
725 " AND pdgparticle.pdgid IN (
726 SELECT decay_pdgid.parent_pdgid
727 FROM pdgid decay_pdgid
728 WHERE decay_pdgid.data_type IN ('BFX', 'BFX1', 'BFX2', 'BFX3', 'BFX4', 'BFX5', 'BFI', 'BFI1', 'BFI2', 'BFI3', 'BFI4', 'BFI5')",
729 );
730
731 for state in states {
732 let names = self.expand_decay_state_names(state, expansion)?;
733 let placeholders = std::iter::repeat_n("?", names.len())
734 .collect::<Vec<_>>()
735 .join(", ");
736 sql.push_str(&format!(
737 " AND EXISTS (
738 SELECT 1
739 FROM pdgdecay
740 WHERE pdgdecay.pdgid = decay_pdgid.pdgid
741 AND pdgdecay.is_outgoing = ?
742 AND pdgdecay.name IN ({placeholders})
743 )"
744 ));
745 params.push(Value::Integer(i64::from(is_outgoing)));
746 params.extend(names.into_iter().map(Value::Text));
747 }
748
749 sql.push(')');
750 Ok(())
751 }
752
753 fn push_quantum_filter<T: ToString>(
754 sql: &mut String,
755 params: &mut Vec<Value>,
756 column: &str,
757 filter: QuantumFilter<T>,
758 ) {
759 match filter {
760 QuantumFilter::Any => {}
761 QuantumFilter::Missing => {
762 sql.push_str(&format!(" AND {column} IS NULL"));
763 }
764 QuantumFilter::Value(value) => {
765 sql.push_str(&format!(" AND {column} = ?"));
766 params.push(Value::Text(value.to_string()));
767 }
768 }
769 }
770
771 fn particle_matches_exact_decay(
772 &self,
773 pdgid: &str,
774 states: &[String],
775 expansion: DecayStateExpansion,
776 ) -> PdgResult<bool> {
777 let requested = states
778 .iter()
779 .map(|state| self.expand_decay_state_names(state.clone(), expansion))
780 .collect::<PdgResult<Vec<_>>>()?;
781 let mut stmt = self.conn.prepare(
782 "SELECT decay_pdgid.pdgid, pdgdecay.name, pdgdecay.multiplier
783 FROM pdgid decay_pdgid
784 JOIN pdgdecay ON pdgdecay.pdgid = decay_pdgid.pdgid
785 WHERE decay_pdgid.parent_pdgid = ?1
786 AND decay_pdgid.data_type IN ('BFX', 'BFX1', 'BFX2', 'BFX3', 'BFX4', 'BFX5', 'BFI', 'BFI1', 'BFI2', 'BFI3', 'BFI4', 'BFI5')
787 AND pdgdecay.is_outgoing = 1
788 ORDER BY decay_pdgid.sort ASC, pdgdecay.sort ASC",
789 )?;
790 let rows = stmt
791 .query_map([pdgid], |row| {
792 Ok((
793 row.get::<_, PdgId>(0)?,
794 row.get::<_, String>(1)?,
795 row.get::<_, i64>(2)?,
796 ))
797 })?
798 .collect::<Result<Vec<_>, _>>()?;
799
800 let mut modes = std::collections::HashMap::<PdgId, Vec<String>>::new();
801 for (mode_pdgid, name, multiplier) in rows {
802 let products = modes.entry(mode_pdgid).or_default();
803 for _ in 0..multiplier {
804 products.push(name.clone());
805 }
806 }
807
808 Ok(modes
809 .values()
810 .any(|products| exact_decay_products_match(&requested, products)))
811 }
812
813 fn expand_decay_state_names(
814 &self,
815 name: String,
816 expansion: DecayStateExpansion,
817 ) -> PdgResult<Vec<String>> {
818 if expansion == DecayStateExpansion::Literal {
819 return Ok(vec![name]);
820 }
821
822 let mut names = vec![name.clone()];
823 let mut seen = std::collections::HashSet::from([name.clone()]);
824 let mut parents = Vec::new();
825
826 let mut stmt = self.conn.prepare(
827 "SELECT child.name, 0 AS is_parent, pdgitem_map.sort
828 FROM pdgitem_map
829 JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id
830 JOIN pdgitem child ON child.id = pdgitem_map.target_id
831 WHERE parent.name = ?1
832 UNION ALL
833 SELECT parent.name, 1 AS is_parent, pdgitem_map.sort
834 FROM pdgitem_map
835 JOIN pdgitem parent ON parent.id = pdgitem_map.pdgitem_id
836 JOIN pdgitem child ON child.id = pdgitem_map.target_id
837 WHERE child.name = ?1
838 ORDER BY is_parent, sort",
839 )?;
840 for (relative, is_parent) in stmt
841 .query_map([&name], |row| {
842 Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
843 })?
844 .collect::<Result<Vec<_>, _>>()?
845 {
846 if is_parent {
847 parents.push(relative.clone());
848 }
849 if seen.insert(relative.clone()) {
850 names.push(relative);
851 }
852 }
853
854 if self.is_antiparticle_item(&name)? || self.is_neutral_meson_particle(&name)? {
855 for parent in parents {
856 let alias = format!("{parent}bar");
857 if self.decay_state_exists(&alias)? && seen.insert(alias.clone()) {
858 names.push(alias);
859 }
860 }
861 }
862
863 Ok(names)
864 }
865
866 fn is_antiparticle_item(&self, name: &str) -> PdgResult<bool> {
867 Ok(self
868 .conn
869 .query_row(
870 "SELECT 1 FROM pdgparticle WHERE name = ?1 AND cc_type = 'A'",
871 [name],
872 |_| Ok(()),
873 )
874 .optional()?
875 .is_some())
876 }
877
878 fn is_neutral_meson_particle(&self, name: &str) -> PdgResult<bool> {
879 Ok(self
880 .conn
881 .query_row(
882 "SELECT 1
883 FROM pdgparticle
884 JOIN pdgid ON pdgid.pdgid = pdgparticle.pdgid AND pdgid.data_type = 'PART'
885 WHERE pdgparticle.name = ?1
886 AND ABS(pdgparticle.charge) < 1e-12
887 AND pdgid.flags = 'M'",
888 [name],
889 |_| Ok(()),
890 )
891 .optional()?
892 .is_some())
893 }
894
895 fn decay_state_exists(&self, name: &str) -> PdgResult<bool> {
896 Ok(self
897 .conn
898 .query_row(
899 "SELECT 1
900 WHERE EXISTS (SELECT 1 FROM pdgitem WHERE name = ?1)
901 OR EXISTS (SELECT 1 FROM pdgdecay WHERE name = ?1)",
902 [name],
903 |_| Ok(()),
904 )
905 .optional()?
906 .is_some())
907 }
908
909 fn property_entries_by_parent(
910 &self,
911 data_type: DataType,
912 ) -> PdgResult<std::collections::HashMap<PdgId, Vec<DataEntry<'_>>>> {
913 let data_type_code = data_type.to_code();
914 let direct_sql = format!(
915 "SELECT {}, pdgid.parent_pdgid FROM pdgdata JOIN pdgid ON pdgid.id = pdgdata.pdgid_id WHERE pdgid.data_type = ?1 AND pdgdata.edition = ?2",
916 DataEntry::COLUMNS
917 );
918 let mut direct_stmt = self.conn.prepare(&direct_sql)?;
919 let direct_rows = direct_stmt
920 .query_map([data_type_code, LATEST_EDITION], |row| {
921 Ok((
922 row.get::<_, PdgId>(DataEntry::COLUMN_COUNT)?,
923 DataEntry::from_row(self, row)?,
924 ))
925 })?
926 .collect::<Result<Vec<_>, _>>()?;
927
928 let section_sql = format!(
929 "SELECT {}, section.parent_pdgid FROM pdgdata
930 JOIN pdgid child ON child.id = pdgdata.pdgid_id
931 JOIN pdgid section ON section.pdgid = child.parent_pdgid
932 WHERE child.data_type = ?1
933 AND section.data_type = ?2
934 AND pdgdata.edition = ?3",
935 DataEntry::COLUMNS
936 );
937 let mut section_stmt = self.conn.prepare(§ion_sql)?;
938 let section_rows = section_stmt
939 .query_map(
940 [data_type_code, DataType::Section.to_code(), LATEST_EDITION],
941 |row| {
942 Ok((
943 row.get::<_, PdgId>(DataEntry::COLUMN_COUNT)?,
944 DataEntry::from_row(self, row)?,
945 ))
946 },
947 )?
948 .collect::<Result<Vec<_>, _>>()?;
949
950 let direct_entries = group_property_entries(direct_rows);
951 let section_entries = group_property_entries(section_rows);
952
953 Ok(section_entries.into_iter().chain(direct_entries).collect())
954 }
955}
956
957fn group_property_entries<'pdg>(
958 rows: Vec<(PdgId, DataEntry<'pdg>)>,
959) -> std::collections::HashMap<PdgId, Vec<DataEntry<'pdg>>> {
960 let mut grouped =
961 std::collections::HashMap::<PdgId, (Vec<DataEntry<'pdg>>, Vec<DataEntry<'pdg>>)>::new();
962 for (parent_pdgid, entry) in rows {
963 let (all_entries, summary_entries) = grouped.entry(parent_pdgid).or_default();
964 all_entries.push(entry.clone());
965 if entry.in_summary_table {
966 summary_entries.push(entry);
967 }
968 }
969
970 grouped
971 .into_iter()
972 .map(|(pdgid, (all_entries, summary_entries))| {
973 let entries = if summary_entries.is_empty() {
974 all_entries
975 } else {
976 summary_entries
977 };
978 (pdgid, entries)
979 })
980 .collect()
981}
982
983#[derive(Copy, Clone)]
984enum Unit {
985 Mev,
986 Seconds,
987}
988
989#[derive(Copy, Clone)]
990struct Interval {
991 min: f64,
992 max: f64,
993}
994
995impl Interval {
996 fn overlaps(self, min: f64, max: f64) -> bool {
997 self.min <= max && self.max >= min
998 }
999}
1000
1001fn matches_data_range(
1002 entries_by_parent: Option<&std::collections::HashMap<PdgId, Vec<DataEntry<'_>>>>,
1003 pdgid: &str,
1004 range: Option<(f64, f64)>,
1005 unit: Unit,
1006) -> bool {
1007 let Some((min, max)) = range else {
1008 return true;
1009 };
1010 let Some(entries) = entries_by_parent.and_then(|entries| entries.get(pdgid)) else {
1011 return true;
1012 };
1013 if entries.is_empty() {
1014 return true;
1015 }
1016
1017 entries
1018 .iter()
1019 .any(|entry| data_interval(entry, unit).is_none_or(|interval| interval.overlaps(min, max)))
1020}
1021
1022fn exact_decay_products_match(requested: &[Vec<String>], products: &[String]) -> bool {
1023 if requested.len() != products.len() {
1024 return false;
1025 }
1026
1027 let mut used = vec![false; products.len()];
1028 exact_decay_products_match_from(requested, products, &mut used, 0)
1029}
1030
1031fn exact_decay_products_match_from(
1032 requested: &[Vec<String>],
1033 products: &[String],
1034 used: &mut [bool],
1035 index: usize,
1036) -> bool {
1037 if index == requested.len() {
1038 return true;
1039 }
1040
1041 for (product_index, product) in products.iter().enumerate() {
1042 if used[product_index] || !requested[index].contains(product) {
1043 continue;
1044 }
1045
1046 used[product_index] = true;
1047 if exact_decay_products_match_from(requested, products, used, index + 1) {
1048 return true;
1049 }
1050 used[product_index] = false;
1051 }
1052
1053 false
1054}
1055
1056fn data_interval(entry: &DataEntry, unit: Unit) -> Option<Interval> {
1057 let factor = unit_factor(&entry.unit_text, unit)?;
1058
1059 if entry.limit_type == Some(LimitType::Range) {
1060 return parse_interval(entry).map(|interval| Interval {
1061 min: interval.min * factor,
1062 max: interval.max * factor,
1063 });
1064 }
1065
1066 let value = entry.value?;
1067 let value = value * factor;
1068 match entry.limit_type {
1069 Some(LimitType::UpperLimit) => Some(Interval {
1070 min: f64::NEG_INFINITY,
1071 max: value,
1072 }),
1073 Some(LimitType::LowerLimit) => Some(Interval {
1074 min: value,
1075 max: f64::INFINITY,
1076 }),
1077 Some(LimitType::RangeExclusion) => None,
1078 Some(LimitType::Range) => unreachable!(),
1079 None => {
1080 let error_positive = entry.error_positive.unwrap_or(0.0) * factor;
1081 let error_negative = entry.error_negative.unwrap_or(0.0) * factor;
1082 Some(Interval {
1083 min: value - error_negative,
1084 max: value + error_positive,
1085 })
1086 }
1087 }
1088}
1089
1090fn parse_interval(entry: &DataEntry) -> Option<Interval> {
1091 let text = entry
1092 .value_text
1093 .as_deref()
1094 .unwrap_or(entry.display_value_text.as_str());
1095 let values = parse_numbers(text);
1096 let min = values.iter().copied().reduce(f64::min)?;
1097 let max = values.iter().copied().reduce(f64::max)?;
1098 Some(Interval { min, max })
1099}
1100
1101fn parse_numbers(text: &str) -> Vec<f64> {
1102 let chars = text.char_indices().collect::<Vec<_>>();
1103 let mut numbers = Vec::new();
1104 let mut index = 0;
1105 while index < chars.len() {
1106 let (start, ch) = chars[index];
1107 let next = chars.get(index + 1).map(|(_, ch)| *ch);
1108 let starts_number = ch.is_ascii_digit()
1109 || (ch == '.' && next.is_some_and(|ch| ch.is_ascii_digit()))
1110 || ((ch == '+' || ch == '-')
1111 && next.is_some_and(|ch| ch.is_ascii_digit() || ch == '.'));
1112 if !starts_number {
1113 index += 1;
1114 continue;
1115 }
1116
1117 let mut end_index = index + 1;
1118 let mut previous = ch;
1119 while end_index < chars.len() {
1120 let (_, current) = chars[end_index];
1121 if current.is_ascii_digit()
1122 || current == '.'
1123 || current == 'e'
1124 || current == 'E'
1125 || ((current == '+' || current == '-') && (previous == 'e' || previous == 'E'))
1126 {
1127 previous = current;
1128 end_index += 1;
1129 } else {
1130 break;
1131 }
1132 }
1133
1134 let end = chars
1135 .get(end_index)
1136 .map_or(text.len(), |(char_index, _)| *char_index);
1137 if let Ok(value) = text[start..end].parse::<f64>() {
1138 numbers.push(value);
1139 }
1140 index = end_index;
1141 }
1142 numbers
1143}
1144
1145fn unit_factor(unit_text: &str, unit: Unit) -> Option<f64> {
1146 match unit {
1147 Unit::Mev => match unit_text {
1148 "MeV" => Some(1.0),
1149 "GeV" => Some(1000.0),
1150 "keV" => Some(0.001),
1151 "eV" => Some(0.000_001),
1152 "u" => Some(931.494_102_42),
1153 _ => None,
1154 },
1155 Unit::Seconds => match unit_text {
1156 "s" => Some(1.0),
1157 "yr" | "years" => Some(31_557_600.0),
1158 _ => None,
1159 },
1160 }
1161}
1162
1163fn fts_query(query: &str) -> Option<String> {
1164 let mut terms = Vec::new();
1165 let mut term = String::new();
1166 for ch in query.chars() {
1167 if ch.is_alphanumeric() {
1168 term.push(ch);
1169 } else if !term.is_empty() {
1170 terms.push(std::mem::take(&mut term));
1171 }
1172 }
1173 if !term.is_empty() {
1174 terms.push(term);
1175 }
1176
1177 (!terms.is_empty()).then(|| {
1178 terms
1179 .into_iter()
1180 .map(|term| format!("\"{term}\""))
1181 .collect::<Vec<_>>()
1182 .join(" ")
1183 })
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189
1190 fn test_pdg() -> Pdg {
1191 Pdg::open_path(concat!(
1192 env!("CARGO_MANIFEST_DIR"),
1193 "/data/pdgall-2025-v0.2.2.sqlite"
1194 ))
1195 .unwrap()
1196 }
1197
1198 #[test]
1199 fn charged_decay_states_do_not_expand_to_antiparticle_siblings() {
1200 let db = test_pdg();
1201 let names = db
1202 .expand_decay_state_names("pi+".to_string(), DecayStateExpansion::Inclusive)
1203 .unwrap();
1204
1205 assert!(names.contains(&"pi+".to_string()));
1206 assert!(names.contains(&"pi".to_string()));
1207 assert!(!names.contains(&"pi-".to_string()));
1208 }
1209
1210 #[test]
1211 fn text_search_finds_pdgid_descriptions() {
1212 let db = test_pdg();
1213 let results = db.search_text("K(S)0 MEAN LIFE").unwrap();
1214
1215 let result = results
1216 .iter()
1217 .find(|result| {
1218 result.pdgid == "S012205" && result.source == TextSearchSource::Description
1219 })
1220 .unwrap();
1221
1222 assert!(result.text.contains("K(S)0 MEAN LIFE"));
1223 assert!(!result.snippet.is_empty());
1224 assert!(result.pdg_text.is_none());
1225 }
1226
1227 #[test]
1228 fn text_search_finds_pdgtext_rows() {
1229 let db = test_pdg();
1230 let results = db
1231 .search_text("Measurements Kbar0 divided convert")
1232 .unwrap();
1233
1234 let result = results
1235 .iter()
1236 .find(|result| matches!(result.source, TextSearchSource::Text { .. }))
1237 .unwrap();
1238
1239 assert!(result.text.contains("Measurements given as a Kbar0 ratio"));
1240 assert!(!result.snippet.is_empty());
1241 assert_eq!(
1242 result.pdg_text.as_ref().unwrap().text.as_deref(),
1243 Some(result.text.as_str())
1244 );
1245 }
1246
1247 #[test]
1248 fn text_search_finds_footnote_rows() {
1249 let db = test_pdg();
1250 let results = db.search_text("normalisation decay").unwrap();
1251
1252 let result = results
1253 .iter()
1254 .find(|result| matches!(result.source, TextSearchSource::Footnote { .. }))
1255 .unwrap();
1256
1257 assert_eq!(result.pdgid, "S042P86");
1258 assert!(result.text.contains("normalisation decay"));
1259 assert!(!result.snippet.is_empty());
1260 assert!(result.pdg_text.is_none());
1261 }
1262
1263 #[test]
1264 fn text_search_handles_punctuation_heavy_queries() {
1265 let db = test_pdg();
1266 let results = db.search_text("K(S)0").unwrap();
1267
1268 assert!(!results.is_empty());
1269 assert!(results.iter().any(|result| result.text.contains("K(S)0")));
1270 }
1271
1272 #[test]
1273 fn text_search_orders_by_score() {
1274 let db = test_pdg();
1275 let results = db.search_text("form factors").unwrap();
1276
1277 assert!(results.len() > 1);
1278 assert!(
1279 results
1280 .windows(2)
1281 .all(|window| window[0].score <= window[1].score)
1282 );
1283 }
1284
1285 #[test]
1286 fn text_search_returns_empty_results_for_empty_or_missing_queries() {
1287 let db = test_pdg();
1288
1289 assert!(db.search_text(".,()").unwrap().is_empty());
1290 assert!(db.search_text("zzzzzznotapdgterm").unwrap().is_empty());
1291 }
1292}