use std::collections::HashMap;
use cobre_core::EntityId;
use cobre_io::extensions::TailraceCurveRow;
use super::error::FphaFittingError;
const CONTIG_EPS: f64 = 1e-6;
const C0_EPS: f64 = 1e-3;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct QuarticSegment {
pub outflow_min: f64,
pub outflow_max: f64,
pub coeffs: [f64; 5],
}
impl QuarticSegment {
#[inline]
pub(crate) fn eval(&self, q: f64) -> f64 {
let [a0, a1, a2, a3, a4] = self.coeffs;
(((a4 * q + a3) * q + a2) * q + a1) * q + a0
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TailraceSegments {
segments: Vec<QuarticSegment>,
}
impl TailraceSegments {
pub(crate) fn from_rows(
rows: &[TailraceCurveRow],
hydro_name: &str,
) -> Result<Self, FphaFittingError> {
if rows.is_empty() {
return Err(FphaFittingError::InsufficientPoints {
hydro_name: hydro_name.to_owned(),
count: 0,
});
}
let segments: Vec<QuarticSegment> = rows
.iter()
.map(|r| QuarticSegment {
outflow_min: r.outflow_min_m3s,
outflow_max: r.outflow_max_m3s,
coeffs: [
r.coefficient_0,
r.coefficient_1,
r.coefficient_2,
r.coefficient_3,
r.coefficient_4,
],
})
.collect();
for k in 1..segments.len() {
let prev = &segments[k - 1];
let curr = &segments[k];
if (curr.outflow_min - prev.outflow_max).abs() > CONTIG_EPS {
return Err(FphaFittingError::TailraceGap {
hydro_name: hydro_name.to_owned(),
outflow_max_prev: prev.outflow_max,
outflow_min_curr: curr.outflow_min,
});
}
let boundary = prev.outflow_max;
let h_left = prev.eval(boundary);
let h_right = curr.eval(boundary);
if (h_left - h_right).abs() > C0_EPS {
return Err(FphaFittingError::TailraceDiscontinuity {
hydro_name: hydro_name.to_owned(),
boundary,
h_left,
h_right,
});
}
}
Ok(Self { segments })
}
pub(crate) fn evaluate(&self, outflow_m3s: f64) -> f64 {
let n = self.segments.len();
let q_lo = self.segments[0].outflow_min;
let q_hi = self.segments[n - 1].outflow_max;
let q = outflow_m3s.clamp(q_lo, q_hi);
let idx = self.segments.partition_point(|s| s.outflow_max <= q);
let i = idx.min(n - 1);
self.segments[i].eval(q)
}
}
#[derive(Debug, Clone)]
pub(crate) struct TailraceFamily {
pub downstream_reference_level_m: Option<f64>,
pub segments: TailraceSegments,
}
#[derive(Debug, Clone)]
pub(crate) struct TailraceFamilies {
families: Vec<TailraceFamily>,
}
impl TailraceFamilies {
pub(crate) fn from_rows(
rows: &[TailraceCurveRow],
hydro_name: &str,
) -> Result<Self, FphaFittingError> {
if rows.is_empty() {
return Err(FphaFittingError::InsufficientPoints {
hydro_name: hydro_name.to_owned(),
count: 0,
});
}
let mut families: Vec<(i32, TailraceFamily)> = Vec::new();
let mut group_start = 0_usize;
for k in 1..=rows.len() {
let at_boundary = k == rows.len() || rows[k].family_id != rows[group_start].family_id;
if at_boundary {
let group = &rows[group_start..k];
let family_id = group[0].family_id;
let downstream_reference_level_m = group[0].downstream_reference_level_m;
let segments = TailraceSegments::from_rows(group, hydro_name)?;
families.push((
family_id,
TailraceFamily {
downstream_reference_level_m,
segments,
},
));
group_start = k;
}
}
if families.len() > 1
&& families
.iter()
.any(|(_, f)| f.downstream_reference_level_m.is_none())
{
return Err(FphaFittingError::TailraceFamilyKeyMissing {
hydro_name: hydro_name.to_owned(),
family_count: families.len(),
});
}
families.sort_by(|(fa, a), (fb, b)| {
let la = a.downstream_reference_level_m.unwrap_or(f64::NEG_INFINITY);
let lb = b.downstream_reference_level_m.unwrap_or(f64::NEG_INFINITY);
la.total_cmp(&lb).then_with(|| fa.cmp(fb))
});
Ok(Self {
families: families.into_iter().map(|(_, f)| f).collect(),
})
}
pub(crate) fn evaluate(&self, outflow_m3s: f64, downstream_level_m: Option<f64>) -> f64 {
let n = self.families.len();
if n == 1 {
return self.families[0].segments.evaluate(outflow_m3s);
}
let Some(level) = downstream_level_m else {
return self.families[0].segments.evaluate(outflow_m3s);
};
let family_level = |i: usize| {
self.families[i]
.downstream_reference_level_m
.unwrap_or(f64::NEG_INFINITY)
};
let l_lo = family_level(0);
let l_hi = family_level(n - 1);
let l = level.clamp(l_lo, l_hi);
let upper = self
.families
.partition_point(|f| level_le(f.downstream_reference_level_m, l));
let hi = upper.min(n - 1).max(1);
let lo = hi - 1;
let h_lo = self.families[lo].segments.evaluate(outflow_m3s);
let h_hi = self.families[hi].segments.evaluate(outflow_m3s);
let level_lo = family_level(lo);
let level_hi = family_level(hi);
let span = level_hi - level_lo;
if span <= 0.0 {
h_lo
} else {
let t = (l - level_lo) / span;
h_lo + t * (h_hi - h_lo)
}
}
}
#[inline]
fn level_le(downstream_reference_level_m: Option<f64>, l: f64) -> bool {
downstream_reference_level_m.unwrap_or(f64::NEG_INFINITY) <= l
}
pub(crate) fn build_tailrace_families_map(
rows: &[TailraceCurveRow],
) -> Result<HashMap<EntityId, TailraceFamilies>, FphaFittingError> {
let mut map: HashMap<EntityId, TailraceFamilies> = HashMap::new();
let mut group_start = 0_usize;
for k in 1..=rows.len() {
let at_boundary = k == rows.len() || rows[k].hydro_id != rows[group_start].hydro_id;
if at_boundary {
let group = &rows[group_start..k];
let hydro_id = group[0].hydro_id;
let families = TailraceFamilies::from_rows(group, &format!("id={}", hydro_id.0))?;
map.insert(hydro_id, families);
group_start = k;
}
}
Ok(map)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::similar_names
)]
mod tests {
use cobre_core::EntityId;
use cobre_io::extensions::TailraceCurveRow;
use super::super::error::FphaFittingError;
use super::{TailraceFamilies, TailraceSegments, build_tailrace_families_map};
fn row(
segment_id: i32,
outflow_min: f64,
outflow_max: f64,
coeffs: [f64; 5],
) -> TailraceCurveRow {
TailraceCurveRow {
hydro_id: EntityId::from(1),
family_id: 1,
downstream_reference_level_m: None,
segment_id,
outflow_min_m3s: outflow_min,
outflow_max_m3s: outflow_max,
coefficient_0: coeffs[0],
coefficient_1: coeffs[1],
coefficient_2: coeffs[2],
coefficient_3: coeffs[3],
coefficient_4: coeffs[4],
}
}
#[test]
fn single_segment_linear_eval_matches_hand_value() {
let rows = vec![row(1, 0.0, 1000.0, [5.0, 0.001, 0.0, 0.0, 0.0])];
let seg = TailraceSegments::from_rows(&rows, "Plant").unwrap();
assert!((seg.evaluate(400.0) - 5.4).abs() < 1e-9);
}
#[test]
fn two_contiguous_c0_matching_segments_ok_and_boundary_agrees() {
let b = 408.649;
let left_at_b = 1.0 + 0.01 * b;
let rows = vec![
row(1, 0.0, b, [1.0, 0.01, 0.0, 0.0, 0.0]),
row(2, b, 1000.0, [left_at_b, 0.0, 0.0, 0.0, 0.0]),
];
let seg = TailraceSegments::from_rows(&rows, "Plant").unwrap();
let at_boundary = seg.evaluate(b);
let just_below = seg.evaluate(b - 1e-7);
let just_above = seg.evaluate(b + 1e-7);
assert!((at_boundary - left_at_b).abs() < 1e-9);
assert!((just_below - left_at_b).abs() < 1e-6);
assert!((just_above - left_at_b).abs() < 1e-9);
}
#[test]
fn gap_between_segments_is_tailrace_gap() {
let rows = vec![
row(1, 0.0, 408.6, [1.0, 0.0, 0.0, 0.0, 0.0]),
row(2, 410.0, 1000.0, [1.0, 0.0, 0.0, 0.0, 0.0]),
];
let err = TailraceSegments::from_rows(&rows, "Plant").unwrap_err();
match err {
FphaFittingError::TailraceGap {
outflow_max_prev,
outflow_min_curr,
..
} => {
assert_eq!(outflow_max_prev, 408.6);
assert_eq!(outflow_min_curr, 410.0);
}
other => panic!("expected TailraceGap, got {other:?}"),
}
}
#[test]
fn overlap_between_segments_is_tailrace_gap() {
let rows = vec![
row(1, 0.0, 408.6, [1.0, 0.0, 0.0, 0.0, 0.0]),
row(2, 400.0, 1000.0, [1.0, 0.0, 0.0, 0.0, 0.0]),
];
let err = TailraceSegments::from_rows(&rows, "Plant").unwrap_err();
match err {
FphaFittingError::TailraceGap {
outflow_max_prev,
outflow_min_curr,
..
} => {
assert_eq!(outflow_max_prev, 408.6);
assert_eq!(outflow_min_curr, 400.0);
}
other => panic!("expected TailraceGap (overlap), got {other:?}"),
}
}
#[test]
fn c0_break_is_tailrace_discontinuity() {
let b = 408.6;
let rows = vec![
row(1, 0.0, b, [1.0, 0.0, 0.0, 0.0, 0.0]),
row(2, b, 1000.0, [1.5, 0.0, 0.0, 0.0, 0.0]),
];
let err = TailraceSegments::from_rows(&rows, "Plant").unwrap_err();
match err {
FphaFittingError::TailraceDiscontinuity {
boundary,
h_left,
h_right,
..
} => {
assert_eq!(boundary, b);
assert!((h_left - 1.0).abs() < 1e-12);
assert!((h_right - 1.5).abs() < 1e-12);
}
other => panic!("expected TailraceDiscontinuity, got {other:?}"),
}
}
#[test]
fn clamp_below_and_above_equals_edge_eval() {
let rows = vec![row(1, 0.0, 1000.0, [5.0, 0.001, 0.0, 0.0, 0.0])];
let seg = TailraceSegments::from_rows(&rows, "Plant").unwrap();
assert_eq!(seg.evaluate(-50.0), seg.evaluate(0.0));
assert_eq!(seg.evaluate(5000.0), seg.evaluate(1000.0));
}
#[test]
fn genuine_quartic_with_negative_coeffs_matches_hand_horner() {
let coeffs = [320.0, 1.0e-3, -3.1e-7, -2.0e-11, 5.0e-15];
let rows = vec![row(1, 0.0, 1500.0, coeffs)];
let seg = TailraceSegments::from_rows(&rows, "Plant").unwrap();
let q = 900.0;
let [a0, a1, a2, a3, a4] = coeffs;
let hand = (((a4 * q + a3) * q + a2) * q + a1) * q + a0;
assert!((seg.evaluate(q) - hand).abs() < 1e-9);
}
#[test]
fn evaluate_is_deterministic_to_bits() {
let rows = vec![row(1, 0.0, 1500.0, [320.0, 1.0e-3, -3.1e-7, 0.0, 0.0])];
let seg_a = TailraceSegments::from_rows(&rows, "Plant").unwrap();
let seg_b = TailraceSegments::from_rows(&rows.clone(), "Plant").unwrap();
let q = 723.456;
assert_eq!(seg_a.evaluate(q).to_bits(), seg_a.evaluate(q).to_bits());
assert_eq!(seg_a.evaluate(q).to_bits(), seg_b.evaluate(q).to_bits());
}
fn family_row(
hydro_id: i32,
family_id: i32,
downstream_reference_level_m: Option<f64>,
h: f64,
) -> TailraceCurveRow {
TailraceCurveRow {
hydro_id: EntityId::from(hydro_id),
family_id,
downstream_reference_level_m,
segment_id: 1,
outflow_min_m3s: 0.0,
outflow_max_m3s: 1000.0,
coefficient_0: h,
coefficient_1: 0.0,
coefficient_2: 0.0,
coefficient_3: 0.0,
coefficient_4: 0.0,
}
}
#[test]
fn single_family_ignores_downstream_level() {
let rows = vec![family_row(1, 1, None, 7.0)];
let fams = TailraceFamilies::from_rows(&rows, "Plant").unwrap();
assert!((fams.evaluate(400.0, Some(900.0)) - 7.0).abs() < 1e-9);
assert!((fams.evaluate(400.0, None) - 7.0).abs() < 1e-9);
assert_eq!(
fams.evaluate(400.0, Some(100.0)).to_bits(),
fams.evaluate(400.0, Some(900.0)).to_bits()
);
}
#[test]
fn two_family_mid_level_interpolates() {
let rows = vec![
family_row(1, 1, Some(880.0), 10.0),
family_row(1, 2, Some(890.0), 20.0),
];
let fams = TailraceFamilies::from_rows(&rows, "Plant").unwrap();
assert!((fams.evaluate(400.0, Some(885.0)) - 15.0).abs() < 1e-9);
}
#[test]
fn level_below_and_above_range_clamp_to_nearest_family() {
let rows = vec![
family_row(1, 1, Some(880.0), 10.0),
family_row(1, 2, Some(890.0), 20.0),
];
let fams = TailraceFamilies::from_rows(&rows, "Plant").unwrap();
assert!((fams.evaluate(400.0, Some(870.0)) - 10.0).abs() < 1e-9);
assert!((fams.evaluate(400.0, Some(900.0)) - 20.0).abs() < 1e-9);
}
#[test]
fn none_level_multi_family_uses_lowest_family_level() {
let rows = vec![
family_row(1, 1, Some(880.0), 10.0),
family_row(1, 2, Some(890.0), 20.0),
];
let fams = TailraceFamilies::from_rows(&rows, "Plant").unwrap();
assert!((fams.evaluate(400.0, None) - 10.0).abs() < 1e-9);
}
#[test]
fn keyless_multi_family_is_family_key_missing() {
let rows = vec![
family_row(1, 1, Some(880.0), 10.0),
family_row(1, 2, None, 20.0),
];
let err = TailraceFamilies::from_rows(&rows, "Plant").unwrap_err();
match err {
FphaFittingError::TailraceFamilyKeyMissing { family_count, .. } => {
assert_eq!(family_count, 2);
}
other => panic!("expected TailraceFamilyKeyMissing, got {other:?}"),
}
}
#[test]
fn evaluate_is_deterministic_across_input_family_orderings() {
let forward = vec![
family_row(1, 1, Some(880.0), 10.0),
family_row(1, 2, Some(890.0), 20.0),
];
let reversed = vec![
family_row(1, 2, Some(890.0), 20.0),
family_row(1, 1, Some(880.0), 10.0),
];
let fams_fwd = TailraceFamilies::from_rows(&forward, "Plant").unwrap();
let fams_rev = TailraceFamilies::from_rows(&reversed, "Plant").unwrap();
let l = Some(883.25);
assert_eq!(
fams_fwd.evaluate(412.0, l).to_bits(),
fams_rev.evaluate(412.0, l).to_bits()
);
}
#[test]
fn family_id_order_inverted_from_level_order_still_brackets_by_level() {
let rows = vec![
family_row(1, 1, Some(890.0), 20.0),
family_row(1, 2, Some(880.0), 10.0),
];
let fams = TailraceFamilies::from_rows(&rows, "Plant").unwrap();
assert!((fams.evaluate(400.0, Some(885.0)) - 15.0).abs() < 1e-9);
assert!((fams.evaluate(400.0, Some(870.0)) - 10.0).abs() < 1e-9);
assert!((fams.evaluate(400.0, Some(900.0)) - 20.0).abs() < 1e-9);
}
#[test]
fn build_map_groups_by_hydro_id() {
let rows = vec![
family_row(1, 1, Some(880.0), 10.0),
family_row(1, 2, Some(890.0), 20.0),
family_row(2, 1, None, 5.0),
];
let map = build_tailrace_families_map(&rows).unwrap();
assert_eq!(map.len(), 2);
let p1 = map.get(&EntityId::from(1)).unwrap();
assert!((p1.evaluate(400.0, Some(885.0)) - 15.0).abs() < 1e-9);
let p2 = map.get(&EntityId::from(2)).unwrap();
assert!((p2.evaluate(400.0, Some(123.0)) - 5.0).abs() < 1e-9);
}
}