use crate::constants::SECONDS_PER_DAY;
use crate::exact::{ExactBrightDate, PS_PER_NS, PS_PER_S};
use crate::instant::BrightInstant;
use crate::types::BrightDateError;
use crate::BrightDate;
pub const PBD_ERA_SECONDS: i64 = 1_000_000_000_000;
pub const PBD_ERA_SECONDS_F: f64 = PBD_ERA_SECONDS as f64;
pub const PBD_ERA_PICOSECONDS: i128 = 1_000_000_000_000_000_000_000_000;
pub const DEFAULT_PBD_PRECISION: u8 = 3;
pub const DEFAULT_BD_PRECISION: u8 = 3;
#[derive(
Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize,
)]
pub struct Pbd {
pub era: u32,
pub page: f64,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct ExactPbd {
pub era: u32,
pub page_picoseconds: u128,
}
#[derive(
Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize,
)]
#[serde(tag = "kind")]
pub enum BrightLabel {
#[serde(rename = "BD")]
Bd { seconds: f64 },
#[serde(rename = "PBD")]
Pbd { era: u32, page: f64 },
}
fn assert_finite(value: f64, name: &str) -> Result<(), BrightDateError> {
if !value.is_finite() {
return Err(BrightDateError::InvalidInput(format!(
"{name} must be a finite number, got: {value}"
)));
}
Ok(())
}
fn assert_era(era: u32) -> Result<(), BrightDateError> {
if era < 1 {
return Err(BrightDateError::InvalidInput(format!(
"PBD era must be ≥ 1 — there is no PBD0. Got: {era}"
)));
}
Ok(())
}
fn assert_negative(raw_seconds: f64) -> Result<(), BrightDateError> {
if raw_seconds >= 0.0 {
return Err(BrightDateError::OutOfRange(format!(
"PBD is defined only for t < 0 (pre-J2000.0). \
For non-negative scalars, use BD directly. Got: {raw_seconds}"
)));
}
Ok(())
}
pub fn to_pbd(raw_seconds: f64) -> Result<Pbd, BrightDateError> {
assert_finite(raw_seconds, "raw_seconds")?;
assert_negative(raw_seconds)?;
let era = ((-raw_seconds) / PBD_ERA_SECONDS_F).floor() as u32 + 1;
let page = (raw_seconds % PBD_ERA_SECONDS_F) + PBD_ERA_SECONDS_F;
Ok(Pbd { era, page })
}
pub fn from_pbd(pbd: Pbd) -> Result<f64, BrightDateError> {
assert_era(pbd.era)?;
assert_finite(pbd.page, "PBD page")?;
Ok(pbd.page - (pbd.era as f64) * PBD_ERA_SECONDS_F)
}
pub fn bright_date_to_pbd(bd: BrightDate) -> Result<Pbd, BrightDateError> {
to_pbd(bd.value * SECONDS_PER_DAY)
}
pub fn bright_date_from_pbd(pbd: Pbd) -> Result<BrightDate, BrightDateError> {
Ok(BrightDate::from_value(from_pbd(pbd)? / SECONDS_PER_DAY))
}
pub fn pbd_era(raw_seconds: f64) -> Result<u32, BrightDateError> {
assert_finite(raw_seconds, "raw_seconds")?;
assert_negative(raw_seconds)?;
Ok(((-raw_seconds) / PBD_ERA_SECONDS_F).floor() as u32 + 1)
}
pub fn pbd_page(raw_seconds: f64) -> Result<f64, BrightDateError> {
assert_finite(raw_seconds, "raw_seconds")?;
assert_negative(raw_seconds)?;
Ok((raw_seconds % PBD_ERA_SECONDS_F) + PBD_ERA_SECONDS_F)
}
pub fn compare_pbd(a: Pbd, b: Pbd) -> std::cmp::Ordering {
use std::cmp::Ordering::*;
if a.era != b.era {
return if a.era < b.era { Greater } else { Less };
}
if a.page == b.page {
Equal
} else if a.page > b.page {
Greater
} else {
Less
}
}
pub fn is_pbd_later(a: Pbd, b: Pbd) -> bool {
compare_pbd(a, b) == std::cmp::Ordering::Greater
}
pub fn format_pbd(pbd: Pbd, precision: u8) -> Result<String, BrightDateError> {
assert_era(pbd.era)?;
assert_finite(pbd.page, "PBD page")?;
if precision > 20 {
return Err(BrightDateError::InvalidPrecision(format!(
"PBD precision must be in [0, 20], got: {precision}"
)));
}
Ok(format!(
"PBD{}: {:.*}",
pbd.era,
precision as usize,
pbd.page
))
}
pub fn parse_pbd(label: &str) -> Result<Pbd, BrightDateError> {
let trimmed = label.trim();
let after_pbd = trimmed
.strip_prefix("PBD")
.ok_or_else(|| BrightDateError::ParseError(format!("invalid PBD label: {label:?}")))?;
let (era_part, page_part) = after_pbd
.split_once(':')
.ok_or_else(|| BrightDateError::ParseError(format!("invalid PBD label: {label:?}")))?;
let era: u32 = era_part
.trim()
.trim_start_matches('+')
.parse()
.map_err(|_| BrightDateError::ParseError(format!("invalid PBD era in {label:?}")))?;
let page: f64 = page_part
.trim()
.parse()
.map_err(|_| BrightDateError::ParseError(format!("invalid PBD page in {label:?}")))?;
if !page.is_finite() {
return Err(BrightDateError::ParseError(format!(
"non-finite PBD page in {label:?}"
)));
}
Ok(Pbd { era, page })
}
pub fn to_exact_pbd(value: ExactBrightDate) -> Result<ExactPbd, BrightDateError> {
let ps = value.picoseconds();
if ps >= 0 {
return Err(BrightDateError::OutOfRange(format!(
"PBD is defined only for t < 0 (pre-J2000.0). \
For non-negative instants, use BD directly. Got picoseconds: {ps}"
)));
}
let era = ((-ps) / PBD_ERA_PICOSECONDS + 1) as u32;
let page_signed = (ps % PBD_ERA_PICOSECONDS) + PBD_ERA_PICOSECONDS;
Ok(ExactPbd {
era,
page_picoseconds: page_signed as u128,
})
}
pub fn from_exact_pbd(pbd: ExactPbd) -> Result<ExactBrightDate, BrightDateError> {
assert_era(pbd.era)?;
let ps = (pbd.page_picoseconds as i128) - (pbd.era as i128) * PBD_ERA_PICOSECONDS;
Ok(ExactBrightDate::from_picoseconds(ps))
}
pub fn compare_exact_pbd(a: ExactPbd, b: ExactPbd) -> std::cmp::Ordering {
use std::cmp::Ordering::*;
if a.era != b.era {
return if a.era < b.era { Greater } else { Less };
}
a.page_picoseconds.cmp(&b.page_picoseconds)
}
pub fn bright_instant_to_pbd(instant: BrightInstant) -> Result<ExactPbd, BrightDateError> {
let ps = (instant.tai_seconds_since_j2000() as i128) * PS_PER_S
+ (instant.tai_nanos() as i128) * PS_PER_NS;
to_exact_pbd(ExactBrightDate::from_picoseconds(ps))
}
pub fn bright_instant_from_pbd(pbd: ExactPbd) -> Result<BrightInstant, BrightDateError> {
let ps = from_exact_pbd(pbd)?.picoseconds();
let mut secs = ps.div_euclid(PS_PER_S);
let mut sub_ps = ps.rem_euclid(PS_PER_S);
if sub_ps < 0 {
secs -= 1;
sub_ps += PS_PER_S;
}
let tai_nanos = (sub_ps / PS_PER_NS) as u32;
BrightInstant::from_tai_components(secs as i64, tai_nanos)
}
pub fn to_bright_label(raw_seconds: f64) -> Result<BrightLabel, BrightDateError> {
assert_finite(raw_seconds, "raw_seconds")?;
if raw_seconds >= 0.0 {
return Ok(BrightLabel::Bd {
seconds: raw_seconds,
});
}
let era = ((-raw_seconds) / PBD_ERA_SECONDS_F).floor() as u32 + 1;
let page = (raw_seconds % PBD_ERA_SECONDS_F) + PBD_ERA_SECONDS_F;
Ok(BrightLabel::Pbd { era, page })
}
pub fn from_bright_label(label: BrightLabel) -> Result<f64, BrightDateError> {
match label {
BrightLabel::Bd { seconds } => {
assert_finite(seconds, "BD seconds")?;
if seconds < 0.0 {
return Err(BrightDateError::OutOfRange(format!(
"BD scalar must be ≥ 0; negative values are PBD. Got: {seconds}"
)));
}
Ok(seconds)
}
BrightLabel::Pbd { era, page } => from_pbd(Pbd { era, page }),
}
}
pub fn format_bright_label(
label: BrightLabel,
bd_precision: u8,
pbd_precision: u8,
) -> Result<String, BrightDateError> {
match label {
BrightLabel::Bd { seconds } => {
if bd_precision > 20 {
return Err(BrightDateError::InvalidPrecision(format!(
"BD precision must be in [0, 20], got: {bd_precision}"
)));
}
assert_finite(seconds, "BD seconds")?;
Ok(format!("{:.*} BD", bd_precision as usize, seconds))
}
BrightLabel::Pbd { era, page } => format_pbd(Pbd { era, page }, pbd_precision),
}
}
pub fn parse_bright_label(label: &str) -> Result<BrightLabel, BrightDateError> {
let trimmed = label.trim();
if let Some(num_part) = trimmed.strip_suffix("BD") {
let seconds: f64 = num_part.trim().parse().map_err(|_| {
BrightDateError::ParseError(format!("invalid BD scalar in {label:?}"))
})?;
if !seconds.is_finite() || seconds < 0.0 {
return Err(BrightDateError::ParseError(format!(
"invalid BD scalar in {label:?}"
)));
}
return Ok(BrightLabel::Bd { seconds });
}
let pbd = parse_pbd(label)?;
Ok(BrightLabel::Pbd {
era: pbd.era,
page: pbd.page,
})
}
pub fn brightdate_to_label(bd: BrightDate) -> Result<BrightLabel, BrightDateError> {
to_bright_label(bd.value * SECONDS_PER_DAY)
}
#[cfg(test)]
mod tests {
use super::*;
const T: f64 = PBD_ERA_SECONDS_F;
#[test]
fn just_before_j2000_is_pbd1_top() {
let pbd = to_pbd(-1.0).unwrap();
assert_eq!(pbd.era, 1);
assert!((pbd.page - (T - 1.0)).abs() < 1e-3);
}
#[test]
fn exact_minus_t_is_pbd2_top() {
let pbd = to_pbd(-T).unwrap();
assert_eq!(pbd.era, 2);
assert!((pbd.page - T).abs() < 1e-3);
}
#[test]
fn pbd_roundtrip() {
for raw in [-1.0, -T + 1.0, -T, -T - 1.0, -1.578e11, -3.156e12] {
let pbd = to_pbd(raw).unwrap();
let back = from_pbd(pbd).unwrap();
assert!(
(back - raw).abs() < 1e-3,
"raw={raw} pbd={pbd:?} back={back}"
);
}
}
#[test]
fn to_pbd_rejects_non_negative() {
assert!(to_pbd(0.0).is_err());
assert!(to_pbd(1.0).is_err());
assert!(to_pbd(f64::NAN).is_err());
}
#[test]
fn era_and_page_helpers_agree() {
let raw = -1.578e11;
let pbd = to_pbd(raw).unwrap();
assert_eq!(pbd_era(raw).unwrap(), pbd.era);
assert!((pbd_page(raw).unwrap() - pbd.page).abs() < 1e-6);
}
#[test]
fn format_and_parse_pbd_roundtrip() {
let pbd = Pbd {
era: 1,
page: 842_000_000_000.0,
};
let s = format_pbd(pbd, 3).unwrap();
assert_eq!(s, "PBD1: 842000000000.000");
let parsed = parse_pbd(&s).unwrap();
assert_eq!(parsed.era, pbd.era);
assert!((parsed.page - pbd.page).abs() < 1e-3);
}
#[test]
fn compare_pbd_orders_by_real_time() {
let earlier = Pbd { era: 2, page: 1.0 };
let later = Pbd { era: 1, page: 1.0 };
assert!(is_pbd_later(later, earlier));
let same_era_earlier = Pbd { era: 1, page: 1.0 };
let same_era_later = Pbd {
era: 1,
page: 100.0,
};
assert!(is_pbd_later(same_era_later, same_era_earlier));
}
#[test]
fn exact_pbd_roundtrip() {
for ps in [-1_i128, -PBD_ERA_PICOSECONDS, -PBD_ERA_PICOSECONDS - 1, -123_456_789_012_345i128] {
let exact = ExactBrightDate::from_picoseconds(ps);
let pbd = to_exact_pbd(exact).unwrap();
assert!(pbd.era >= 1);
assert!(pbd.page_picoseconds > 0 && pbd.page_picoseconds <= PBD_ERA_PICOSECONDS as u128);
let back = from_exact_pbd(pbd).unwrap();
assert_eq!(back.picoseconds(), ps);
}
}
#[test]
fn exact_pbd_rejects_non_negative() {
assert!(to_exact_pbd(ExactBrightDate::epoch()).is_err());
assert!(to_exact_pbd(ExactBrightDate::from_picoseconds(1)).is_err());
}
#[test]
fn bright_instant_pbd_roundtrip() {
let inst = BrightInstant::from_tai_components(-1_000_000_000, 123_456_789).unwrap();
let pbd = bright_instant_to_pbd(inst).unwrap();
let back = bright_instant_from_pbd(pbd).unwrap();
assert_eq!(back, inst);
}
#[test]
fn bright_label_dispatch() {
assert!(matches!(
to_bright_label(0.0).unwrap(),
BrightLabel::Bd { .. }
));
assert!(matches!(
to_bright_label(1.0).unwrap(),
BrightLabel::Bd { .. }
));
assert!(matches!(
to_bright_label(-1.0).unwrap(),
BrightLabel::Pbd { era: 1, .. }
));
assert!(matches!(
to_bright_label(-PBD_ERA_SECONDS_F).unwrap(),
BrightLabel::Pbd { era: 2, .. }
));
}
#[test]
fn bright_label_roundtrip() {
for raw in [0.0, 1.0, 12345.0, -1.0, -T + 1.0, -2.0 * T] {
let label = to_bright_label(raw).unwrap();
let back = from_bright_label(label).unwrap();
assert!((back - raw).abs() < 1e-3);
}
}
#[test]
fn format_parse_bright_label_roundtrip() {
let bd = BrightLabel::Bd { seconds: 9635.845 };
let s = format_bright_label(bd, 3, 3).unwrap();
assert_eq!(s, "9635.845 BD");
let parsed = parse_bright_label(&s).unwrap();
assert!(matches!(parsed, BrightLabel::Bd { .. }));
let pbd = BrightLabel::Pbd {
era: 1,
page: 842_000_000_000.0,
};
let s = format_bright_label(pbd, 3, 3).unwrap();
assert!(s.starts_with("PBD1"));
let parsed = parse_bright_label(&s).unwrap();
assert!(matches!(parsed, BrightLabel::Pbd { era: 1, .. }));
}
#[test]
fn brightdate_to_label_dispatches() {
let positive = BrightDate::from_value(1.0);
assert!(matches!(
brightdate_to_label(positive).unwrap(),
BrightLabel::Bd { .. }
));
let pre = BrightDate::from_value(-1.0);
assert!(matches!(
brightdate_to_label(pre).unwrap(),
BrightLabel::Pbd { era: 1, .. }
));
}
}