use nalgebra::base::{Matrix3, Unit, Vector3};
use std::collections::{HashMap, HashSet};
use table_lookup::InvalidRangeTable;
#[cfg(feature = "parallel")]
pub use rayon;
#[cfg(feature = "parallel")]
use rayon::prelude::*;
pub use nalgebra;
mod smat;
pub use smat::ScoreMatrixBuilder;
mod table_lookup;
pub use table_lookup::{BinLookup, NdBinLookup, RangeTable};
pub mod neurons;
pub use neurons::{NblastNeuron, Neuron, QueryNeuron, TargetNeuron};
#[cfg(not(any(
feature = "nabo",
feature = "rstar",
feature = "kiddo",
feature = "bosque"
)))]
compile_error!("no spatial backend feature enabled");
pub type Precision = f64;
pub type Point3 = [Precision; 3];
pub type Normal3 = Unit<Vector3<Precision>>;
fn centroid<T: IntoIterator<Item = Point3>>(points: T) -> Point3 {
let mut len: f64 = 0.0;
let mut out = [0.0; 3];
for p in points {
len += 1.0;
for idx in 0..3 {
out[idx] += p[idx];
}
}
for el in &mut out {
*el /= len;
}
out
}
fn geometric_mean(a: Precision, b: Precision) -> Precision {
(a.max(0.0) * b.max(0.0)).sqrt()
}
fn harmonic_mean(a: Precision, b: Precision) -> Precision {
if a <= 0.0 || b <= 0.0 {
0.0
} else {
2.0 / (1.0 / a + 1.0 / b)
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct TangentAlpha {
pub tangent: Normal3,
pub alpha: Precision,
}
impl TangentAlpha {
fn new_from_points<'a>(points: impl Iterator<Item = &'a Point3>) -> Self {
let inertia = calc_inertia(points);
let eig = inertia.symmetric_eigen();
let mut sum = 0.0;
let mut vals: Vec<_> = eig
.eigenvalues
.iter()
.enumerate()
.map(|(idx, v)| {
sum += v;
(idx, v)
})
.collect();
vals.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap());
let alpha = (vals[0].1 - vals[1].1) / sum;
let tangent = Unit::new_normalize(eig.eigenvectors.column(vals[0].0).into());
Self { tangent, alpha }
}
}
#[derive(Default)]
pub enum Symmetry {
ArithmeticMean,
#[default]
GeometricMean,
HarmonicMean,
Min,
Max,
}
impl Symmetry {
pub fn apply(&self, query_score: Precision, target_score: Precision) -> Precision {
match self {
Symmetry::ArithmeticMean => (query_score + target_score) / 2.0,
Symmetry::GeometricMean => geometric_mean(query_score, target_score),
Symmetry::HarmonicMean => harmonic_mean(query_score, target_score),
Symmetry::Min => query_score.min(target_score),
Symmetry::Max => query_score.max(target_score),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DistDot {
pub dist: Precision,
pub dot: Precision,
}
impl DistDot {
fn to_idxs(
self,
dist_thresholds: &[Precision],
dot_thresholds: &[Precision],
) -> (usize, usize) {
let dist_bin = find_bin_binary(self.dist, dist_thresholds);
let dot_bin = find_bin_binary(self.dot, dot_thresholds);
(dist_bin, dot_bin)
}
fn to_linear_idx(self, dist_thresholds: &[Precision], dot_thresholds: &[Precision]) -> usize {
let (row_idx, col_idx) = self.to_idxs(dist_thresholds, dot_thresholds);
row_idx * dot_thresholds.len() + col_idx
}
}
impl Default for DistDot {
fn default() -> Self {
Self {
dist: 0.0,
dot: 1.0,
}
}
}
fn subtract_points(p1: &Point3, p2: &Point3) -> Point3 {
let mut result = [0.0; 3];
for ((rref, v1), v2) in result.iter_mut().zip(p1).zip(p2) {
*rref = v1 - v2;
}
result
}
fn center_points<'a>(points: impl Iterator<Item = &'a Point3>) -> impl Iterator<Item = Point3> {
let mut points_vec = Vec::default();
let mut means: Point3 = [0.0, 0.0, 0.0];
for pt in points {
points_vec.push(*pt);
for (sum, v) in means.iter_mut().zip(pt.iter()) {
*sum += v;
}
}
for val in means.iter_mut() {
*val /= points_vec.len() as Precision;
}
let subtract = move |p| subtract_points(&p, &means);
points_vec.into_iter().map(subtract)
}
fn dot(a: &[Precision], b: &[Precision]) -> Precision {
a.iter()
.zip(b.iter())
.fold(0.0, |sum, (ax, bx)| sum + ax * bx)
}
fn calc_inertia<'a>(points: impl Iterator<Item = &'a Point3>) -> Matrix3<Precision> {
let mut xs = Vec::default();
let mut ys = Vec::default();
let mut zs = Vec::default();
for point in center_points(points) {
xs.push(point[0]);
ys.push(point[1]);
zs.push(point[2]);
}
Matrix3::new(
dot(&xs, &xs),
0.0,
0.0,
dot(&ys, &xs),
dot(&ys, &ys),
0.0,
dot(&zs, &xs),
dot(&zs, &ys),
dot(&zs, &zs),
)
}
#[derive(Clone)]
pub struct PointsTangentsAlphas {
points: Vec<Point3>,
tangents_alphas: Vec<TangentAlpha>,
}
impl PointsTangentsAlphas {
pub fn new(points: Vec<Point3>, tangents_alphas: Vec<TangentAlpha>) -> Self {
Self {
points,
tangents_alphas,
}
}
}
impl NblastNeuron for PointsTangentsAlphas {
fn len(&self) -> usize {
self.points.len()
}
fn points(&self) -> impl Iterator<Item = Point3> + '_ {
self.points.iter().cloned()
}
fn centroid(&self) -> Point3 {
centroid(self.points())
}
fn tangents(&self) -> impl Iterator<Item = Normal3> + '_ {
self.tangents_alphas.iter().map(|ta| ta.tangent)
}
fn alphas(&self) -> impl Iterator<Item = Precision> + '_ {
self.tangents_alphas.iter().map(|ta| ta.alpha)
}
}
impl QueryNeuron for PointsTangentsAlphas {
fn query_dist_dots<'a>(
&'a self,
target: &'a impl TargetNeuron,
use_alpha: bool,
) -> impl Iterator<Item = DistDot> + 'a {
self.points
.iter()
.zip(self.tangents_alphas.iter())
.map(move |(q_pt, q_ta)| {
let alpha = if use_alpha { Some(q_ta.alpha) } else { None };
target.nearest_match_dist_dot(q_pt, &q_ta.tangent, alpha)
})
}
fn query(
&self,
target: &impl TargetNeuron,
use_alpha: bool,
score_calc: &ScoreCalc,
) -> Precision {
let mut score_total: Precision = 0.0;
for (q_pt, q_ta) in self.points.iter().zip(self.tangents_alphas.iter()) {
let alpha = if use_alpha { Some(q_ta.alpha) } else { None };
score_total +=
score_calc.calc(&target.nearest_match_dist_dot(q_pt, &q_ta.tangent, alpha));
}
score_total
}
fn self_hit(&self, score_calc: &ScoreCalc, use_alpha: bool) -> Precision {
if use_alpha {
self.tangents_alphas
.iter()
.map(|ta| {
score_calc.calc(&DistDot {
dist: 0.0,
dot: ta.alpha,
})
})
.fold(0.0, |total, s| total + s)
} else {
score_calc.calc(&DistDot {
dist: 0.0,
dot: 1.0,
}) * self.len() as Precision
}
}
}
fn find_bin_binary(value: Precision, upper_bounds: &[Precision]) -> usize {
let raw = match upper_bounds.binary_search_by(|bound| bound.partial_cmp(&value).unwrap()) {
Ok(v) => v + 1,
Err(v) => v,
};
let highest = upper_bounds.len() - 1;
if raw > highest {
highest
} else {
raw
}
}
pub fn table_to_fn(
dist_thresholds: Vec<Precision>,
dot_thresholds: Vec<Precision>,
cells: Vec<Precision>,
) -> impl Fn(&DistDot) -> Precision {
if dist_thresholds.len() * dot_thresholds.len() != cells.len() {
panic!("Number of cells in table do not match number of columns/rows");
}
move |dd: &DistDot| -> Precision { cells[dd.to_linear_idx(&dist_thresholds, &dot_thresholds)] }
}
pub fn range_table_to_fn(
range_table: RangeTable<Precision, Precision>,
) -> impl Fn(&DistDot) -> Precision {
move |dd: &DistDot| -> Precision { *range_table.lookup(&[dd.dist, dd.dot]) }
}
trait Location {
fn location(&self) -> &Point3;
fn distance2_to<T: Location>(&self, other: T) -> Precision {
self.location()
.iter()
.zip(other.location().iter())
.map(|(a, b)| a * a + b * b)
.sum()
}
fn distance_to<T: Location>(&self, other: T) -> Precision {
self.distance2_to(other).sqrt()
}
}
impl Location for Point3 {
fn location(&self) -> &Point3 {
self
}
}
impl Location for &Point3 {
fn location(&self) -> &Point3 {
self
}
}
#[derive(Clone)]
struct NeuronSelfHit<N: QueryNeuron> {
neuron: N,
self_hit: Precision,
centroid: [Precision; 3],
}
impl<N: QueryNeuron> NeuronSelfHit<N> {
fn new(neuron: N, self_hit: Precision) -> Self {
let centroid = neuron.centroid();
Self {
neuron,
self_hit,
centroid,
}
}
fn score(&self) -> Precision {
self.self_hit
}
}
#[derive(Debug, Clone)]
pub enum ScoreCalc {
Table(RangeTable<Precision, Precision>),
}
impl ScoreCalc {
pub fn table_from_bins(
dists: Vec<Precision>,
dots: Vec<Precision>,
values: Vec<Precision>,
) -> Result<Self, InvalidRangeTable> {
Ok(Self::Table(RangeTable::new_from_bins(
vec![dists, dots],
values,
)?))
}
pub fn calc(&self, dist_dot: &DistDot) -> Precision {
match self {
Self::Table(tab) => *tab.lookup(&[dist_dot.dist, dist_dot.dot]),
}
}
}
#[allow(dead_code)]
pub struct NblastArena<N>
where
N: TargetNeuron,
{
neurons_scores: Vec<NeuronSelfHit<N>>,
score_calc: ScoreCalc,
use_alpha: bool,
threads: Option<usize>,
}
pub type NeuronIdx = usize;
impl<N> NblastArena<N>
where
N: TargetNeuron + Sync,
{
pub fn new(score_calc: ScoreCalc, use_alpha: bool) -> Self {
Self {
neurons_scores: Vec::default(),
score_calc,
use_alpha,
threads: None,
}
}
#[cfg(feature = "parallel")]
pub fn with_threads(self, threads: usize) -> Self {
Self {
neurons_scores: self.neurons_scores,
score_calc: self.score_calc,
use_alpha: self.use_alpha,
threads: Some(threads),
}
}
pub fn size_of(&self, idx: NeuronIdx) -> Option<usize> {
self.neurons_scores.get(idx).map(|n| n.neuron.len())
}
fn next_id(&self) -> NeuronIdx {
self.neurons_scores.len()
}
pub fn add_neuron(&mut self, neuron: N) -> NeuronIdx {
let idx = self.next_id();
let self_hit = neuron.self_hit(&self.score_calc, self.use_alpha);
self.neurons_scores
.push(NeuronSelfHit::new(neuron, self_hit));
idx
}
pub fn query_target(
&self,
query_idx: NeuronIdx,
target_idx: NeuronIdx,
normalize: bool,
symmetry: &Option<Symmetry>,
) -> Option<Precision> {
let q = self.neurons_scores.get(query_idx)?;
if query_idx == target_idx {
return if normalize {
Some(1.0)
} else {
Some(q.score())
};
}
let t = self.neurons_scores.get(target_idx)?;
let mut score = q.neuron.query(&t.neuron, self.use_alpha, &self.score_calc);
if normalize {
score /= q.score()
}
match symmetry {
Some(s) => {
let mut score2 = t.neuron.query(&q.neuron, self.use_alpha, &self.score_calc);
if normalize {
score2 /= t.score();
}
Some(s.apply(score, score2))
}
_ => Some(score),
}
}
pub fn queries_targets(
&self,
query_idxs: &[NeuronIdx],
target_idxs: &[NeuronIdx],
normalize: bool,
symmetry: &Option<Symmetry>,
max_centroid_dist: Option<Precision>,
) -> HashMap<(NeuronIdx, NeuronIdx), Precision> {
let pairs: Vec<_> = query_idxs
.iter()
.filter_map(|q| {
let q2 = *q;
if q2 >= self.len() {
None
} else {
Some(target_idxs.iter().filter_map(move |t| {
if t >= &self.len() {
None
} else {
Some((q2, *t))
}
}))
}
})
.flatten()
.collect();
self.query_target_pairs(&pairs, normalize, symmetry, max_centroid_dist)
}
pub fn query_target_pairs(
&self,
query_target_idxs: &[(NeuronIdx, NeuronIdx)],
normalize: bool,
symmetry: &Option<Symmetry>,
max_centroid_dist: Option<Precision>,
) -> HashMap<(NeuronIdx, NeuronIdx), Precision> {
let mut max_jobs = query_target_idxs.len();
let mut out = HashMap::with_capacity(query_target_idxs.len());
if symmetry.is_some() {
max_jobs *= 2;
}
let mut jobs = HashSet::with_capacity(max_jobs);
for (q, t) in query_target_idxs {
if q > &self.len() || t > &self.len() {
continue;
}
let key = (*q, *t);
if q == t {
out.insert(
key,
if normalize {
1.0
} else {
self.neurons_scores[*q].score()
},
);
continue;
} else {
out.insert(key, Precision::NAN);
}
if jobs.contains(&(*q, *t)) {
continue;
}
if let Some(d) = max_centroid_dist {
if !self
.centroids_within_distance(*q, *t, d)
.expect("Already checked indices")
{
continue;
}
}
jobs.insert(key);
if symmetry.is_some() {
jobs.insert((key.1, key.0));
}
}
let raw = pairs_to_raw(self, &jobs.into_iter().collect::<Vec<_>>(), normalize);
for (key, value) in out.iter_mut() {
if let Some(forward) = raw.get(key) {
if let Some(s) = symmetry {
let backward = raw[&(key.1, key.0)];
*value = s.apply(*forward, backward);
} else {
*value = *forward;
}
}
}
out
}
pub fn centroids_within_distance(
&self,
query_idx: NeuronIdx,
target_idx: NeuronIdx,
max_centroid_dist: Precision,
) -> Option<bool> {
if query_idx == target_idx {
return Some(true);
}
self.neurons_scores.get(query_idx).and_then(|q| {
self.neurons_scores
.get(target_idx)
.map(|t| q.centroid.distance_to(t.centroid) < max_centroid_dist)
})
}
pub fn self_hit(&self, idx: NeuronIdx) -> Option<Precision> {
self.neurons_scores.get(idx).map(|n| n.score())
}
pub fn all_v_all(
&self,
normalize: bool,
symmetry: &Option<Symmetry>,
max_centroid_dist: Option<Precision>,
) -> HashMap<(NeuronIdx, NeuronIdx), Precision> {
let idxs: Vec<NeuronIdx> = (0..self.len()).collect();
self.queries_targets(&idxs, &idxs, normalize, symmetry, max_centroid_dist)
}
pub fn is_empty(&self) -> bool {
self.neurons_scores.is_empty()
}
pub fn len(&self) -> usize {
self.neurons_scores.len()
}
pub fn points(&self, idx: NeuronIdx) -> Option<impl Iterator<Item = Point3> + '_> {
self.neurons_scores.get(idx).map(|n| n.neuron.points())
}
pub fn tangents(&self, idx: NeuronIdx) -> Option<impl Iterator<Item = Normal3> + '_> {
self.neurons_scores.get(idx).map(|n| n.neuron.tangents())
}
pub fn alphas(&self, idx: NeuronIdx) -> Option<impl Iterator<Item = Precision> + '_> {
self.neurons_scores.get(idx).map(|n| n.neuron.alphas())
}
}
fn pairs_to_raw_serial<N: TargetNeuron + Sync>(
arena: &NblastArena<N>,
pairs: &[(NeuronIdx, NeuronIdx)],
normalize: bool,
) -> HashMap<(NeuronIdx, NeuronIdx), Precision> {
pairs
.iter()
.filter_map(|(q_idx, t_idx)| {
arena
.query_target(*q_idx, *t_idx, normalize, &None)
.map(|s| ((*q_idx, *t_idx), s))
})
.collect()
}
#[cfg(not(feature = "parallel"))]
fn pairs_to_raw<N>(
arena: &NblastArena<N>,
pairs: &[(NeuronIdx, NeuronIdx)],
normalize: bool,
) -> HashMap<(NeuronIdx, NeuronIdx), Precision>
where
N: TargetNeuron + Sync,
{
pairs_to_raw_serial(arena, pairs, normalize)
}
#[cfg(feature = "parallel")]
fn pairs_to_raw<N: TargetNeuron + Sync>(
arena: &NblastArena<N>,
pairs: &[(NeuronIdx, NeuronIdx)],
normalize: bool,
) -> HashMap<(NeuronIdx, NeuronIdx), Precision> {
if let Some(t) = arena.threads {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(t)
.build()
.unwrap();
pool.install(|| {
pairs
.par_iter()
.filter_map(|(q_idx, t_idx)| {
arena
.query_target(*q_idx, *t_idx, normalize, &None)
.map(|s| ((*q_idx, *t_idx), s))
})
.collect()
})
} else {
pairs_to_raw_serial(arena, pairs, normalize)
}
}
#[cfg(test)]
mod test {
use super::*;
const EPSILON: Precision = 0.001;
const N_NEIGHBORS: usize = 5;
fn add_points(a: &Point3, b: &Point3) -> Point3 {
let mut out = [0., 0., 0.];
for (idx, (x, y)) in a.iter().zip(b.iter()).enumerate() {
out[idx] = x + y;
}
out
}
fn make_points(offset: &Point3, step: &Point3, count: usize) -> Vec<Point3> {
let mut out = Vec::default();
out.push(*offset);
for _ in 0..count - 1 {
let to_push = add_points(out.last().unwrap(), step);
out.push(to_push);
}
out
}
#[test]
fn construct() {
let points = make_points(&[0., 0., 0.], &[1., 0., 0.], 10);
Neuron::new(points, N_NEIGHBORS).unwrap();
}
fn is_close(val1: Precision, val2: Precision) -> bool {
println!("Comparing values:\n\tval1: {:?}\n\tval2: {:?}", val1, val2);
(val1 - val2).abs() < EPSILON
}
fn assert_close(val1: Precision, val2: Precision) {
if !is_close(val1, val2) {
panic!("Not close:\n\t{:?}\n\t{:?}", val1, val2);
}
}
#[test]
fn unit_tangents_eig() {
let (points, _, _) = tangent_data();
let tangent = TangentAlpha::new_from_points(points.iter()).tangent;
assert_close(tangent.dot(&tangent), 1.0)
}
fn equivalent_tangents(tan1: &Normal3, tan2: &Normal3) -> bool {
is_close(tan1.dot(tan2).abs(), 1.0)
}
fn tangent_data() -> (Vec<Point3>, Normal3, Precision) {
let tangent = Unit::new_normalize(Vector3::from_column_slice(&[
-0.939_392_2,
0.313_061_82,
0.139_766_18,
]));
let points = vec![
[
329.679_962_158_203,
72.718_803_405_761_7,
31.028_469_085_693_4,
],
[
328.647_399_902_344,
73.046_119_689_941_4,
31.537_061_691_284_2,
],
[
335.219_879_150_391,
70.710_479_736_328_1,
30.398_145_675_659_2,
],
[
332.611_389_160_156,
72.322_929_382_324_2,
30.887_334_823_608_4,
],
[
331.770_782_470_703,
72.434_440_612_793,
31.169_372_558_593_8,
],
];
let alpha = 0.844_842_871_450_449;
(points, tangent, alpha)
}
#[test]
fn test_tangent_eig() {
let (points, exp_tan, _exp_alpha) = tangent_data();
let ta = TangentAlpha::new_from_points(points.iter());
if !equivalent_tangents(&ta.tangent, &exp_tan) {
panic!(
"Non-equivalent tangents:\n\t{:?}\n\t{:?}",
ta.tangent, exp_tan
)
}
}
#[test]
fn test_neuron() {
let (points, exp_tan, _exp_alpha) = tangent_data();
let tgt = Neuron::new(points, N_NEIGHBORS).unwrap();
assert!(equivalent_tangents(
&tgt.tangents().next().unwrap(),
&exp_tan
));
}
fn score_mat() -> (Vec<Precision>, Vec<Precision>, Vec<Precision>) {
let dists = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let dots = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
let mut values = vec![];
let n_values = dots.len() * dists.len();
for v in 0..n_values {
values.push(v as Precision);
}
(dists, dots, values)
}
#[test]
fn test_score_calc() {
let (dists, dots, values) = score_mat();
let func = table_to_fn(dists, dots, values);
assert_close(
func(&DistDot {
dist: 0.0,
dot: 0.0,
}),
0.0,
);
assert_close(
func(&DistDot {
dist: 0.0,
dot: 0.1,
}),
1.0,
);
assert_close(
func(&DistDot {
dist: 11.0,
dot: 0.0,
}),
10.0,
);
assert_close(
func(&DistDot {
dist: 55.0,
dot: 0.0,
}),
40.0,
);
assert_close(
func(&DistDot {
dist: 55.0,
dot: 10.0,
}),
49.0,
);
assert_close(
func(&DistDot {
dist: 15.0,
dot: 0.15,
}),
11.0,
);
}
#[test]
fn test_find_bin_binary() {
let dots = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
assert_eq!(find_bin_binary(0.0, &dots), 0);
assert_eq!(find_bin_binary(0.15, &dots), 1);
assert_eq!(find_bin_binary(0.95, &dots), 9);
assert_eq!(find_bin_binary(-10.0, &dots), 0);
assert_eq!(find_bin_binary(10.0, &dots), 9);
assert_eq!(find_bin_binary(0.1, &dots), 1);
}
#[test]
fn arena() {
let dist_thresholds = vec![0.0, 1.0, 2.0];
let dot_thresholds = vec![0.0, 0.5, 1.0];
let cells = vec![1.0, 2.0, 4.0, 8.0];
let score_calc = ScoreCalc::Table(
RangeTable::new_from_bins(vec![dist_thresholds, dot_thresholds], cells).unwrap(),
);
let query =
Neuron::new(make_points(&[0., 0., 0.], &[1., 0., 0.], 10), N_NEIGHBORS).unwrap();
let target =
Neuron::new(make_points(&[0.5, 0., 0.], &[1.1, 0., 0.], 10), N_NEIGHBORS).unwrap();
let mut arena = NblastArena::new(score_calc, false);
let q_idx = arena.add_neuron(query);
let t_idx = arena.add_neuron(target);
let no_norm = arena
.query_target(q_idx, t_idx, false, &None)
.expect("should exist");
let self_hit = arena
.query_target(q_idx, q_idx, false, &None)
.expect("should exist");
assert!(
arena
.query_target(q_idx, t_idx, true, &None)
.expect("should exist")
- no_norm / self_hit
< EPSILON
);
assert_eq!(
arena.query_target(q_idx, t_idx, false, &Some(Symmetry::ArithmeticMean)),
arena.query_target(t_idx, q_idx, false, &Some(Symmetry::ArithmeticMean)),
);
let out = arena.queries_targets(&[q_idx, t_idx], &[t_idx, q_idx], false, &None, None);
assert_eq!(out.len(), 4);
}
fn test_symmetry(symmetry: &Symmetry, a: Precision, b: Precision) {
assert_close(symmetry.apply(a, b), symmetry.apply(b, a))
}
fn test_symmetry_multiple(symmetry: &Symmetry) {
for (a, b) in vec![(0.3, 0.7), (0.0, 0.7), (-1.0, 0.7), (100.0, 1000.0)].into_iter() {
test_symmetry(symmetry, a, b);
}
}
#[test]
fn symmetry_arithmetic() {
test_symmetry_multiple(&Symmetry::ArithmeticMean)
}
#[test]
fn symmetry_harmonic() {
test_symmetry_multiple(&Symmetry::HarmonicMean)
}
#[test]
fn symmetry_geometric() {
test_symmetry_multiple(&Symmetry::GeometricMean)
}
#[test]
fn symmetry_min() {
test_symmetry_multiple(&Symmetry::Min)
}
#[test]
fn symmetry_max() {
test_symmetry_multiple(&Symmetry::Max)
}
}