use core::f64::consts::PI;
use metrology::{AU, C, G, SOLAR_MASS};
use crate::integrate_log;
pub const KAPPA: f64 = 4.0 * G / (C * C * AU);
pub fn sis(sigma: f64, dls: f64, ds: f64) -> f64 {
4.0 * PI * (sigma / C).powi(2) * dls / ds
}
pub fn microlensing(u: f64) -> (f64, f64) {
((u * u + 2.0) / (u * (u * u + 4.0).sqrt()), u / (u * u + 2.0))
}
pub fn finite_source(u: f64, rho: f64) -> f64 {
let delta = ((u - rho).abs() / rho).clamp(1e-9, 1.0);
let integrand = |v: f64| {
let s2 = (0.5 * (v - delta)).sin().powi(2);
let (near, r2) = (rho - u + 2.0 * u * s2, (u - rho).powi(2) + 4.0 * u * rho * s2);
if r2 == 0.0 { 0.0 } else { near * (r2 + 4.0).sqrt() / r2.sqrt() }
};
integrate_log(delta, PI + delta, 16000, integrand) / (PI * rho)
}
pub fn sigma_crit(dd: f64, ds: f64, dds: f64) -> f64 {
C * C * ds / (4.0 * PI * G * dd * dds)
}
pub fn nfw_delta_c(c: f64) -> f64 {
200.0 / 3.0 * c.powi(3) / (c.ln_1p() - c / (1.0 + c))
}
pub fn concentration_dutton(m200: f64, h: f64, z: f64) -> f64 {
let (a, b) = (0.520 + 0.385 * (-0.617 * z.powf(1.21)).exp(), -0.101 + 0.026 * z);
10f64.powf(a + b * (m200 * h / (1e12 * SOLAR_MASS)).log10())
}
pub fn lens_mass(theta_e: f64, pi_e: f64) -> f64 {
theta_e / (KAPPA * pi_e)
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Weighing {
pub name: &'static str,
pub mass: (f64, f64, f64),
pub theta_e: f64,
pub pi_e: f64,
pub superseded: bool,
pub source: &'static str,
}
pub const OGLE_2011_BLG_0462: &[Weighing] = &[
Weighing { name: "Sahu et al. 2022", mass: (7.1, 1.3, 1.3), theta_e: 5.18, pi_e: 0.0894, superseded: false, source: "https://arxiv.org/abs/2201.13296" },
Weighing { name: "Lam et al. 2022 (default weight)", mass: (3.79, 0.62, 0.57), theta_e: 3.89, pi_e: 0.12, superseded: true, source: "https://arxiv.org/abs/2202.01903" },
Weighing { name: "Lam et al. 2022 (equal weight)", mass: (2.15, 0.67, 0.54), theta_e: 4.13, pi_e: 0.24, superseded: true, source: "https://arxiv.org/abs/2202.01903" },
Weighing { name: "Mróz, Udalski & Gould 2022", mass: (7.88, 0.82, 0.82), theta_e: 5.68, pi_e: 0.0885, superseded: false, source: "https://arxiv.org/abs/2207.10729" },
Weighing { name: "Lam & Lu 2023", mass: (6.03, 1.19, 1.04), theta_e: 4.79, pi_e: 0.10, superseded: false, source: "https://arxiv.org/abs/2308.03302" },
Weighing { name: "Sahu et al. 2025", mass: (7.15, 0.83, 0.83), theta_e: 5.496, pi_e: 0.0944, superseded: false, source: "https://arxiv.org/abs/2503.07820" },
];
pub fn nfw(x: f64) -> (f64, f64) {
let u = x * x - 1.0;
let f = if u > 0.0 { u.sqrt().atan() / u.sqrt() } else if u < 0.0 { (-u).sqrt().atanh() / (-u).sqrt() } else { 1.0 };
let density = if u.abs() < 0.1 {
(0..20).map(|k| (-u).powi(k) / (2 * k + 3) as f64).sum()
} else {
(1.0 - f) / u
};
(density, 2.0 / (x * x) * (f + (x / 2.0).ln()))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Assumption {
Assertive,
Conservative,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Hubble {
pub name: &'static str,
pub value: (f64, f64, f64),
pub assumption: Assumption,
pub data: &'static str,
pub source: &'static str,
}
pub const H0_REGISTRY: [Hubble; 2] = [
Hubble { name: "H0LiCOW (six lenses)", value: (73.3, 1.7, 1.8), assumption: Assumption::Assertive, data: "lenses only", source: "https://arxiv.org/abs/1907.04869" },
Hubble { name: "TDCOSMO 2025 (eight lenses, new kinematics)", value: (71.6, 3.9, 3.3), assumption: Assumption::Conservative, data: "lenses with Pantheon+ supernovae", source: "https://arxiv.org/abs/2506.03023" },
];
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Method {
Cmb,
Bao,
DistanceLadder,
TimeDelay(Assumption),
Siren,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Measurement {
pub name: &'static str,
pub value: (f64, f64, f64),
pub method: Method,
pub data: &'static str,
pub source: &'static str,
}
const fn time_delay(h: Hubble) -> Measurement {
Measurement { name: h.name, value: h.value, method: Method::TimeDelay(h.assumption), data: h.data, source: h.source }
}
pub const H0_MEASUREMENTS: &[Measurement] = &[
Measurement { name: "GW170817 and NGC 4993", value: (74.0, 16.0, 8.0), method: Method::Siren, data: "one siren; the abstract's 70.0 (+12.0/−8.0) is the maximum a posteriori and its narrowest 68% interval", source: "https://arxiv.org/abs/1710.05835" },
Measurement { name: "Planck 2018 (TT,TE,EE+lowE+lensing)", value: (67.36, 0.54, 0.54), method: Method::Cmb, data: "flat ΛCDM; 67.66 ± 0.42 with BAO", source: "https://arxiv.org/abs/1807.06209" },
time_delay(H0_REGISTRY[0]),
Measurement { name: "SH0ES 2022", value: (73.04, 1.04, 1.04), method: Method::DistanceLadder, data: "Cepheids and type Ia supernovae, systematics included", source: "https://arxiv.org/abs/2112.04510" },
Measurement { name: "CCHP 2025 (tip of the red-giant branch)", value: (70.39, 1.94, 1.94), method: Method::DistanceLadder, data: "24 supernova calibrators from HST and JWST; ±1.22 (stat), ±1.33 (sys) and ±0.70 (σ_SN) in quadrature", source: "https://arxiv.org/abs/2408.06153" },
Measurement { name: "DESI DR2 (BAO + BBN)", value: (68.51, 0.58, 0.58), method: Method::Bao, data: "flat ΛCDM; the conclusions print 68.50", source: "https://arxiv.org/abs/2503.14738" },
time_delay(H0_REGISTRY[1]),
];
#[cfg(test)]
mod tests {
use super::*;
use crate::{H100, cosmology::{PLANCK18, angular_diameter_distance}};
use metrology::MPC;
#[test]
fn ngc_6505() {
let (zl, zs, theta) = (0.042, 0.4058, 2.5 / 206_264.806_247);
let (dls, ds) = (angular_diameter_distance(zl, zs, 0.7 * H100, 0.3), angular_diameter_distance(0.0, zs, 0.7 * H100, 0.3));
let sigma = C * (theta * ds / (4.0 * PI * dls)).sqrt();
assert!((sis(sigma, dls, ds) / theta - 1.0).abs() < 1e-14);
assert!((sigma / 1e3 - 313.0).abs() < 1.0 && (sigma / 1e3 - 303.0).abs() < 15.0, "{}", sigma / 1e3);
let other = angular_diameter_distance(zl, zs, 0.6 * H100, 0.3) / angular_diameter_distance(0.0, zs, 0.6 * H100, 0.3);
assert!((other / (dls / ds) - 1.0).abs() < 1e-12);
}
#[test]
fn microlensing_and_the_lens_mass() {
assert!((microlensing(1e-6).0 * 1e-6 - 1.0).abs() < 1e-11 && (microlensing(1e3).0 - 1.0) < 1e-11);
let peak = microlensing(2f64.sqrt()).1;
assert!((peak - 1.0 / (2.0 * 2f64.sqrt())).abs() < 1e-15 && microlensing(1.3).1 < peak && microlensing(1.5).1 < peak);
let mas = PI / 180.0 / 3600.0 * 1e-3;
assert!((lens_mass(5.496 * mas, 0.0944) / SOLAR_MASS - 7.149).abs() < 1e-3);
assert!((KAPPA * SOLAR_MASS / mas - 8.144).abs() < 5e-4);
}
#[test]
fn an_nfw_halo() {
assert!((nfw(1.0).0 - 1.0 / 3.0).abs() < 1e-16);
let slope = (nfw(1.0 + 1e-6).0 - nfw(1.0 - 1e-6).0) / 2e-6;
assert!((slope + 0.4).abs() < 1e-8);
for edge in [1.1f64.sqrt(), 0.9f64.sqrt()] {
let (a, b) = (nfw(edge * (1.0 - 1e-15)).0, nfw(edge * (1.0 + 1e-15)).0);
assert!((a - b).abs() < 1e-14, "{edge}");
}
for x in [0.3, 1.0, 2.5] {
let n = 4000;
let h = x / n as f64;
let integral: f64 = (0..n).map(|i| { let s = (i as f64 + 0.5) * h; s * nfw(s).0 }).sum::<f64>() * h;
assert!((2.0 / (x * x) * integral / nfw(x).1 - 1.0).abs() < 1e-5, "x = {x}");
}
assert!((nfw(1.0).1 - 2.0 * (1.0 + 0.5f64.ln())).abs() < 1e-15);
}
#[test]
fn finite_sources() {
for rho in [0.01, 0.05, 0.5, 1.0] {
assert!((finite_source(0.0, rho) / (1.0 + 4.0 / (rho * rho)).sqrt() - 1.0).abs() < 1e-14, "{rho}");
let limb = (2.0 / rho + (1.0 + rho * rho) / (rho * rho) * (PI / 2.0 + ((rho * rho - 1.0) / (rho * rho + 1.0)).asin())) / PI;
assert!((finite_source(rho, rho) / limb - 1.0).abs() < 1e-12, "{rho}");
}
assert!((finite_source(1.0, 1.0) / (1.0 + 2.0 / PI) - 1.0).abs() < 1e-12);
for (u, rho, a) in [(0.1, 0.5, 4.086_266_585_622_305), (0.4995, 0.5, 2.759_105_373_407_014), (0.5005, 0.5, 2.739_050_255_247_673), (1.0, 0.5, 1.381_003_927_897_093), (0.01, 0.05, 39.609_820_689_795_04)] {
assert!((finite_source(u, rho) / a - 1.0).abs() < 1e-13, "{u} {rho}");
}
let (u, rho) = (30.0f64, 0.3f64);
let excess = finite_source(u, rho) - microlensing(u).0;
assert!((excess / (4.0 * rho * rho / u.powi(6)) - 1.0).abs() < 0.02, "{excess:e}");
}
#[test]
fn nfw_normalisation() {
for (c, dc) in [(4.0, 5271.147547), (5.0, 8694.810191), (10.0, 44778.661516)] {
assert!((nfw_delta_c(c) - dc).abs() < 1e-6, "{c}");
}
let h = 0.6766;
assert!((concentration_dutton(1e12 / h * SOLAR_MASS, h, 0.0) - 10f64.powf(0.905)).abs() < 1e-13);
assert!((concentration_dutton(1e14 / h * SOLAR_MASS, h, 0.5) - 4.35381).abs() < 5e-6);
let (p, zd, zs) = (PLANCK18, 0.3, 1.0);
let rho_c = 3.0 * (p.h0 * p.e(zd)).powi(2) / (8.0 * PI * G);
let m200 = 1e14 / h * SOLAR_MASS;
let r200 = (3.0 * m200 / (800.0 * PI * rho_c)).cbrt();
let c = concentration_dutton(m200, h, zd);
let (dc, rs) = (nfw_delta_c(c), r200 / c);
assert!((rho_c / 1.180190e-26 - 1.0).abs() < 1e-6 && (c - 4.6457).abs() < 5e-5 && (dc - 7361.493).abs() < 5e-4);
assert!((rs / (MPC / 1e3) - 216.089).abs() < 5e-4);
assert!((4.0 * PI * rho_c * dc * rs.powi(3) * (c.ln_1p() - c / (1.0 + c)) / m200 - 1.0).abs() < 1e-14);
let sigma = sigma_crit(p.angular_diameter_distance(0.0, zd), p.angular_diameter_distance(0.0, zs), p.angular_diameter_distance(zd, zs));
assert!((sigma - 5.749602).abs() < 5e-7 && (2.0 * rs * dc * rho_c / sigma - 0.20151).abs() < 5e-6, "{sigma}");
}
#[test]
fn ogle_2011_blg_0462() {
let mas = PI / 180.0 / 3600.0 * 1e-3;
for w in OGLE_2011_BLG_0462.iter().filter(|w| !w.name.starts_with("Lam")) {
let m = lens_mass(w.theta_e * mas, w.pi_e) / SOLAR_MASS;
assert!((m - w.mass.0).abs() < 0.02, "{}: {m}", w.name);
}
for w in OGLE_2011_BLG_0462.iter().filter(|w| w.name.starts_with("Lam")) {
let m = lens_mass(w.theta_e * mas, w.pi_e) / SOLAR_MASS;
assert!((m / w.mass.0 - 1.0).abs() < 0.06, "{}: {m}", w.name);
}
let current: Vec<_> = OGLE_2011_BLG_0462.iter().filter(|w| !w.superseded).map(|w| w.mass.0).collect();
assert!(current.iter().all(|&m| m > 5.0) && OGLE_2011_BLG_0462.iter().filter(|w| w.superseded).all(|w| w.mass.0 < 4.0));
}
#[test]
fn the_h0_registry() {
let [h0licow, tdcosmo] = H0_REGISTRY;
assert!(tdcosmo.value.0 < h0licow.value.0 && tdcosmo.value.1 > h0licow.value.1);
assert_eq!((h0licow.assumption, tdcosmo.assumption), (Assumption::Assertive, Assumption::Conservative));
let lenses: Vec<_> = H0_MEASUREMENTS.iter().filter(|m| matches!(m.method, Method::TimeDelay(_))).collect();
assert!(lenses.len() == 2 && lenses[0].value == h0licow.value && lenses[1].method == Method::TimeDelay(Assumption::Conservative));
for method in [Method::Cmb, Method::Bao, Method::DistanceLadder, Method::Siren] {
assert!(H0_MEASUREMENTS.iter().any(|m| m.method == method), "{method:?}");
}
assert!((1.22f64.hypot(1.33).hypot(0.70) - 1.94).abs() < 0.005);
}
}