use super::geometry::FittingBounds;
use super::grid::{GridParams, build_grid};
use super::hull_fit::RawPlane;
use super::production::ProductionFunction;
use super::rng::{SplitMix64, fnv1a64};
use cobre_io::extensions::PlaneReductionConfig;
const ORIGIN_EPS: f64 = 1e-9;
fn plane_normal(plane: &RawPlane) -> [f64; 3] {
[plane.gamma_v, plane.gamma_q, -1.0]
}
fn angle_deg(a: &RawPlane, b: &RawPlane) -> f64 {
let n1 = plane_normal(a);
let n2 = plane_normal(b);
let dot = n1[0] * n2[0] + n1[1] * n2[1] + n1[2] * n2[2];
let norm1 = (n1[0] * n1[0] + n1[1] * n1[1] + n1[2] * n1[2]).sqrt();
let norm2 = (n2[0] * n2[0] + n2[1] * n2[1] + n2[2] * n2[2]).sqrt();
let cos = (dot / (norm1 * norm2)).clamp(-1.0, 1.0);
cos.acos().to_degrees()
}
fn mean_plane(a: &RawPlane, b: &RawPlane) -> RawPlane {
RawPlane {
gamma_0: f64::midpoint(a.gamma_0, b.gamma_0),
gamma_v: f64::midpoint(a.gamma_v, b.gamma_v),
gamma_q: f64::midpoint(a.gamma_q, b.gamma_q),
gamma_s: f64::midpoint(a.gamma_s, b.gamma_s),
}
}
fn is_origin_plane(plane: &RawPlane) -> bool {
plane.gamma_0.abs() <= ORIGIN_EPS && plane.gamma_v.abs() <= ORIGIN_EPS
}
fn reduce_planes_with<F>(planes: &[RawPlane], should_merge: F) -> Vec<RawPlane>
where
F: Fn(usize, usize, &RawPlane, &RawPlane) -> bool,
{
let mut out: Vec<RawPlane> = Vec::with_capacity(planes.len());
let mut tail_slot: usize = 0;
for (cand_slot, candidate) in planes.iter().enumerate() {
let merged_tail = match out.last() {
Some(tail)
if !is_origin_plane(tail)
&& !is_origin_plane(candidate)
&& should_merge(tail_slot, cand_slot, tail, candidate) =>
{
Some(mean_plane(tail, candidate))
}
_ => None,
};
if let Some(merged) = merged_tail {
if let Some(tail) = out.last_mut() {
*tail = merged;
}
} else {
out.push(*candidate);
tail_slot = cand_slot;
}
}
out
}
fn reduce_planes_angle(planes: &[RawPlane], tolerance_deg: f64) -> Vec<RawPlane> {
reduce_planes_with(planes, |_tail_slot, _cand_slot, tail, candidate| {
angle_deg(tail, candidate) < tolerance_deg
})
}
fn grid_max_gh(pf: &ProductionFunction, grid: &GridParams) -> f64 {
let mut generation_max = f64::NEG_INFINITY;
for &v in &grid.v_points {
for &q in &grid.q_points {
let generation = pf.evaluate(v, q, 0.0);
if generation > generation_max {
generation_max = generation;
}
}
}
generation_max
}
fn pair_seed(hydro_id: i32, entry_level_bits: u64, tail_slot: usize, candidate_slot: usize) -> u64 {
let tail_slot_u32 = u32::try_from(tail_slot).unwrap_or(u32::MAX);
let candidate_slot_u32 = u32::try_from(candidate_slot).unwrap_or(u32::MAX);
let mut buf = [0u8; 20];
buf[0..4].copy_from_slice(&hydro_id.to_le_bytes());
buf[4..12].copy_from_slice(&entry_level_bits.to_le_bytes());
buf[12..16].copy_from_slice(&tail_slot_u32.to_le_bytes());
buf[16..20].copy_from_slice(&candidate_slot_u32.to_le_bytes());
fnv1a64(&buf)
}
fn pair_similar_distance(
tail: &RawPlane,
candidate: &RawPlane,
grid: &GridParams,
generation_max: f64,
n_samples: u32,
seed: u64,
tolerance_pct: f64,
) -> bool {
let v_first = grid.v_points.first().copied().unwrap_or(0.0);
let v_last = grid.v_points.last().copied().unwrap_or(0.0);
let q_first = grid.q_points.first().copied().unwrap_or(0.0);
let q_last = grid.q_points.last().copied().unwrap_or(0.0);
let v_span = v_last - v_first;
let q_span = q_last - q_first;
let mut rng = SplitMix64::new(seed);
let mut sum_sq = 0.0_f64;
for _ in 0..n_samples {
let v = v_first + rng.next_unit_f64() * v_span;
let q = q_first + rng.next_unit_f64() * q_span;
let d = tail.evaluate(v, q, 0.0) - candidate.evaluate(v, q, 0.0);
sum_sq += d * d;
}
let eqm = sum_sq / f64::from(n_samples);
let delta = eqm / (generation_max * generation_max);
delta < tolerance_pct / 100.0
}
fn reduce_planes_distance(
planes: &[RawPlane],
pf: &ProductionFunction,
bounds: &FittingBounds,
tolerance_pct: f64,
n_samples: u32,
hydro_id: i32,
entry_level_bits: u64,
) -> Vec<RawPlane> {
let grid = build_grid(pf, bounds);
let generation_max = grid_max_gh(pf, &grid);
if generation_max <= 0.0 {
return planes.to_vec();
}
reduce_planes_with(planes, |tail_slot, cand_slot, tail, candidate| {
let seed = pair_seed(hydro_id, entry_level_bits, tail_slot, cand_slot);
pair_similar_distance(
tail,
candidate,
&grid,
generation_max,
n_samples,
seed,
tolerance_pct,
)
})
}
pub(crate) fn reduce_planes(
planes: &[RawPlane],
config: &PlaneReductionConfig,
pf: &ProductionFunction,
bounds: &FittingBounds,
hydro_id: i32,
entry_level_bits: u64,
) -> Vec<RawPlane> {
match config {
PlaneReductionConfig::Angle { tolerance_deg } => {
reduce_planes_angle(planes, *tolerance_deg)
}
PlaneReductionConfig::Distance {
tolerance_pct,
n_samples,
} => reduce_planes_distance(
planes,
pf,
bounds,
*tolerance_pct,
*n_samples,
hydro_id,
entry_level_bits,
),
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::similar_names
)]
mod tests {
use cobre_core::{EfficiencyModel, EntityId, HydraulicLossesModel, TailraceModel};
use cobre_io::extensions::HydroGeometryRow;
use super::super::geometry::{FittingBounds, ForebayTable};
use super::super::hull_fit::RawPlane;
use super::super::production::{ProductionFunction, TailraceSource};
use super::super::selection::validate_fitted_planes;
use super::{
angle_deg, grid_max_gh, is_origin_plane, mean_plane, pair_seed, pair_similar_distance,
reduce_planes, reduce_planes_angle, reduce_planes_distance,
};
use cobre_io::extensions::PlaneReductionConfig;
fn plane(gamma_0: f64, gamma_v: f64, gamma_q: f64, gamma_s: f64) -> RawPlane {
RawPlane {
gamma_0,
gamma_v,
gamma_q,
gamma_s,
}
}
fn row(volume_hm3: f64, height_m: f64) -> HydroGeometryRow {
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3,
height_m,
area_km2: 0.0,
}
}
fn test_bounds() -> FittingBounds {
FittingBounds {
v_min: 0.0,
v_max: 30_000.0,
n_volume_points: 6,
n_flow_points: 6,
n_spillage_points: 2,
max_planes_per_hydro: 10,
single_volume: false,
}
}
fn test_pf() -> ProductionFunction {
let rows = vec![
row(0.0, 380.0),
row(10_000.0, 396.0),
row(20_000.0, 404.0),
row(30_000.0, 408.0),
];
let forebay = ForebayTable::new(&rows, "DistanceFit").expect("valid VHA curve");
let tailrace = TailraceModel::Polynomial {
coefficients: vec![0.0, 0.0008, -2e-8],
};
ProductionFunction::new(
forebay,
TailraceSource::Entity(Some(tailrace)),
Some(&HydraulicLossesModel::Constant { value_m: 2.0 }),
Some(&EfficiencyModel::Constant { value: 0.92 }),
3_000.0,
"DistanceFit".to_owned(),
)
}
#[test]
fn identical_normal_pair_merges_to_component_wise_mean() {
let a = plane(100.0, 0.002, 0.85, -0.01);
let b = plane(200.0, 0.002, 0.85, -0.03);
let reduced = reduce_planes_angle(&[a, b], 1.0);
assert_eq!(
reduced.len(),
1,
"identical normals must merge to one plane"
);
let expected = mean_plane(&a, &b);
assert_eq!(reduced[0].gamma_0.to_bits(), expected.gamma_0.to_bits());
assert_eq!(reduced[0].gamma_v.to_bits(), expected.gamma_v.to_bits());
assert_eq!(reduced[0].gamma_q.to_bits(), expected.gamma_q.to_bits());
assert_eq!(reduced[0].gamma_s.to_bits(), expected.gamma_s.to_bits());
}
#[test]
fn below_tolerance_merges_at_or_above_does_not() {
let a = plane(100.0, 0.0, 1.0, 0.0);
let b = plane(100.0, 0.01, 1.0, 0.0);
let theta = angle_deg(&a, &b);
assert!(
theta > 0.0,
"the two normals must differ by a positive angle"
);
let below = reduce_planes_angle(&[a, b], theta + 1e-6);
assert_eq!(below.len(), 1, "a below-tolerance pair must merge");
let at = reduce_planes_angle(&[a, b], theta);
assert_eq!(
at.len(),
2,
"an at-tolerance pair must NOT merge (strict <)"
);
let above = reduce_planes_angle(&[a, b], theta - 1e-6);
assert_eq!(above.len(), 2, "an above-tolerance pair must NOT merge");
}
#[test]
fn greedy_cascade_three_planes_collapse_to_one() {
let a = plane(100.0, 0.000, 1.0, -0.01);
let b = plane(120.0, 0.001, 1.0, -0.02);
let c = plane(140.0, 0.002, 1.0, -0.03);
let reduced = reduce_planes_angle(&[a, b, c], 5.0);
assert_eq!(
reduced.len(),
1,
"all three within tolerance must cascade into one plane, got {}",
reduced.len()
);
}
#[test]
fn origin_plane_never_merged() {
let origin = plane(0.0, 0.0, 1.0, 0.0);
assert!(
is_origin_plane(&origin),
"the fixture must be the origin plane"
);
let neighbour = plane(0.0, 0.0001, 1.0, 0.0);
let reduced = reduce_planes_angle(&[origin, neighbour], 90.0);
assert_eq!(reduced.len(), 2, "origin must not absorb its neighbour");
assert_eq!(reduced[0].gamma_0.to_bits(), origin.gamma_0.to_bits());
assert_eq!(reduced[0].gamma_v.to_bits(), origin.gamma_v.to_bits());
assert_eq!(reduced[0].gamma_q.to_bits(), origin.gamma_q.to_bits());
assert_eq!(reduced[0].gamma_s.to_bits(), origin.gamma_s.to_bits());
let reduced_rev = reduce_planes_angle(&[neighbour, origin], 90.0);
assert_eq!(
reduced_rev.len(),
2,
"origin must not be absorbed as candidate"
);
assert_eq!(reduced_rev[1].gamma_0.to_bits(), origin.gamma_0.to_bits());
assert_eq!(reduced_rev[1].gamma_v.to_bits(), origin.gamma_v.to_bits());
assert_eq!(reduced_rev[1].gamma_q.to_bits(), origin.gamma_q.to_bits());
assert_eq!(reduced_rev[1].gamma_s.to_bits(), origin.gamma_s.to_bits());
}
#[test]
fn zero_tolerance_merges_nothing() {
let a = plane(100.0, 0.002, 0.85, -0.01);
let b = plane(200.0, 0.002, 0.85, -0.03);
let reduced = reduce_planes_angle(&[a, b], 0.0);
assert_eq!(reduced.len(), 2, "tolerance 0 must merge nothing");
}
#[test]
fn merged_plane_passes_validation() {
let a = plane(100.0, 0.001, 3.5, -0.01);
let b = plane(200.0, 0.001, 3.5, -0.02);
let config = PlaneReductionConfig::Angle { tolerance_deg: 1.0 };
let pf = test_pf();
let bounds = test_bounds();
let reduced = reduce_planes(&[a, b], &config, &pf, &bounds, 1, 0);
assert_eq!(reduced.len(), 1, "the pair must merge");
let result = validate_fitted_planes(&reduced, 0.99, "MergedHydro");
assert!(
result.is_ok(),
"merged plane must be sign-valid: {result:?}"
);
}
#[test]
fn grid_max_gh_is_positive_for_concave_window() {
let pf = test_pf();
let bounds = test_bounds();
let generation_max = grid_max_gh(&pf, &super::build_grid(&pf, &bounds));
assert!(
generation_max > 0.0,
"the concave fixture must have a positive generation window, got {generation_max}"
);
}
#[test]
fn pair_similar_distance_is_reproducible_from_seed() {
let pf = test_pf();
let bounds = test_bounds();
let grid = super::build_grid(&pf, &bounds);
let generation_max = grid_max_gh(&pf, &grid);
let tail = plane(100.0, 0.002, 0.85, -0.01);
let candidate = plane(140.0, 0.0025, 0.90, -0.02);
let seed = 0x1234_5678_9abc_def0_u64;
let n_samples = 128;
let sum_sq = |seed: u64| -> f64 {
let grid = super::build_grid(&pf, &bounds);
let v_first = grid.v_points[0];
let v_last = *grid.v_points.last().expect("non-empty");
let q_first = grid.q_points[0];
let q_last = *grid.q_points.last().expect("non-empty");
let mut rng = super::SplitMix64::new(seed);
let mut acc = 0.0_f64;
for _ in 0..n_samples {
let v = v_first + rng.next_unit_f64() * (v_last - v_first);
let q = q_first + rng.next_unit_f64() * (q_last - q_first);
let d = tail.evaluate(v, q, 0.0) - candidate.evaluate(v, q, 0.0);
acc += d * d;
}
acc
};
assert_eq!(
sum_sq(seed).to_bits(),
sum_sq(seed).to_bits(),
"Σ d² must be bit-identical for a shared seed"
);
let first = pair_similar_distance(
&tail,
&candidate,
&grid,
generation_max,
n_samples,
seed,
50.0,
);
let second = pair_similar_distance(
&tail,
&candidate,
&grid,
generation_max,
n_samples,
seed,
50.0,
);
assert_eq!(
first, second,
"the predicate must be reproducible from its seed"
);
}
#[test]
fn coincident_planes_have_zero_delta_and_merge() {
let pf = test_pf();
let bounds = test_bounds();
let grid = super::build_grid(&pf, &bounds);
let generation_max = grid_max_gh(&pf, &grid);
let p = plane(120.0, 0.0015, 0.80, -0.015);
assert!(
pair_similar_distance(&p, &p, &grid, generation_max, 128, 0xabc_def, 1e-9),
"coincident planes must be similar at any positive tolerance"
);
let reduced = reduce_planes_distance(&[p, p], &pf, &bounds, 0.5, 128, 1, 0);
assert_eq!(reduced.len(), 1, "coincident pair must merge to one plane");
assert_eq!(reduced[0].gamma_0.to_bits(), p.gamma_0.to_bits());
assert_eq!(reduced[0].gamma_v.to_bits(), p.gamma_v.to_bits());
assert_eq!(reduced[0].gamma_q.to_bits(), p.gamma_q.to_bits());
assert_eq!(reduced[0].gamma_s.to_bits(), p.gamma_s.to_bits());
}
#[test]
fn distance_reduction_is_input_order_invariant() {
let pf = test_pf();
let bounds = test_bounds();
let planes = [
plane(100.0, 0.0010, 0.80, -0.010),
plane(101.0, 0.0011, 0.81, -0.011),
plane(300.0, 0.0050, 1.50, -0.030),
];
let config = PlaneReductionConfig::Distance {
tolerance_pct: 0.5,
n_samples: 64,
};
let first = reduce_planes(&planes, &config, &pf, &bounds, 7, 42);
let second = reduce_planes(&planes, &config, &pf, &bounds, 7, 42);
assert_eq!(first.len(), second.len());
for (a, b) in first.iter().zip(&second) {
assert_eq!(a.gamma_0.to_bits(), b.gamma_0.to_bits());
assert_eq!(a.gamma_v.to_bits(), b.gamma_v.to_bits());
assert_eq!(a.gamma_q.to_bits(), b.gamma_q.to_bits());
assert_eq!(a.gamma_s.to_bits(), b.gamma_s.to_bits());
}
}
#[test]
fn distinct_entry_level_bits_yield_distinct_seed() {
let seed_a = pair_seed(5, 0, 1, 2);
let seed_b = pair_seed(5, 0x3ff0_0000_0000_0000, 1, 2);
assert_ne!(
seed_a, seed_b,
"a different entry_level_bits must change the seed"
);
}
#[test]
fn non_positive_gh_max_does_not_merge() {
let pf = test_pf();
let bounds = test_bounds();
let p = plane(120.0, 0.0015, 0.80, -0.015);
let grid = super::build_grid(&pf, &bounds);
assert!(
!pair_similar_distance(&p, &p, &grid, 0.0, 64, 1, 99.0),
"generation_max = 0 must make the predicate inert (no similarity)"
);
}
#[test]
fn zero_tolerance_distance_merges_nothing() {
let pf = test_pf();
let bounds = test_bounds();
let planes = [
plane(100.0, 0.0010, 0.80, -0.010),
plane(101.0, 0.0011, 0.81, -0.011),
];
let reduced = reduce_planes_distance(&planes, &pf, &bounds, 0.0, 64, 1, 0);
assert_eq!(reduced.len(), 2, "zero tolerance must merge nothing");
}
#[test]
fn distance_origin_plane_never_merged() {
let pf = test_pf();
let bounds = test_bounds();
let origin = plane(0.0, 0.0, 1.0, 0.0);
assert!(is_origin_plane(&origin), "fixture must be the origin plane");
let neighbour = plane(0.0, 0.0001, 1.0, 0.0);
let reduced = reduce_planes_distance(&[origin, neighbour], &pf, &bounds, 100.0, 64, 1, 0);
assert_eq!(reduced.len(), 2, "origin must not absorb its neighbour");
assert_eq!(reduced[0].gamma_0.to_bits(), origin.gamma_0.to_bits());
assert_eq!(reduced[0].gamma_v.to_bits(), origin.gamma_v.to_bits());
assert_eq!(reduced[0].gamma_q.to_bits(), origin.gamma_q.to_bits());
assert_eq!(reduced[0].gamma_s.to_bits(), origin.gamma_s.to_bits());
let reduced_rev =
reduce_planes_distance(&[neighbour, origin], &pf, &bounds, 100.0, 64, 1, 0);
assert_eq!(reduced_rev.len(), 2, "origin must not be absorbed");
assert_eq!(reduced_rev[1].gamma_0.to_bits(), origin.gamma_0.to_bits());
assert_eq!(reduced_rev[1].gamma_v.to_bits(), origin.gamma_v.to_bits());
assert_eq!(reduced_rev[1].gamma_q.to_bits(), origin.gamma_q.to_bits());
assert_eq!(reduced_rev[1].gamma_s.to_bits(), origin.gamma_s.to_bits());
}
}