// Copyright 2026 hua1104. Apache-2.0.
//
// Classical life table construction: survivorship functions derived from a
// vector of annual death probabilities.
///|
/// An assumption used to interpolate the survivorship function between two
/// consecutive integer ages. The published CL(2010-2013) values of Lx are
/// reproduced exactly by `UniformDeaths`.
pub enum FractionalAge {
/// Uniform Distribution of Deaths (UDD, 均匀死亡分布). lx is linear between
/// consecutive ages, so Lx = (lx + l[x+1]) / 2 exactly.
UniformDeaths
/// Constant force of mortality within each year of age (死亡力恒定). lx
/// decays exponentially within the year, so Lx = -dx / ln(px).
ConstantForce
/// Balducci / hyperbolic assumption, in which the reciprocal of lx is linear.
Hyperbolic
}
///|
/// Parse a fractional age assumption by name, accepting the common
/// abbreviations "udd", "cfm" and "balducci" plus their full English names.
/// Used by the command line front end and by callers loading a table from
/// configuration.
pub fn FractionalAge::parse(name : String) -> FractionalAge? {
match name.to_lower() {
"udd" | "uniform" | "uniformdeaths" | "uniform_deaths" =>
Some(UniformDeaths)
"cfm" | "constantforce" | "constant_force" => Some(ConstantForce)
"balducci" | "hyperbolic" => Some(Hyperbolic)
_ => None
}
}
///|
/// A complete life table. Indices run from 0 to `limiting_age()` inclusive.
///
/// The radix follows the Chinese convention of 1,000,000 lives, matching the
/// published CL(2010-2013) survivorship columns.
pub struct LifeTable {
id : String
label : String
omega : Int32
radix : Double
fraction : FractionalAge
qx : Array[Double]
lx : Array[Double]
dx : Array[Double]
big_lx : Array[Double]
big_tx : Array[Double]
ex : Array[Double]
}
///|
/// Build a life table from annual death probabilities `qx`, covering ages 0
/// through `qx.length() - 1` inclusive. The final rate must terminate the
/// table: survivors reaching the limiting age all die within that year, so
/// `qx[omega]` is expected to be 1.0.
pub fn LifeTable::from_qx(
qx~ : Array[Double],
id~ : String = "",
label~ : String = "",
radix~ : Double = 1000000.0,
fraction~ : FractionalAge = UniformDeaths,
) -> LifeTable {
let n = qx.length()
let lx = Array::make(n, 0.0)
let dx = Array::make(n, 0.0)
let big_lx = Array::make(n, 0.0)
let big_tx = Array::make(n, 0.0)
let ex = Array::make(n, 0.0)
// Forward recursion: l[x+1] = l[x] - d[x] with d[x] = l[x] * q[x].
let mut survivor = radix
for x in 0.. 0.0 { total / lx[x] } else { 0.0 }
}
LifeTable::{
id,
label,
omega: n - 1,
radix,
fraction,
qx,
lx,
dx,
big_lx,
big_tx,
ex,
}
}
///|
/// Person-years lived between ages `x` and `x + 1` by the `l` lives attaining
/// age `x`, of whom `next` survive to age `x + 1`.
fn integrate_year(l : Double, next : Double, f : FractionalAge) -> Double {
match f {
UniformDeaths => (l + next) / 2.0
ConstantForce =>
if next > 0.0 && l > next {
let px = next / l
// L = (l - next) / -ln(px); the trapezoid is only a fallback for a
// death probability so extreme that ln(px) rounds to zero.
let mu = -@math.ln(px)
if mu.abs() > 1.0e-300 {
(l - next) / mu
} else {
(l + next) / 2.0
}
} else {
l / 2.0
}
Hyperbolic =>
if next > 0.0 && l > next {
// Integral of 1 / ((1-t)/l + t/next) over t in [0, 1].
l * next * @math.ln(l / next) / (l - next)
} else {
l / 2.0
}
}
}
///|
/// The limiting age omega: every life attaining it dies within that year.
pub fn LifeTable::limiting_age(self : LifeTable) -> Int32 {
self.omega
}
///|
/// The radix l[0] the table was built from.
pub fn LifeTable::radix(self : LifeTable) -> Double {
self.radix
}
///|
/// The fractional age assumption the table was built under.
pub fn LifeTable::fraction(self : LifeTable) -> FractionalAge {
self.fraction
}
///|
/// The identifier of the table source, for example "CL1".
pub fn LifeTable::id(self : LifeTable) -> String {
self.id
}
///|
/// A human readable name for the table.
pub fn LifeTable::label(self : LifeTable) -> String {
self.label
}
///|
/// The annual death probability qx at exact integral age `x`. Ages past omega
/// report certainty of death; negative ages report zero mortality.
pub fn LifeTable::qx(self : LifeTable, x : Int32) -> Double {
if x < 0 {
0.0
} else if x > self.omega {
1.0
} else {
self.qx[x]
}
}
///|
/// The annual survival probability px = 1 - qx.
pub fn LifeTable::px(self : LifeTable, x : Int32) -> Double {
1.0 - self.qx(x)
}
///|
/// Survivors lx attaining exact age `x`.
pub fn LifeTable::lx(self : LifeTable, x : Int32) -> Double {
if x < 0 || x > self.omega {
0.0
} else {
self.lx[x]
}
}
///|
/// Deaths dx occurring between ages `x` and `x + 1`.
pub fn LifeTable::dx(self : LifeTable, x : Int32) -> Double {
if x < 0 || x > self.omega {
0.0
} else {
self.dx[x]
}
}
///|
/// Person-years Lx lived jointly by survivors of age `x` over the next year.
pub fn LifeTable::person_years(self : LifeTable, x : Int32) -> Double {
if x < 0 || x > self.omega {
0.0
} else {
self.big_lx[x]
}
}
///|
/// Total future person-years Tx yet to be lived by survivors of age `x`.
pub fn LifeTable::total_person_years(self : LifeTable, x : Int32) -> Double {
if x < 0 || x > self.omega {
0.0
} else {
self.big_tx[x]
}
}
///|
/// The complete expectation of life ex(平均余命)at exact age `x`, counting
/// fractional years.
pub fn LifeTable::expectation(self : LifeTable, x : Int32) -> Double {
if x < 0 || x > self.omega {
0.0
} else {
self.ex[x]
}
}
///|
/// The curtate expectation of life(取整余命)at age `x`: the expected number
/// of whole future years survived, excluding the final partial year. Under the
/// uniform distribution of deaths this sits exactly half a year below the
/// complete expectation.
pub fn LifeTable::curtate_expectation(self : LifeTable, x : Int32) -> Double {
if x < 0 || x > self.omega {
0.0
} else if self.lx[x] == 0.0 {
0.0
} else {
let mut acc = 0.0
for k in (x + 1)..<(self.omega + 1) {
acc = acc + self.lx[k]
}
acc / self.lx[x]
}
}
///|
/// t p x: the probability that a life aged `x` survives at least `t` more
/// whole years.
pub fn LifeTable::survival(self : LifeTable, x : Int32, t : Int32) -> Double {
if x < 0 || x > self.omega {
0.0
} else if t <= 0 {
1.0
} else {
let target = x + t
if target > self.omega {
0.0
} else if self.lx[x] == 0.0 {
0.0
} else {
self.lx[target] / self.lx[x]
}
}
}
///|
/// t q x: the probability that a life aged `x` dies within the next `t` years.
pub fn LifeTable::death_probability(
self : LifeTable,
x : Int32,
t : Int32,
) -> Double {
1.0 - self.survival(x, t)
}
///|
/// t | q x: the probability that a life aged `x` survives `t` years and then
/// dies during the following year.
pub fn LifeTable::deferred_death_probability(
self : LifeTable,
x : Int32,
t : Int32,
) -> Double {
self.survival(x, t) * self.qx(x + t)
}