use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BrkInt {
pub raw: f64,
pub slope: f64,
pub eng: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BrkTable {
pub name: String,
pub points: Vec<BrkInt>,
}
impl BrkTable {
pub fn build(name: impl Into<String>, pairs: &[(f64, f64)]) -> Result<BrkTable, String> {
let name = name.into();
let number = pairs.len();
if number < 2 {
return Err(format!("breaktable {name}: Must have at least two points!"));
}
let mut points: Vec<BrkInt> = pairs
.iter()
.map(|&(raw, eng)| BrkInt {
raw,
slope: 0.0,
eng,
})
.collect();
let mut down = false;
for i in 0..number - 1 {
let denom = points[i + 1].raw - points[i].raw;
let slope = (points[i + 1].eng - points[i].eng) / denom;
if slope == 0.0 {
return Err(format!("breaktable {name}: slope is zero"));
}
if i == 0 {
down = slope < 0.0;
} else if down != (slope < 0.0) {
return Err(format!("breaktable {name}: slope changes sign"));
}
points[i].slope = slope;
}
points[number - 1].slope = points[number - 2].slope;
Ok(BrkTable { name, points })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BptStatus {
InRange,
OutOfRange,
}
pub fn cvt_raw_to_eng_bpt(val: f64, table: &BrkTable, lbrk: &mut usize) -> (f64, BptStatus) {
let pts = &table.points;
let number = pts.len(); let mut status = BptStatus::InRange;
let mut l = (*lbrk).min(number - 2);
if pts[l + 1].raw > pts[l].raw {
while val > pts[l + 1].raw {
l += 1;
if l > number - 2 {
status = BptStatus::OutOfRange;
break;
}
}
while val < pts[l].raw {
if l == 0 {
status = BptStatus::OutOfRange;
break;
}
l -= 1;
}
} else {
while val <= pts[l + 1].raw {
l += 1;
if l > number - 2 {
status = BptStatus::OutOfRange;
break;
}
}
while val > pts[l].raw {
if l == 0 {
status = BptStatus::OutOfRange;
break;
}
l -= 1;
}
}
*lbrk = l;
let p = &pts[l];
(p.eng + (val - p.raw) * p.slope, status)
}
pub fn cvt_eng_to_raw_bpt(val: f64, table: &BrkTable, lbrk: &mut usize) -> (f64, BptStatus) {
let pts = &table.points;
let number = pts.len(); let mut status = BptStatus::InRange;
let mut l = (*lbrk).min(number - 2);
if pts[l + 1].eng > pts[l].eng {
while val > pts[l + 1].eng {
l += 1;
if l > number - 2 {
status = BptStatus::OutOfRange;
break;
}
}
while val < pts[l].eng {
if l == 0 {
status = BptStatus::OutOfRange;
break;
}
l -= 1;
}
} else {
while val <= pts[l + 1].eng {
l += 1;
if l > number - 2 {
status = BptStatus::OutOfRange;
break;
}
}
while val > pts[l].eng {
if l == 0 {
status = BptStatus::OutOfRange;
break;
}
l -= 1;
}
}
*lbrk = l;
let p = &pts[l];
(p.raw + (val - p.eng) / p.slope, status)
}
pub const LINR_FIRST_BREAKTABLE: i16 = 3;
pub const LINR_FIRST_USER_TABLE: i16 = LINR_FIRST_BREAKTABLE + STANDARD_CONVERT_NAMES.len() as i16;
const STANDARD_CONVERT_NAMES: &[&str] = &[
"typeKdegF",
"typeKdegC",
"typeJdegF",
"typeJdegC",
"typeEdegF(ixe only)",
"typeEdegC(ixe only)",
"typeTdegF",
"typeTdegC",
"typeRdegF",
"typeRdegC",
"typeSdegF",
"typeSdegC",
];
fn standard_index_of(name: &str) -> Option<i16> {
STANDARD_CONVERT_NAMES
.iter()
.position(|n| *n == name)
.map(|pos| LINR_FIRST_BREAKTABLE + pos as i16)
}
#[derive(Debug, Clone)]
pub struct BreakTableRegistry {
tables: BTreeMap<String, Arc<BrkTable>>,
extra_menu: Vec<String>,
}
impl Default for BreakTableRegistry {
fn default() -> Self {
Self::new()
}
}
impl BreakTableRegistry {
pub fn new() -> Self {
let mut registry = Self {
tables: BTreeMap::new(),
extra_menu: Vec::new(),
};
for (name, points) in crate::server::record::bpt_generated::BREAK_TABLES {
let table = BrkTable::build(*name, points).unwrap_or_else(|e| {
unreachable!("vendored breakpoint table {name} is malformed: {e}")
});
registry.insert(table);
}
registry
}
pub fn is_empty(&self) -> bool {
self.tables.is_empty()
}
pub fn insert(&mut self, table: BrkTable) {
if self.tables.contains_key(&table.name) {
return;
}
if standard_index_of(&table.name).is_none() && !self.extra_menu.contains(&table.name) {
self.extra_menu.push(table.name.clone());
}
self.tables.insert(table.name.clone(), Arc::new(table));
}
pub fn get(&self, name: &str) -> Option<Arc<BrkTable>> {
self.tables.get(name).cloned()
}
pub fn linr_index_of(&self, name: &str) -> Option<i16> {
if let Some(idx) = standard_index_of(name) {
return Some(idx);
}
self.extra_menu
.iter()
.position(|n| n == name)
.map(|pos| LINR_FIRST_USER_TABLE + pos as i16)
}
pub fn table_for_linr(&self, linr: i16) -> Option<Arc<BrkTable>> {
let name = self.name_for_linr(linr)?;
self.tables.get(name).cloned()
}
fn name_for_linr(&self, linr: i16) -> Option<&str> {
if linr < LINR_FIRST_BREAKTABLE {
return None;
}
if linr < LINR_FIRST_USER_TABLE {
return STANDARD_CONVERT_NAMES
.get((linr - LINR_FIRST_BREAKTABLE) as usize)
.copied();
}
self.extra_menu
.get((linr - LINR_FIRST_USER_TABLE) as usize)
.map(|s| s.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ramp_table() -> BrkTable {
BrkTable::build("ramp", &[(0.0, 0.0), (100.0, 10.0), (300.0, 30.0)]).unwrap()
}
#[test]
fn build_computes_slopes_and_copies_last() {
let t = ramp_table();
assert_eq!(t.points.len(), 3);
assert!((t.points[0].slope - 0.1).abs() < 1e-12);
assert!((t.points[1].slope - 0.1).abs() < 1e-12);
assert_eq!(t.points[2].slope, t.points[1].slope);
}
#[test]
fn build_rejects_too_few_points() {
assert!(BrkTable::build("x", &[(0.0, 0.0)]).is_err());
assert!(BrkTable::build("x", &[]).is_err());
}
#[test]
fn build_rejects_zero_slope() {
let e = BrkTable::build("flat", &[(0.0, 5.0), (10.0, 5.0)]).unwrap_err();
assert!(e.contains("slope is zero"), "{e}");
}
#[test]
fn build_rejects_sign_change() {
let e = BrkTable::build("v", &[(0.0, 0.0), (10.0, 10.0), (20.0, 0.0)]).unwrap_err();
assert!(e.contains("slope changes sign"), "{e}");
}
#[test]
fn raw_to_eng_in_range_interpolates() {
let t = ramp_table();
let mut lbrk = 0;
let (eng, status) = cvt_raw_to_eng_bpt(50.0, &t, &mut lbrk);
assert_eq!(status, BptStatus::InRange);
assert!((eng - 5.0).abs() < 1e-12, "eng={eng}");
let (eng, status) = cvt_raw_to_eng_bpt(200.0, &t, &mut lbrk);
assert_eq!(status, BptStatus::InRange);
assert!((eng - 20.0).abs() < 1e-12, "eng={eng}");
}
#[test]
fn raw_to_eng_above_table_extrapolates_out_of_range() {
let t = ramp_table();
let mut lbrk = 0;
let (eng, status) = cvt_raw_to_eng_bpt(400.0, &t, &mut lbrk);
assert_eq!(status, BptStatus::OutOfRange);
assert!((eng - 40.0).abs() < 1e-12, "eng={eng}");
}
#[test]
fn raw_to_eng_below_table_extrapolates_out_of_range() {
let t = ramp_table();
let mut lbrk = 0;
let (eng, status) = cvt_raw_to_eng_bpt(-100.0, &t, &mut lbrk);
assert_eq!(status, BptStatus::OutOfRange);
assert!((eng + 10.0).abs() < 1e-12, "eng={eng}");
}
#[test]
fn eng_to_raw_is_inverse_in_range() {
let t = ramp_table();
let mut lbrk = 0;
let (raw, status) = cvt_eng_to_raw_bpt(20.0, &t, &mut lbrk);
assert_eq!(status, BptStatus::InRange);
assert!((raw - 200.0).abs() < 1e-9, "raw={raw}");
}
#[test]
fn decreasing_raw_table_brackets_correctly() {
let t = BrkTable::build("dn", &[(300.0, 0.0), (100.0, 20.0), (0.0, 30.0)]).unwrap();
let mut lbrk = 0;
let (eng, status) = cvt_raw_to_eng_bpt(200.0, &t, &mut lbrk);
assert_eq!(status, BptStatus::InRange);
assert!((eng - 10.0).abs() < 1e-12, "eng={eng}");
}
#[test]
fn standard_names_match_menu_convert_tail() {
assert_eq!(
STANDARD_CONVERT_NAMES,
&crate::server::record::MENU_CONVERT[LINR_FIRST_BREAKTABLE as usize..]
);
assert_eq!(LINR_FIRST_USER_TABLE, 15);
}
#[test]
fn user_tables_index_from_fifteen_in_load_order() {
let mut reg = BreakTableRegistry::new();
reg.insert(BrkTable::build("zeta", &[(0.0, 0.0), (1.0, 1.0)]).unwrap());
reg.insert(BrkTable::build("alpha", &[(0.0, 0.0), (1.0, 1.0)]).unwrap());
assert_eq!(reg.linr_index_of("zeta"), Some(15));
assert_eq!(reg.linr_index_of("alpha"), Some(16));
assert_eq!(reg.linr_index_of("missing"), None);
assert_eq!(reg.table_for_linr(15).unwrap().name, "zeta");
assert_eq!(reg.table_for_linr(16).unwrap().name, "alpha");
assert!(reg.table_for_linr(17).is_none());
assert!(reg.table_for_linr(2).is_none());
}
#[test]
fn standard_named_table_binds_to_reserved_index() {
let mut reg = BreakTableRegistry::new();
reg.insert(BrkTable::build("ramp", &[(0.0, 0.0), (1.0, 1.0)]).unwrap());
reg.insert(BrkTable::build("typeKdegF", &[(0.0, 0.0), (1.0, 2.0)]).unwrap());
assert_eq!(reg.linr_index_of("typeKdegF"), Some(3));
assert_eq!(reg.linr_index_of("typeSdegC"), Some(14));
assert_eq!(reg.linr_index_of("ramp"), Some(15));
assert_eq!(reg.table_for_linr(3).unwrap().name, "typeKdegF");
assert_eq!(reg.table_for_linr(15).unwrap().name, "ramp");
}
#[test]
fn standard_index_without_data_resolves_to_no_table() {
let mut reg = BreakTableRegistry::new();
reg.insert(BrkTable::build("ramp", &[(0.0, 0.0), (1.0, 1.0)]).unwrap());
assert_eq!(reg.linr_index_of("typeTdegC"), Some(10));
assert!(reg.table_for_linr(10).is_none());
}
#[test]
fn registry_index_is_stable_across_later_inserts() {
let mut reg = BreakTableRegistry::new();
reg.insert(BrkTable::build("zeta", &[(0.0, 0.0), (1.0, 1.0)]).unwrap());
assert_eq!(reg.linr_index_of("zeta"), Some(15));
reg.insert(BrkTable::build("alpha", &[(0.0, 0.0), (1.0, 1.0)]).unwrap());
assert_eq!(
reg.linr_index_of("zeta"),
Some(15),
"zeta index must not shift"
);
assert_eq!(reg.table_for_linr(15).unwrap().name, "zeta");
assert_eq!(reg.linr_index_of("alpha"), Some(16));
reg.insert(BrkTable::build("zeta", &[(0.0, 0.0), (2.0, 20.0)]).unwrap());
assert_eq!(
reg.linr_index_of("zeta"),
Some(15),
"redefinition keeps index"
);
assert_eq!(
reg.get("zeta").unwrap().points[1].eng,
1.0,
"first-wins: original zeta data kept, redefinition discarded"
);
}
}