use cobre_core::{EfficiencyModel, HydraulicLossesModel, Hydro, TailraceModel};
use cobre_io::extensions::{FphaConfig, HydroGeometryRow};
use crate::hydro_models::FphaPlane;
#[derive(Debug)]
pub(crate) enum FphaFittingError {
InsufficientPoints {
hydro_name: String,
count: usize,
},
NonMonotonicVolume {
hydro_name: String,
index: usize,
v_prev: f64,
v_curr: f64,
},
NonMonotonicHeight {
hydro_name: String,
index: usize,
h_prev: f64,
h_curr: f64,
},
ConflictingFittingWindow {
hydro_name: String,
detail: String,
},
EmptyFittingWindow {
hydro_name: String,
v_min: f64,
v_max: f64,
},
InsufficientDiscretization {
hydro_name: String,
dimension: String,
value: usize,
},
InvalidKappa {
hydro_name: String,
kappa: f64,
},
NoHyperplanesProduced {
hydro_name: String,
},
InvalidCoefficient {
hydro_name: String,
plane_index: usize,
detail: String,
},
}
impl std::fmt::Display for FphaFittingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientPoints { hydro_name, count } => write!(
f,
"hydro '{hydro_name}': VHA curve has {count} point(s); \
at least 2 are required for interpolation"
),
Self::NonMonotonicVolume {
hydro_name,
index,
v_prev,
v_curr,
} => write!(
f,
"hydro '{hydro_name}': volume is not strictly increasing at index {index}: \
v[{index}]={v_curr} is not greater than v[{}]={v_prev}",
index - 1
),
Self::NonMonotonicHeight {
hydro_name,
index,
h_prev,
h_curr,
} => write!(
f,
"hydro '{hydro_name}': height decreases at index {index}: \
h[{index}]={h_curr} < h[{}]={h_prev}",
index - 1
),
Self::ConflictingFittingWindow { hydro_name, detail } => write!(
f,
"hydro '{hydro_name}': conflicting fitting window configuration: {detail}"
),
Self::EmptyFittingWindow {
hydro_name,
v_min,
v_max,
} => write!(
f,
"hydro '{hydro_name}': fitting window is empty after resolution: \
v_min={v_min} >= v_max={v_max}"
),
Self::InsufficientDiscretization {
hydro_name,
dimension,
value,
} => write!(
f,
"hydro '{hydro_name}': discretization count for '{dimension}' is {value}, \
which is below the minimum required"
),
Self::InvalidKappa { hydro_name, kappa } => write!(
f,
"hydro '{hydro_name}': computed kappa {kappa} is outside the valid range (0, 1]; \
kappa must be strictly positive and at most 1.0"
),
Self::NoHyperplanesProduced { hydro_name } => write!(
f,
"hydro '{hydro_name}': fitting pipeline produced zero valid hyperplanes; \
check that net head is positive over the fitting grid"
),
Self::InvalidCoefficient {
hydro_name,
plane_index,
detail,
} => write!(
f,
"hydro '{hydro_name}': hyperplane {plane_index} has an invalid coefficient: \
{detail}"
),
}
}
}
impl std::error::Error for FphaFittingError {}
#[derive(Debug)]
pub(crate) struct FittingBounds {
pub v_min: f64,
pub v_max: f64,
pub n_volume_points: usize,
pub n_flow_points: usize,
pub n_spillage_points: usize,
pub max_planes_per_hydro: usize,
}
pub(crate) fn resolve_fitting_bounds(
config: &FphaConfig,
hydro: &Hydro,
forebay: &ForebayTable,
) -> Result<FittingBounds, FphaFittingError> {
let hydro_name = &hydro.name;
let (v_min, v_max) = match &config.fitting_window {
None => (forebay.v_min(), forebay.v_max()),
Some(fw) => {
if fw.volume_min_hm3.is_some() && fw.volume_min_percentile.is_some() {
return Err(FphaFittingError::ConflictingFittingWindow {
hydro_name: hydro_name.clone(),
detail: "volume_min_hm3 and volume_min_percentile cannot both be set; \
use absolute bounds OR percentiles, not both for the same bound"
.to_owned(),
});
}
if fw.volume_max_hm3.is_some() && fw.volume_max_percentile.is_some() {
return Err(FphaFittingError::ConflictingFittingWindow {
hydro_name: hydro_name.clone(),
detail: "volume_max_hm3 and volume_max_percentile cannot both be set; \
use absolute bounds OR percentiles, not both for the same bound"
.to_owned(),
});
}
let entity_v_min = hydro.min_storage_hm3;
let entity_v_max = hydro.max_storage_hm3;
let entity_range = entity_v_max - entity_v_min;
let v_min_raw = if let Some(abs) = fw.volume_min_hm3 {
abs
} else if let Some(pct) = fw.volume_min_percentile {
entity_v_min + pct * entity_range
} else {
forebay.v_min()
};
let v_max_raw = if let Some(abs) = fw.volume_max_hm3 {
abs
} else if let Some(pct) = fw.volume_max_percentile {
entity_v_min + pct * entity_range
} else {
forebay.v_max()
};
let v_min = v_min_raw.clamp(forebay.v_min(), forebay.v_max());
let v_max = v_max_raw.clamp(forebay.v_min(), forebay.v_max());
(v_min, v_max)
}
};
if v_min >= v_max {
return Err(FphaFittingError::EmptyFittingWindow {
hydro_name: hydro_name.clone(),
v_min,
v_max,
});
}
#[allow(clippy::cast_sign_loss)]
let n_volume_points = config.volume_discretization_points.unwrap_or(5) as usize;
#[allow(clippy::cast_sign_loss)]
let n_flow_points = config.turbine_discretization_points.unwrap_or(5) as usize;
#[allow(clippy::cast_sign_loss)]
let n_spillage_points = config.spillage_discretization_points.unwrap_or(5) as usize;
#[allow(clippy::cast_sign_loss)]
let max_planes = config.max_planes_per_hydro.unwrap_or(10) as usize;
if n_volume_points < 2 {
return Err(FphaFittingError::InsufficientDiscretization {
hydro_name: hydro_name.clone(),
dimension: "volume".to_owned(),
value: n_volume_points,
});
}
if n_flow_points < 2 {
return Err(FphaFittingError::InsufficientDiscretization {
hydro_name: hydro_name.clone(),
dimension: "turbine".to_owned(),
value: n_flow_points,
});
}
if n_spillage_points < 2 {
return Err(FphaFittingError::InsufficientDiscretization {
hydro_name: hydro_name.clone(),
dimension: "spillage".to_owned(),
value: n_spillage_points,
});
}
if max_planes < 1 {
return Err(FphaFittingError::InsufficientDiscretization {
hydro_name: hydro_name.clone(),
dimension: "max_planes_per_hydro".to_owned(),
value: max_planes,
});
}
Ok(FittingBounds {
v_min,
v_max,
n_volume_points,
n_flow_points,
n_spillage_points,
max_planes_per_hydro: max_planes,
})
}
#[derive(Debug, Clone)]
pub(crate) struct ForebayTable {
volumes: Vec<f64>,
heights: Vec<f64>,
}
impl ForebayTable {
pub(crate) fn new(
rows: &[HydroGeometryRow],
hydro_name: &str,
) -> Result<Self, FphaFittingError> {
if rows.len() < 2 {
return Err(FphaFittingError::InsufficientPoints {
hydro_name: hydro_name.to_owned(),
count: rows.len(),
});
}
let mut volumes = Vec::with_capacity(rows.len());
let mut heights = Vec::with_capacity(rows.len());
volumes.push(rows[0].volume_hm3);
heights.push(rows[0].height_m);
for i in 1..rows.len() {
let v_prev = rows[i - 1].volume_hm3;
let v_curr = rows[i].volume_hm3;
let h_prev = rows[i - 1].height_m;
let h_curr = rows[i].height_m;
if v_curr <= v_prev {
return Err(FphaFittingError::NonMonotonicVolume {
hydro_name: hydro_name.to_owned(),
index: i,
v_prev,
v_curr,
});
}
if h_curr < h_prev {
return Err(FphaFittingError::NonMonotonicHeight {
hydro_name: hydro_name.to_owned(),
index: i,
h_prev,
h_curr,
});
}
volumes.push(v_curr);
heights.push(h_curr);
}
Ok(Self { volumes, heights })
}
#[inline]
pub(crate) fn v_min(&self) -> f64 {
self.volumes[0]
}
#[inline]
pub(crate) fn v_max(&self) -> f64 {
self.volumes[self.volumes.len() - 1]
}
pub(crate) fn height(&self, volume_hm3: f64) -> f64 {
let v = volume_hm3.clamp(self.v_min(), self.v_max());
let (i, t) = self.locate(v);
self.heights[i] + t * (self.heights[i + 1] - self.heights[i])
}
pub(crate) fn height_derivative(&self, volume_hm3: f64) -> f64 {
let v = volume_hm3.clamp(self.v_min(), self.v_max());
let (i, _) = self.locate(v);
(self.heights[i + 1] - self.heights[i]) / (self.volumes[i + 1] - self.volumes[i])
}
fn locate(&self, v: f64) -> (usize, f64) {
let n = self.volumes.len();
let idx = self.volumes.partition_point(|&vk| vk <= v);
let i = idx.saturating_sub(1).min(n - 2);
let dv = self.volumes[i + 1] - self.volumes[i];
let t = (v - self.volumes[i]) / dv;
(i, t)
}
}
pub(crate) fn evaluate_tailrace(model: &TailraceModel, outflow_m3s: f64) -> f64 {
match model {
TailraceModel::Polynomial { coefficients } => {
coefficients
.iter()
.rev()
.fold(0.0_f64, |acc, c| acc * outflow_m3s + c)
}
TailraceModel::Piecewise { points } => {
let n = points.len();
if n == 0 {
return 0.0;
}
if n == 1 {
return points[0].height_m;
}
let q = outflow_m3s.clamp(points[0].outflow_m3s, points[n - 1].outflow_m3s);
let (i, t) = locate_tailrace(points, q);
points[i].height_m + t * (points[i + 1].height_m - points[i].height_m)
}
}
}
pub(crate) fn evaluate_tailrace_derivative(model: &TailraceModel, outflow_m3s: f64) -> f64 {
match model {
TailraceModel::Polynomial { coefficients } => {
let n = coefficients.len();
if n <= 1 {
return 0.0;
}
let mut acc = 0.0_f64;
for k in (1..n).rev() {
#[allow(clippy::cast_possible_truncation)]
let k_f64 = f64::from(k as u32);
acc = acc * outflow_m3s + k_f64 * coefficients[k];
}
acc
}
TailraceModel::Piecewise { points } => {
let n = points.len();
if n <= 1 {
return 0.0;
}
let q = outflow_m3s.clamp(points[0].outflow_m3s, points[n - 1].outflow_m3s);
let (i, _) = locate_tailrace(points, q);
(points[i + 1].height_m - points[i].height_m)
/ (points[i + 1].outflow_m3s - points[i].outflow_m3s)
}
}
}
pub(crate) fn evaluate_losses(
model: &HydraulicLossesModel,
gross_head: f64,
_turbined_m3s: f64,
) -> f64 {
match model {
HydraulicLossesModel::Factor { value } => value * gross_head,
HydraulicLossesModel::Constant { value_m } => *value_m,
}
}
#[allow(dead_code)]
pub(crate) fn evaluate_losses_factor(model: &HydraulicLossesModel) -> f64 {
match model {
HydraulicLossesModel::Factor { value } => *value,
HydraulicLossesModel::Constant { .. } => 0.0,
}
}
fn locate_tailrace(points: &[cobre_core::TailracePoint], q: f64) -> (usize, f64) {
let n = points.len();
let idx = points.partition_point(|p| p.outflow_m3s <= q);
let i = idx.saturating_sub(1).min(n - 2);
let dq = points[i + 1].outflow_m3s - points[i].outflow_m3s;
let t = (q - points[i].outflow_m3s) / dq;
(i, t)
}
const K: f64 = 9.81 / 1000.0;
#[derive(Debug, Clone)]
pub(crate) struct ProductionFunction {
forebay: ForebayTable,
tailrace: Option<TailraceModel>,
hydraulic_losses: Option<HydraulicLossesModel>,
efficiency: f64,
pub(crate) max_turbined_m3s: f64,
#[allow(dead_code)]
pub(crate) hydro_name: String,
}
impl ProductionFunction {
pub(crate) fn new(
forebay: ForebayTable,
tailrace: Option<&TailraceModel>,
hydraulic_losses: Option<&HydraulicLossesModel>,
efficiency: Option<&EfficiencyModel>,
max_turbined_m3s: f64,
hydro_name: String,
) -> Self {
let efficiency_value = match efficiency {
Some(EfficiencyModel::Constant { value }) => *value,
None => 1.0,
};
Self {
forebay,
tailrace: tailrace.cloned(),
hydraulic_losses: hydraulic_losses.copied(),
efficiency: efficiency_value,
max_turbined_m3s,
hydro_name,
}
}
pub(crate) fn net_head(&self, v: f64, q: f64, s: f64) -> f64 {
let h_fore = self.forebay.height(v);
let q_out = q + s;
let h_tail = self
.tailrace
.as_ref()
.map_or(0.0, |m| evaluate_tailrace(m, q_out));
let gross_head = h_fore - h_tail;
let h_loss = self
.hydraulic_losses
.as_ref()
.map_or(0.0, |m| evaluate_losses(m, gross_head, q));
let h_net = gross_head - h_loss;
h_net.max(0.0)
}
pub(crate) fn evaluate(&self, v: f64, q: f64, s: f64) -> f64 {
let h_net = self.net_head(v, q, s);
K * self.efficiency * q * h_net
}
#[allow(clippy::similar_names)] pub(crate) fn partial_derivatives(&self, v: f64, q: f64, s: f64) -> (f64, f64, f64) {
let h_fore = self.forebay.height(v);
let dh_fore_dv = self.forebay.height_derivative(v);
let q_out = q + s;
let h_tail = self
.tailrace
.as_ref()
.map_or(0.0, |m| evaluate_tailrace(m, q_out));
let dh_tail_dq_out = self
.tailrace
.as_ref()
.map_or(0.0, |m| evaluate_tailrace_derivative(m, q_out));
let ke = K * self.efficiency;
match self.hydraulic_losses {
Some(HydraulicLossesModel::Factor { value: k_loss }) => {
let one_minus_k = 1.0 - k_loss;
let h_net = (one_minus_k * (h_fore - h_tail)).max(0.0);
let d_phi_dv = ke * q * one_minus_k * dh_fore_dv;
let d_phi_dq = ke * (h_net - q * one_minus_k * dh_tail_dq_out);
let d_phi_ds = -ke * q * one_minus_k * dh_tail_dq_out;
(d_phi_dv, d_phi_dq, d_phi_ds)
}
Some(HydraulicLossesModel::Constant { .. }) | None => {
let h_net = self.net_head(v, q, s);
let d_phi_dv = ke * q * dh_fore_dv;
let d_phi_dq = ke * (h_net - q * dh_tail_dq_out);
let d_phi_ds = -ke * q * dh_tail_dq_out;
(d_phi_dv, d_phi_dq, d_phi_ds)
}
}
}
}
#[derive(Debug, Clone, Copy)]
#[allow(clippy::struct_field_names)]
pub(crate) struct RawHyperplane {
pub gamma_0: f64,
pub gamma_v: f64,
pub gamma_q: f64,
pub gamma_s: f64,
}
impl RawHyperplane {
pub(crate) fn evaluate(&self, v: f64, q: f64, s: f64) -> f64 {
self.gamma_0 + self.gamma_v * v + self.gamma_q * q + self.gamma_s * s
}
}
pub(crate) fn compute_tangent_plane(
pf: &ProductionFunction,
v: f64,
q: f64,
s: f64,
) -> Option<RawHyperplane> {
if q <= 0.0 {
return None;
}
let phi_val = pf.evaluate(v, q, s);
if phi_val <= 0.0 {
return None;
}
let (dv, dq, ds) = pf.partial_derivatives(v, q, s);
let gamma_0 = phi_val - dv * v - dq * q - ds * s;
Some(RawHyperplane {
gamma_0,
gamma_v: dv,
gamma_q: dq,
gamma_s: ds,
})
}
#[allow(clippy::struct_field_names)]
struct GridParams {
v_points: Vec<f64>,
q_points: Vec<f64>,
s_points: Vec<f64>,
}
fn build_grid(pf: &ProductionFunction, bounds: &FittingBounds) -> GridParams {
let n_v = bounds.n_volume_points;
let n_q = bounds.n_flow_points;
let n_s = bounds.n_spillage_points;
let v_range = bounds.v_max - bounds.v_min;
#[allow(clippy::cast_possible_truncation)]
let v_denom = f64::from((n_v - 1) as u32);
let q_min = (pf.max_turbined_m3s * 0.01_f64).max(1.0_f64);
let q_range = pf.max_turbined_m3s - q_min;
#[allow(clippy::cast_possible_truncation)]
let q_denom = f64::from((n_q - 1) as u32);
let s_max = pf.max_turbined_m3s * 0.5_f64;
#[allow(clippy::cast_possible_truncation)]
let s_denom = f64::from((n_s - 1) as u32);
#[allow(clippy::cast_possible_truncation)]
let v_points: Vec<f64> = (0..n_v)
.map(|i| bounds.v_min + f64::from(i as u32) * v_range / v_denom)
.collect();
#[allow(clippy::cast_possible_truncation)]
let q_points: Vec<f64> = (0..n_q)
.map(|j| q_min + f64::from(j as u32) * q_range / q_denom)
.collect();
#[allow(clippy::cast_possible_truncation)]
let s_points: Vec<f64> = (0..n_s)
.map(|k| f64::from(k as u32) * s_max / s_denom)
.collect();
GridParams {
v_points,
q_points,
s_points,
}
}
pub(crate) fn sample_tangent_planes(
pf: &ProductionFunction,
bounds: &FittingBounds,
) -> Vec<RawHyperplane> {
let grid = build_grid(pf, bounds);
let n_v = grid.v_points.len();
let n_q = grid.q_points.len();
let n_s = grid.s_points.len();
let mut planes = Vec::with_capacity(n_v * n_q * n_s);
for &v in &grid.v_points {
for &q in &grid.q_points {
for &s in &grid.s_points {
if let Some(plane) = compute_tangent_plane(pf, v, q, s) {
planes.push(plane);
}
}
}
}
planes
}
pub(crate) fn eliminate_redundant(
planes: &[RawHyperplane],
pf: &ProductionFunction,
bounds: &FittingBounds,
) -> Vec<RawHyperplane> {
if planes.is_empty() {
return Vec::new();
}
let grid = build_grid(pf, bounds);
let mut active = vec![false; planes.len()];
for &v in &grid.v_points {
for &q in &grid.q_points {
for &s in &grid.s_points {
let max_val = planes
.iter()
.map(|p| p.evaluate(v, q, s))
.fold(f64::NEG_INFINITY, f64::max);
for (idx, plane) in planes.iter().enumerate() {
if max_val - plane.evaluate(v, q, s) <= 1e-8 {
active[idx] = true;
}
}
}
}
}
let active_planes: Vec<RawHyperplane> = planes
.iter()
.zip(active.iter())
.filter_map(|(p, &is_active)| if is_active { Some(*p) } else { None })
.collect();
let mut unique: Vec<RawHyperplane> = Vec::with_capacity(active_planes.len());
'outer: for candidate in &active_planes {
for existing in &unique {
if (candidate.gamma_0 - existing.gamma_0).abs() < 1e-8
&& (candidate.gamma_v - existing.gamma_v).abs() < 1e-8
&& (candidate.gamma_q - existing.gamma_q).abs() < 1e-8
&& (candidate.gamma_s - existing.gamma_s).abs() < 1e-8
{
continue 'outer;
}
}
unique.push(*candidate);
}
unique
}
#[allow(dead_code)]
pub(crate) fn compute_max_approximation_error(
planes: &[RawHyperplane],
pf: &ProductionFunction,
bounds: &FittingBounds,
) -> f64 {
compute_grid_errors(planes, pf, bounds)
.into_iter()
.fold(0.0_f64, f64::max)
}
fn compute_grid_errors(
planes: &[RawHyperplane],
pf: &ProductionFunction,
bounds: &FittingBounds,
) -> Vec<f64> {
let grid = build_grid(pf, bounds);
let n = grid.v_points.len() * grid.q_points.len() * grid.s_points.len();
let mut errors = Vec::with_capacity(n);
for &v in &grid.v_points {
for &q in &grid.q_points {
for &s in &grid.s_points {
let phi_val = pf.evaluate(v, q, s);
let envelope_val = if planes.is_empty() {
f64::NEG_INFINITY
} else {
planes
.iter()
.map(|p| p.evaluate(v, q, s))
.fold(f64::NEG_INFINITY, f64::max)
};
errors.push(envelope_val - phi_val);
}
}
}
errors
}
pub(crate) fn select_planes(
planes: &[RawHyperplane],
pf: &ProductionFunction,
bounds: &FittingBounds,
) -> Vec<RawHyperplane> {
if planes.len() <= bounds.max_planes_per_hydro {
return planes.to_vec();
}
let target = bounds.max_planes_per_hydro;
let mut current: Vec<RawHyperplane> = planes.to_vec();
let mut scratch: Vec<RawHyperplane> = Vec::with_capacity(current.len());
let envelope_tol = -1e-8_f64;
while current.len() > target {
let n = current.len();
let mut best_idx = 0_usize;
let mut best_is_valid = false;
let mut best_max_error = f64::INFINITY;
for remove_idx in 0..n {
scratch.clear();
scratch.extend(
current.iter().enumerate().filter_map(
|(i, &p)| {
if i == remove_idx { None } else { Some(p) }
},
),
);
let errors = compute_grid_errors(&scratch, pf, bounds);
let min_err = errors.iter().copied().fold(f64::INFINITY, f64::min);
let max_err = errors.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let is_valid = min_err >= envelope_tol;
let max_err_nonneg = max_err.max(0.0);
let prefer = if is_valid && !best_is_valid {
true
} else if is_valid == best_is_valid {
max_err_nonneg < best_max_error
} else {
false
};
if prefer {
best_is_valid = is_valid;
best_max_error = max_err_nonneg;
best_idx = remove_idx;
}
}
if !best_is_valid {
break;
}
current.swap_remove(best_idx);
}
current
}
pub(crate) fn compute_kappa(
planes: &[RawHyperplane],
pf: &ProductionFunction,
bounds: &FittingBounds,
) -> f64 {
if planes.is_empty() {
return 1.0;
}
let grid = build_grid(pf, bounds);
let mut min_ratio = f64::MAX;
let mut found_valid = false;
for &v in &grid.v_points {
for &q in &grid.q_points {
for &s in &grid.s_points {
let phi_val = pf.evaluate(v, q, s);
let max_plane = planes
.iter()
.map(|p| p.evaluate(v, q, s))
.fold(f64::NEG_INFINITY, f64::max);
if phi_val > 0.0 && max_plane > 0.0 {
min_ratio = min_ratio.min(phi_val / max_plane);
found_valid = true;
}
}
}
}
if found_valid { min_ratio } else { 1.0 }
}
pub(crate) fn validate_fitted_planes(
planes: &[RawHyperplane],
kappa: f64,
hydro_name: &str,
) -> Result<Option<f64>, FphaFittingError> {
if planes.is_empty() {
return Err(FphaFittingError::NoHyperplanesProduced {
hydro_name: hydro_name.to_owned(),
});
}
if kappa <= 0.0 || kappa > 1.0 {
return Err(FphaFittingError::InvalidKappa {
hydro_name: hydro_name.to_owned(),
kappa,
});
}
let low_kappa = if kappa < 0.95 { Some(kappa) } else { None };
for (idx, plane) in planes.iter().enumerate() {
if plane.gamma_v < -1e-10 {
return Err(FphaFittingError::InvalidCoefficient {
hydro_name: hydro_name.to_owned(),
plane_index: idx,
detail: format!(
"gamma_v={:.6e} must be >= 0 (more storage should increase production)",
plane.gamma_v
),
});
}
if plane.gamma_q < -1e-10 {
return Err(FphaFittingError::InvalidCoefficient {
hydro_name: hydro_name.to_owned(),
plane_index: idx,
detail: format!(
"gamma_q={:.6e} must be >= 0 (turbined flow should produce power)",
plane.gamma_q
),
});
}
if plane.gamma_s > 1e-10 {
return Err(FphaFittingError::InvalidCoefficient {
hydro_name: hydro_name.to_owned(),
plane_index: idx,
detail: format!(
"gamma_s={:.6e} must be <= 0 (spillage should not increase production)",
plane.gamma_s
),
});
}
}
Ok(low_kappa)
}
#[derive(Debug)]
pub(crate) struct FphaFitResult {
pub planes: Vec<FphaPlane>,
pub kappa: f64,
pub low_kappa_warning: Option<f64>,
}
pub(crate) fn fit_fpha_planes(
forebay_rows: &[HydroGeometryRow],
hydro: &Hydro,
config: &FphaConfig,
) -> Result<FphaFitResult, FphaFittingError> {
let forebay = ForebayTable::new(forebay_rows, &hydro.name)?;
let pf = ProductionFunction::new(
forebay.clone(),
hydro.tailrace.as_ref(),
hydro.hydraulic_losses.as_ref(),
hydro.efficiency.as_ref(),
hydro.max_turbined_m3s,
hydro.name.clone(),
);
let bounds = resolve_fitting_bounds(config, hydro, &forebay)?;
let sampled = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&sampled, &pf, &bounds);
let selected = select_planes(&non_redundant, &pf, &bounds);
let kappa = compute_kappa(&selected, &pf, &bounds);
let low_kappa_warning = validate_fitted_planes(&selected, kappa, &hydro.name)?;
let planes = selected
.iter()
.map(|raw| FphaPlane {
intercept: raw.gamma_0 * kappa,
gamma_v: raw.gamma_v,
gamma_q: raw.gamma_q,
gamma_s: raw.gamma_s,
})
.collect();
Ok(FphaFitResult {
planes,
kappa,
low_kappa_warning,
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::doc_markdown,
clippy::similar_names
)]
mod tests {
use cobre_core::{
EfficiencyModel, EntityId, HydraulicLossesModel, Hydro, HydroGenerationModel,
HydroPenalties, TailraceModel, TailracePoint,
};
use cobre_io::extensions::{FittingWindow, FphaConfig, HydroGeometryRow};
use super::{
FittingBounds, ForebayTable, FphaFittingError, ProductionFunction, RawHyperplane,
compute_kappa, compute_tangent_plane, eliminate_redundant, evaluate_losses,
evaluate_losses_factor, evaluate_tailrace, evaluate_tailrace_derivative, fit_fpha_planes,
resolve_fitting_bounds, sample_tangent_planes, validate_fitted_planes,
};
fn row(volume_hm3: f64, height_m: f64) -> HydroGeometryRow {
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3,
height_m,
area_km2: 0.0,
}
}
fn sobradinho_rows() -> Vec<HydroGeometryRow> {
vec![
row(0.0, 386.5),
row(2_000.0, 390.0),
row(10_000.0, 396.0),
row(24_500.0, 400.5),
row(34_116.0, 401.3),
]
}
#[test]
fn valid_five_point_curve_construction_succeeds() {
let table = ForebayTable::new(&sobradinho_rows(), "Sobradinho").unwrap();
assert_eq!(table.v_min(), 0.0);
assert_eq!(table.v_max(), 34_116.0);
}
#[test]
fn interpolation_at_midpoint_segment_0_to_2000() {
let table = ForebayTable::new(&sobradinho_rows(), "Sobradinho").unwrap();
let expected = 386.5 + (390.0 - 386.5) * 1000.0 / 2000.0;
assert!((table.height(1000.0) - expected).abs() < 1e-10);
}
#[test]
fn interpolation_at_breakpoints_returns_exact_values() {
let rows = sobradinho_rows();
let table = ForebayTable::new(&rows, "Sobradinho").unwrap();
for r in &rows {
assert!(
(table.height(r.volume_hm3) - r.height_m).abs() < 1e-10,
"breakpoint v={}: expected h={}, got h={}",
r.volume_hm3,
r.height_m,
table.height(r.volume_hm3)
);
}
}
#[test]
fn height_clamped_below_v_min() {
let table = ForebayTable::new(&sobradinho_rows(), "Sobradinho").unwrap();
let at_min = table.height(table.v_min());
let below = table.height(table.v_min() - 100.0);
assert!((at_min - below).abs() < 1e-10);
}
#[test]
fn height_clamped_above_v_max() {
let table = ForebayTable::new(&sobradinho_rows(), "Sobradinho").unwrap();
let at_max = table.height(table.v_max());
let above = table.height(table.v_max() + 999.0);
assert!((at_max - above).abs() < 1e-10);
}
#[test]
fn derivative_first_segment_correct() {
let table = ForebayTable::new(&sobradinho_rows(), "Sobradinho").unwrap();
let expected = (390.0 - 386.5) / 2_000.0;
assert!((table.height_derivative(1000.0) - expected).abs() < 1e-10);
}
#[test]
fn derivative_last_segment_and_at_v_max() {
let rows = sobradinho_rows();
let table = ForebayTable::new(&rows, "Sobradinho").unwrap();
let n = rows.len();
let expected = (rows[n - 1].height_m - rows[n - 2].height_m)
/ (rows[n - 1].volume_hm3 - rows[n - 2].volume_hm3);
let v_mid = f64::midpoint(rows[n - 2].volume_hm3, rows[n - 1].volume_hm3);
assert!(
(table.height_derivative(v_mid) - expected).abs() < 1e-10,
"derivative at last-segment midpoint"
);
assert!(
(table.height_derivative(table.v_max()) - expected).abs() < 1e-10,
"derivative at v_max"
);
}
#[test]
fn derivative_at_interior_breakpoint_uses_right_segment() {
let rows = sobradinho_rows();
let table = ForebayTable::new(&rows, "Sobradinho").unwrap();
let expected_right =
(rows[2].height_m - rows[1].height_m) / (rows[2].volume_hm3 - rows[1].volume_hm3);
assert!(
(table.height_derivative(rows[1].volume_hm3) - expected_right).abs() < 1e-10,
"expected right-segment slope at breakpoint index 1"
);
}
#[test]
fn derivative_clamped_below_v_min_returns_first_segment_slope() {
let rows = sobradinho_rows();
let table = ForebayTable::new(&rows, "Sobradinho").unwrap();
let first_slope =
(rows[1].height_m - rows[0].height_m) / (rows[1].volume_hm3 - rows[0].volume_hm3);
assert!((table.height_derivative(table.v_min() - 50.0) - first_slope).abs() < 1e-10);
}
#[test]
fn insufficient_points_zero_rows() {
let err = ForebayTable::new(&[], "Itaipu").unwrap_err();
match err {
FphaFittingError::InsufficientPoints { count, .. } => {
assert_eq!(count, 0);
}
other => panic!("expected InsufficientPoints, got: {other:?}"),
}
}
#[test]
fn insufficient_points_one_row() {
let err = ForebayTable::new(&[row(0.0, 386.5)], "Itaipu").unwrap_err();
match err {
FphaFittingError::InsufficientPoints { count, .. } => {
assert_eq!(count, 1);
}
other => panic!("expected InsufficientPoints, got: {other:?}"),
}
}
#[test]
fn non_monotonic_volume_duplicate() {
let rows = vec![row(0.0, 386.5), row(1000.0, 388.0), row(1000.0, 390.0)];
let err = ForebayTable::new(&rows, "Tucurui").unwrap_err();
match err {
FphaFittingError::NonMonotonicVolume {
index,
v_prev,
v_curr,
..
} => {
assert_eq!(index, 2);
assert_eq!(v_prev, 1000.0);
assert_eq!(v_curr, 1000.0);
}
other => panic!("expected NonMonotonicVolume, got: {other:?}"),
}
}
#[test]
fn non_monotonic_volume_decreasing() {
let rows = vec![row(0.0, 386.5), row(2000.0, 390.0), row(1500.0, 392.0)];
let err = ForebayTable::new(&rows, "Belo Monte").unwrap_err();
match err {
FphaFittingError::NonMonotonicVolume { index, .. } => {
assert_eq!(index, 2);
}
other => panic!("expected NonMonotonicVolume, got: {other:?}"),
}
}
#[test]
fn non_monotonic_height_decreasing() {
let rows = vec![row(0.0, 390.0), row(1000.0, 392.0), row(2000.0, 388.0)];
let err = ForebayTable::new(&rows, "Serra da Mesa").unwrap_err();
match err {
FphaFittingError::NonMonotonicHeight {
index,
h_prev,
h_curr,
..
} => {
assert_eq!(index, 2);
assert_eq!(h_prev, 392.0);
assert_eq!(h_curr, 388.0);
}
other => panic!("expected NonMonotonicHeight, got: {other:?}"),
}
}
#[test]
fn equal_consecutive_heights_accepted() {
let rows = vec![row(0.0, 386.5), row(1000.0, 386.5), row(2000.0, 390.0)];
let result = ForebayTable::new(&rows, "Furnas");
assert!(result.is_ok(), "plateau heights should be accepted");
}
#[test]
fn display_insufficient_points_contains_name_and_count() {
let err = ForebayTable::new(&[], "Anta").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Anta"), "should contain hydro name: {msg}");
assert!(msg.contains('0'), "should contain count: {msg}");
}
#[test]
fn display_non_monotonic_volume_contains_name_and_index() {
let rows = vec![row(0.0, 386.5), row(1000.0, 390.0), row(500.0, 392.0)];
let err = ForebayTable::new(&rows, "Cachoeira").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Cachoeira"),
"should contain hydro name: {msg}"
);
assert!(msg.contains('2'), "should contain index: {msg}");
}
#[test]
fn display_non_monotonic_height_contains_name_and_index() {
let rows = vec![row(0.0, 395.0), row(1000.0, 393.0)];
let err = ForebayTable::new(&rows, "Marimbondo").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Marimbondo"),
"should contain hydro name: {msg}"
);
assert!(msg.contains('1'), "should contain index: {msg}");
}
#[test]
fn fpha_fitting_error_implements_std_error() {
fn assert_error<E: std::error::Error>() {}
assert_error::<FphaFittingError>();
}
#[test]
fn tailrace_polynomial_constant_one_coefficient() {
let model = TailraceModel::Polynomial {
coefficients: vec![7.5],
};
assert!((evaluate_tailrace(&model, 0.0) - 7.5).abs() < 1e-10);
assert!((evaluate_tailrace(&model, 5000.0) - 7.5).abs() < 1e-10);
}
#[test]
fn tailrace_polynomial_linear_two_coefficients() {
let model = TailraceModel::Polynomial {
coefficients: vec![3.0, 0.0005],
};
assert!((evaluate_tailrace(&model, 2000.0) - 4.0).abs() < 1e-10);
}
#[test]
fn tailrace_polynomial_quadratic_acceptance_criterion() {
let model = TailraceModel::Polynomial {
coefficients: vec![5.0, 0.001, -1e-7],
};
let expected = 5.0 + 0.001 * 3000.0 + (-1e-7) * 3000.0_f64.powi(2);
assert!((evaluate_tailrace(&model, 3000.0) - expected).abs() < 1e-10);
}
#[test]
fn tailrace_polynomial_quartic_five_coefficients() {
let model = TailraceModel::Polynomial {
coefficients: vec![1.0, 2.0, 3.0, 4.0, 5.0],
};
assert!((evaluate_tailrace(&model, 1.0) - 15.0).abs() < 1e-10);
}
#[test]
fn tailrace_polynomial_derivative_constant_is_zero() {
let model = TailraceModel::Polynomial {
coefficients: vec![7.5],
};
assert!((evaluate_tailrace_derivative(&model, 1000.0)).abs() < 1e-10);
}
#[test]
fn tailrace_polynomial_derivative_linear() {
let model = TailraceModel::Polynomial {
coefficients: vec![3.0, 0.0005],
};
assert!((evaluate_tailrace_derivative(&model, 9999.0) - 0.0005).abs() < 1e-10);
}
#[test]
fn tailrace_polynomial_derivative_quadratic_acceptance_criterion() {
let model = TailraceModel::Polynomial {
coefficients: vec![5.0, 0.001, -1e-7],
};
let expected = 0.001 + 2.0 * (-1e-7) * 3000.0;
assert!((evaluate_tailrace_derivative(&model, 3000.0) - expected).abs() < 1e-10);
}
#[test]
fn tailrace_polynomial_derivative_quartic() {
let model = TailraceModel::Polynomial {
coefficients: vec![1.0, 2.0, 3.0, 4.0, 5.0],
};
assert!((evaluate_tailrace_derivative(&model, 1.0) - 40.0).abs() < 1e-10);
}
fn ac_piecewise() -> TailraceModel {
use cobre_core::TailracePoint;
TailraceModel::Piecewise {
points: vec![
TailracePoint {
outflow_m3s: 0.0,
height_m: 3.0,
},
TailracePoint {
outflow_m3s: 5000.0,
height_m: 4.5,
},
TailracePoint {
outflow_m3s: 15_000.0,
height_m: 6.2,
},
],
}
}
#[test]
fn tailrace_piecewise_midpoint_first_segment_acceptance_criterion() {
let model = ac_piecewise();
let expected = 3.0 + (4.5 - 3.0) * 2500.0 / 5000.0;
assert!((evaluate_tailrace(&model, 2500.0) - expected).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_at_breakpoints_exact() {
let model = ac_piecewise();
assert!((evaluate_tailrace(&model, 0.0) - 3.0).abs() < 1e-10);
assert!((evaluate_tailrace(&model, 5000.0) - 4.5).abs() < 1e-10);
assert!((evaluate_tailrace(&model, 15_000.0) - 6.2).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_clamp_below_range() {
let model = ac_piecewise();
assert!((evaluate_tailrace(&model, -500.0) - 3.0).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_clamp_above_range() {
let model = ac_piecewise();
assert!((evaluate_tailrace(&model, 99_999.0) - 6.2).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_derivative_first_segment_acceptance_criterion() {
let model = ac_piecewise();
let expected = (4.5 - 3.0) / 5000.0;
assert!((evaluate_tailrace_derivative(&model, 2500.0) - expected).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_derivative_second_segment() {
let model = ac_piecewise();
let expected = (6.2 - 4.5) / (15_000.0 - 5000.0);
assert!((evaluate_tailrace_derivative(&model, 10_000.0) - expected).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_derivative_at_q_max_returns_last_segment_slope() {
let model = ac_piecewise();
let expected = (6.2 - 4.5) / (15_000.0 - 5000.0);
assert!((evaluate_tailrace_derivative(&model, 15_000.0) - expected).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_derivative_clamp_above_returns_last_segment_slope() {
let model = ac_piecewise();
let expected = (6.2 - 4.5) / (15_000.0 - 5000.0);
assert!((evaluate_tailrace_derivative(&model, 99_999.0) - expected).abs() < 1e-10);
}
#[test]
fn tailrace_piecewise_derivative_clamp_below_returns_first_segment_slope() {
let model = ac_piecewise();
let expected = (4.5 - 3.0) / 5000.0;
assert!((evaluate_tailrace_derivative(&model, -1.0) - expected).abs() < 1e-10);
}
#[test]
fn losses_factor_acceptance_criterion() {
let model = HydraulicLossesModel::Factor { value: 0.03 };
assert!((evaluate_losses(&model, 100.0, 0.0) - 3.0).abs() < 1e-10);
}
#[test]
fn losses_factor_scales_with_gross_head() {
let model = HydraulicLossesModel::Factor { value: 0.05 };
assert!((evaluate_losses(&model, 200.0, 1000.0) - 10.0).abs() < 1e-10);
assert!((evaluate_losses(&model, 0.0, 5000.0) - 0.0).abs() < 1e-10);
}
#[test]
fn losses_factor_turbined_has_no_effect() {
let model = HydraulicLossesModel::Factor { value: 0.02 };
let r1 = evaluate_losses(&model, 80.0, 0.0);
let r2 = evaluate_losses(&model, 80.0, 99_999.0);
assert!((r1 - r2).abs() < 1e-10);
}
#[test]
fn losses_constant_acceptance_criterion() {
let model = HydraulicLossesModel::Constant { value_m: 2.5 };
assert!((evaluate_losses(&model, 0.0, 0.0) - 2.5).abs() < 1e-10);
assert!((evaluate_losses(&model, 999.0, 0.0) - 2.5).abs() < 1e-10);
}
#[test]
fn losses_constant_independent_of_all_inputs() {
let model = HydraulicLossesModel::Constant { value_m: 1.25 };
let r1 = evaluate_losses(&model, 50.0, 500.0);
let r2 = evaluate_losses(&model, 200.0, 8000.0);
assert!((r1 - 1.25).abs() < 1e-10);
assert!((r2 - 1.25).abs() < 1e-10);
}
#[test]
fn losses_factor_extraction_returns_factor() {
let model = HydraulicLossesModel::Factor { value: 0.04 };
assert!((evaluate_losses_factor(&model) - 0.04).abs() < 1e-10);
}
#[test]
fn losses_factor_extraction_constant_returns_zero() {
let model = HydraulicLossesModel::Constant { value_m: 5.0 };
assert!((evaluate_losses_factor(&model)).abs() < 1e-10);
}
#[test]
fn two_point_minimum_curve_works() {
let rows = vec![row(0.0, 380.0), row(1000.0, 400.0)];
let table = ForebayTable::new(&rows, "MinimalPlant").unwrap();
assert_eq!(table.v_min(), 0.0);
assert_eq!(table.v_max(), 1000.0);
assert!((table.height(500.0) - 390.0).abs() < 1e-10);
assert!((table.height_derivative(500.0) - 0.02).abs() < 1e-10);
}
#[test]
fn interpolation_second_segment_correct() {
let table = ForebayTable::new(&sobradinho_rows(), "Sobradinho").unwrap();
let expected = 390.0 + 0.5 * (396.0 - 390.0);
assert!((table.height(6_000.0) - expected).abs() < 1e-10);
}
fn flat_forebay_400m() -> ForebayTable {
ForebayTable::new(
&[
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 0.0,
height_m: 400.0,
area_km2: 0.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 20_000.0,
height_m: 400.0,
area_km2: 0.0,
},
],
"TestPlant",
)
.unwrap()
}
fn sloped_forebay() -> ForebayTable {
ForebayTable::new(
&[
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 0.0,
height_m: 380.0,
area_km2: 0.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 10_000.0,
height_m: 400.0,
area_km2: 0.0,
},
],
"TestPlant",
)
.unwrap()
}
fn linear_tailrace_5_5_at_3000() -> TailraceModel {
let slope = 5.5 / 3000.0;
TailraceModel::Polynomial {
coefficients: vec![0.0, slope],
}
}
fn piecewise_tailrace() -> TailraceModel {
TailraceModel::Piecewise {
points: vec![
TailracePoint {
outflow_m3s: 0.0,
height_m: 3.0,
},
TailracePoint {
outflow_m3s: 5000.0,
height_m: 4.5,
},
TailracePoint {
outflow_m3s: 15_000.0,
height_m: 6.2,
},
],
}
}
#[test]
fn net_head_no_tailrace_no_losses_equals_h_fore() {
let forebay = sloped_forebay();
let pf =
ProductionFunction::new(forebay, None, None, None, 12_600.0, "TestPlant".to_owned());
assert!((pf.net_head(10_000.0, 3000.0, 0.0) - 400.0).abs() < 1e-10);
}
#[test]
fn net_head_polynomial_tailrace_constant_losses_acceptance_criterion() {
let forebay = flat_forebay_400m();
let tailrace = linear_tailrace_5_5_at_3000();
let losses = HydraulicLossesModel::Constant { value_m: 2.0 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&EfficiencyModel::Constant { value: 0.92 }),
12_600.0,
"TestPlant".to_owned(),
);
let h_net = pf.net_head(10_000.0, 3000.0, 0.0);
let expected = 400.0 - 5.5 - 2.0; assert!(
(h_net - expected).abs() < 1e-6,
"h_net={h_net}, expected={expected}"
);
}
#[test]
fn net_head_piecewise_tailrace_factor_losses() {
let forebay = flat_forebay_400m();
let tailrace = piecewise_tailrace();
let losses = HydraulicLossesModel::Factor { value: 0.03 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
None,
12_600.0,
"TestPlant".to_owned(),
);
let h_tail = 3.0 + (4.5 - 3.0) * 2500.0 / 5000.0; let gross_head = 400.0 - h_tail; let expected = (1.0 - 0.03) * gross_head; let h_net = pf.net_head(0.0, 2500.0, 0.0);
assert!(
(h_net - expected).abs() < 1e-6,
"h_net={h_net}, expected={expected}"
);
}
#[test]
fn net_head_clamped_to_zero_when_losses_exceed_forebay() {
let forebay = flat_forebay_400m();
let losses = HydraulicLossesModel::Constant { value_m: 500.0 };
let pf = ProductionFunction::new(
forebay,
None,
Some(&losses),
None,
12_600.0,
"TestPlant".to_owned(),
);
let h_net = pf.net_head(10_000.0, 3000.0, 0.0);
assert!(h_net >= 0.0, "net head must be non-negative, got {h_net}");
assert!(
(h_net - 0.0).abs() < 1e-10,
"h_net should be exactly 0.0, got {h_net}"
);
}
#[test]
fn evaluate_acceptance_criterion() {
let forebay = flat_forebay_400m();
let tailrace = linear_tailrace_5_5_at_3000();
let losses = HydraulicLossesModel::Constant { value_m: 2.0 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&EfficiencyModel::Constant { value: 0.92 }),
12_600.0,
"TestPlant".to_owned(),
);
let phi = pf.evaluate(10_000.0, 3000.0, 0.0);
let expected = (9.81 * 0.92 * 3000.0 * 392.5) / 1000.0;
assert!(
(phi - expected).abs() < 1e-2,
"phi={phi}, expected={expected}"
);
}
#[test]
fn partial_derivatives_no_tailrace_ds_is_zero() {
let forebay = sloped_forebay();
let pf =
ProductionFunction::new(forebay, None, None, None, 12_600.0, "TestPlant".to_owned());
let (_, _, d_phi_ds) = pf.partial_derivatives(10_000.0, 3000.0, 0.0);
assert!(
d_phi_ds.abs() < 1e-10,
"d_phi_ds should be 0.0 with no tailrace, got {d_phi_ds}"
);
}
#[test]
fn partial_derivatives_no_tailrace_dv_is_positive() {
let forebay = sloped_forebay(); let pf =
ProductionFunction::new(forebay, None, None, None, 12_600.0, "TestPlant".to_owned());
let (d_phi_dv, _, _) = pf.partial_derivatives(10_000.0, 3000.0, 0.0);
assert!(d_phi_dv > 0.0, "d_phi_dv must be positive, got {d_phi_dv}");
}
#[test]
fn partial_derivatives_polynomial_tailrace_constant_losses() {
let forebay = sloped_forebay();
let tailrace = linear_tailrace_5_5_at_3000();
let losses = HydraulicLossesModel::Constant { value_m: 2.0 };
let efficiency = EfficiencyModel::Constant { value: 0.92 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&efficiency),
12_600.0,
"TestPlant".to_owned(),
);
let (d_phi_dv, d_phi_dq, d_phi_ds) = pf.partial_derivatives(10_000.0, 3000.0, 0.0);
let ke = 9.81e-3 * 0.92;
let dh_fore_dv = 2e-3_f64; let dh_tail_dq_out = 5.5 / 3000.0;
let h_net = 392.5_f64;
let expected_dv = ke * 3000.0 * dh_fore_dv;
let expected_dq = ke * (h_net - 3000.0 * dh_tail_dq_out);
let expected_ds = -ke * 3000.0 * dh_tail_dq_out;
assert!(
(d_phi_dv - expected_dv).abs() < 1e-10,
"d_phi_dv={d_phi_dv}, expected={expected_dv}"
);
assert!(
(d_phi_dq - expected_dq).abs() < 1e-10,
"d_phi_dq={d_phi_dq}, expected={expected_dq}"
);
assert!(
(d_phi_ds - expected_ds).abs() < 1e-10,
"d_phi_ds={d_phi_ds}, expected={expected_ds}"
);
}
#[test]
fn partial_derivatives_polynomial_tailrace_constant_losses_dv_positive() {
let forebay = sloped_forebay();
let tailrace = linear_tailrace_5_5_at_3000();
let losses = HydraulicLossesModel::Constant { value_m: 2.0 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
None,
12_600.0,
"TestPlant".to_owned(),
);
let (d_phi_dv, _, _) = pf.partial_derivatives(10_000.0, 3000.0, 0.0);
assert!(d_phi_dv > 0.0, "d_phi_dv must be positive, got {d_phi_dv}");
}
#[test]
fn partial_derivatives_polynomial_tailrace_ds_nonpositive() {
let forebay = sloped_forebay();
let tailrace = linear_tailrace_5_5_at_3000();
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
None,
None,
12_600.0,
"TestPlant".to_owned(),
);
let (_, _, d_phi_ds) = pf.partial_derivatives(10_000.0, 3000.0, 0.0);
assert!(
d_phi_ds <= 0.0,
"d_phi_ds must be <= 0 with tailrace, got {d_phi_ds}"
);
}
#[test]
fn partial_derivatives_factor_losses_dv_accounts_for_k_factor() {
let forebay = sloped_forebay();
let losses = HydraulicLossesModel::Factor { value: 0.03 };
let pf_factor = ProductionFunction::new(
forebay.clone(),
None,
Some(&losses),
None,
12_600.0,
"TestPlant".to_owned(),
);
let pf_noloss =
ProductionFunction::new(forebay, None, None, None, 12_600.0, "TestPlant".to_owned());
let (dv_factor, _, _) = pf_factor.partial_derivatives(10_000.0, 3000.0, 0.0);
let (dv_noloss, _, _) = pf_noloss.partial_derivatives(10_000.0, 3000.0, 0.0);
assert!(
dv_factor > 0.0,
"d_phi_dv must be positive, got {dv_factor}"
);
let expected_ratio = 0.97; let ratio = dv_factor / dv_noloss;
assert!(
(ratio - expected_ratio).abs() < 1e-10,
"ratio of d_phi_dv factor/noloss should be 0.97, got {ratio}"
);
}
#[test]
fn partial_derivatives_piecewise_tailrace_factor_losses() {
let forebay = sloped_forebay();
let tailrace = piecewise_tailrace();
let losses = HydraulicLossesModel::Factor { value: 0.03 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
None,
12_600.0,
"TestPlant".to_owned(),
);
let (d_phi_dv, d_phi_dq, d_phi_ds) = pf.partial_derivatives(0.0, 2000.0, 500.0);
let ke = 9.81e-3_f64;
let k = 0.03_f64;
let one_minus_k = 1.0 - k;
let dh_fore_dv = 2e-3_f64;
let dh_tail_dq_out = (4.5 - 3.0) / 5000.0; let h_fore = 380.0_f64; let h_tail = 3.0 + (4.5 - 3.0) * 2500.0 / 5000.0; let h_net = one_minus_k * (h_fore - h_tail); let q = 2000.0_f64;
let expected_dv = ke * q * one_minus_k * dh_fore_dv;
let expected_dq = ke * (h_net - q * one_minus_k * dh_tail_dq_out);
let expected_ds = -ke * q * one_minus_k * dh_tail_dq_out;
assert!(
(d_phi_dv - expected_dv).abs() < 1e-10,
"d_phi_dv={d_phi_dv}, expected={expected_dv}"
);
assert!(
(d_phi_dq - expected_dq).abs() < 1e-10,
"d_phi_dq={d_phi_dq}, expected={expected_dq}"
);
assert!(
(d_phi_ds - expected_ds).abs() < 1e-10,
"d_phi_ds={d_phi_ds}, expected={expected_ds}"
);
}
#[test]
fn partial_derivatives_piecewise_tailrace_ds_negative() {
let forebay = sloped_forebay();
let tailrace = piecewise_tailrace();
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
None,
None,
12_600.0,
"TestPlant".to_owned(),
);
let (_, _, d_phi_ds) = pf.partial_derivatives(10_000.0, 2000.0, 500.0);
assert!(
d_phi_ds < 0.0,
"d_phi_ds must be < 0 with piecewise tailrace, got {d_phi_ds}"
);
}
fn fd_derivative(f: impl Fn(f64) -> f64, x: f64, h: f64) -> f64 {
(f(x + h) - f(x - h)) / (2.0 * h)
}
#[test]
fn finite_difference_cross_check_dv() {
let forebay = sloped_forebay();
let tailrace = piecewise_tailrace();
let losses = HydraulicLossesModel::Constant { value_m: 2.0 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&EfficiencyModel::Constant { value: 0.92 }),
12_600.0,
"TestPlant".to_owned(),
);
let v = 5_000.0_f64;
let q = 2000.0_f64;
let s = 300.0_f64;
let h = 1e-3_f64;
let (d_phi_dv_analytical, _, _) = pf.partial_derivatives(v, q, s);
let d_phi_dv_fd = fd_derivative(|vi| pf.evaluate(vi, q, s), v, h);
let rel_err = (d_phi_dv_analytical - d_phi_dv_fd).abs() / d_phi_dv_fd.abs().max(1e-12);
assert!(
rel_err < 1e-4,
"FD check d_phi_dv: analytical={d_phi_dv_analytical}, fd={d_phi_dv_fd}, rel_err={rel_err}"
);
}
#[test]
fn finite_difference_cross_check_dq() {
let forebay = sloped_forebay();
let tailrace = piecewise_tailrace();
let losses = HydraulicLossesModel::Factor { value: 0.03 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&EfficiencyModel::Constant { value: 0.92 }),
12_600.0,
"TestPlant".to_owned(),
);
let v = 7_000.0_f64;
let q = 3000.0_f64;
let s = 500.0_f64;
let h = 1e-4_f64;
let (_, d_phi_dq_analytical, _) = pf.partial_derivatives(v, q, s);
let d_phi_dq_fd = fd_derivative(|qi| pf.evaluate(v, qi, s), q, h);
let rel_err = (d_phi_dq_analytical - d_phi_dq_fd).abs() / d_phi_dq_fd.abs().max(1e-12);
assert!(
rel_err < 1e-4,
"FD check d_phi_dq: analytical={d_phi_dq_analytical}, fd={d_phi_dq_fd}, rel_err={rel_err}"
);
}
#[test]
fn finite_difference_cross_check_ds() {
let forebay = sloped_forebay();
let tailrace = piecewise_tailrace();
let losses = HydraulicLossesModel::Constant { value_m: 1.5 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&EfficiencyModel::Constant { value: 0.90 }),
12_600.0,
"TestPlant".to_owned(),
);
let v = 8_000.0_f64;
let q = 2500.0_f64;
let s = 200.0_f64;
let h = 1e-4_f64;
let (_, _, d_phi_ds_analytical) = pf.partial_derivatives(v, q, s);
let d_phi_ds_fd = fd_derivative(|si| pf.evaluate(v, q, si), s, h);
let rel_err = (d_phi_ds_analytical - d_phi_ds_fd).abs() / d_phi_ds_fd.abs().max(1e-12);
assert!(
rel_err < 1e-4,
"FD check d_phi_ds: analytical={d_phi_ds_analytical}, fd={d_phi_ds_fd}, rel_err={rel_err}"
);
}
#[test]
fn finite_difference_cross_check_all_derivatives_factor_losses() {
let forebay = sloped_forebay();
let tailrace = linear_tailrace_5_5_at_3000();
let losses = HydraulicLossesModel::Factor { value: 0.05 };
let pf = ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&EfficiencyModel::Constant { value: 0.95 }),
12_600.0,
"TestPlant".to_owned(),
);
let v = 6_000.0_f64;
let q = 1500.0_f64;
let s = 100.0_f64;
let (dv, dq, ds) = pf.partial_derivatives(v, q, s);
let h_v = 1e-3_f64;
let h_qs = 1e-4_f64;
let dv_fd = fd_derivative(|vi| pf.evaluate(vi, q, s), v, h_v);
let dq_fd = fd_derivative(|qi| pf.evaluate(v, qi, s), q, h_qs);
let ds_fd = fd_derivative(|si| pf.evaluate(v, q, si), s, h_qs);
let rel_err_v = (dv - dv_fd).abs() / dv_fd.abs().max(1e-12);
let rel_err_q = (dq - dq_fd).abs() / dq_fd.abs().max(1e-12);
let rel_err_s = (ds - ds_fd).abs() / ds_fd.abs().max(1e-12);
assert!(
rel_err_v < 1e-4,
"dv: analytical={dv}, fd={dv_fd}, rel_err={rel_err_v}"
);
assert!(
rel_err_q < 1e-4,
"dq: analytical={dq}, fd={dq_fd}, rel_err={rel_err_q}"
);
assert!(
rel_err_s < 1e-4,
"ds: analytical={ds}, fd={ds_fd}, rel_err={rel_err_s}"
);
}
fn make_hydro(min_storage_hm3: f64, max_storage_hm3: f64) -> Hydro {
let zero_penalties = HydroPenalties {
spillage_cost: 0.0,
diversion_cost: 0.0,
fpha_turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
};
Hydro {
id: EntityId::from(1),
name: "TestHydro".to_owned(),
bus_id: EntityId::from(10),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3,
max_storage_hm3,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::Fpha,
min_turbined_m3s: 0.0,
max_turbined_m3s: 12_600.0,
min_generation_mw: 0.0,
max_generation_mw: 14_000.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: zero_penalties,
}
}
fn sobradinho_forebay() -> ForebayTable {
ForebayTable::new(&sobradinho_rows(), "Sobradinho").unwrap()
}
fn small_forebay() -> ForebayTable {
let small_rows = vec![row(100.0, 386.5), row(2_000.0, 390.0)];
ForebayTable::new(&small_rows, "SmallHydro").unwrap()
}
fn default_config() -> FphaConfig {
FphaConfig {
source: "computed".to_owned(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
}
}
#[test]
fn no_fitting_window_uses_forebay_defaults() {
let config = default_config();
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds: FittingBounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.v_min, 0.0);
assert_eq!(bounds.v_max, 34_116.0);
assert_eq!(bounds.n_volume_points, 5);
assert_eq!(bounds.n_flow_points, 5);
assert_eq!(bounds.n_spillage_points, 5);
assert_eq!(bounds.max_planes_per_hydro, 10);
}
#[test]
fn absolute_bounds_both_set() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(1_000.0),
volume_max_hm3: Some(30_000.0),
volume_min_percentile: None,
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.v_min, 1_000.0);
assert_eq!(bounds.v_max, 30_000.0);
}
#[test]
fn absolute_bounds_only_min() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(5_000.0),
volume_max_hm3: None,
volume_min_percentile: None,
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.v_min, 5_000.0);
assert_eq!(bounds.v_max, 34_116.0);
}
#[test]
fn absolute_bounds_only_max() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: None,
volume_max_hm3: Some(20_000.0),
volume_min_percentile: None,
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.v_min, 0.0);
assert_eq!(bounds.v_max, 20_000.0);
}
#[test]
fn percentile_bounds_both_set() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: None,
volume_max_hm3: None,
volume_min_percentile: Some(0.1),
volume_max_percentile: Some(0.9),
}),
..default_config()
};
let hydro = make_hydro(100.0, 2_000.0);
let forebay = small_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
let expected_min = 100.0 + 0.1 * 1_900.0; let expected_max = 100.0 + 0.9 * 1_900.0; assert!((bounds.v_min - expected_min).abs() < 1e-10);
assert!((bounds.v_max - expected_max).abs() < 1e-10);
}
#[test]
fn mixed_absolute_min_percentile_max() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(200.0),
volume_max_hm3: None,
volume_min_percentile: None,
volume_max_percentile: Some(0.9),
}),
..default_config()
};
let hydro = make_hydro(100.0, 2_000.0);
let forebay = small_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
let expected_max = 100.0 + 0.9 * 1_900.0; assert_eq!(bounds.v_min, 200.0);
assert!((bounds.v_max - expected_max).abs() < 1e-10);
}
#[test]
fn conflicting_min_bound_returns_error() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(500.0),
volume_max_hm3: None,
volume_min_percentile: Some(0.1),
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(100.0, 2_000.0);
let forebay = small_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(err, FphaFittingError::ConflictingFittingWindow { .. }),
"expected ConflictingFittingWindow, got: {err:?}"
);
}
#[test]
fn conflicting_max_bound_returns_error() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: None,
volume_max_hm3: Some(1_800.0),
volume_min_percentile: None,
volume_max_percentile: Some(0.9),
}),
..default_config()
};
let hydro = make_hydro(100.0, 2_000.0);
let forebay = small_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(err, FphaFittingError::ConflictingFittingWindow { .. }),
"expected ConflictingFittingWindow, got: {err:?}"
);
}
#[test]
fn inverted_absolute_bounds_returns_empty_window_error() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(1_500.0),
volume_max_hm3: Some(1_000.0),
volume_min_percentile: None,
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(err, FphaFittingError::EmptyFittingWindow { .. }),
"expected EmptyFittingWindow, got: {err:?}"
);
}
#[test]
fn equal_absolute_bounds_returns_empty_window_error() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(1_000.0),
volume_max_hm3: Some(1_000.0),
volume_min_percentile: None,
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(err, FphaFittingError::EmptyFittingWindow { .. }),
"expected EmptyFittingWindow, got: {err:?}"
);
}
#[test]
fn absolute_min_below_forebay_gets_clamped() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(-500.0),
volume_max_hm3: Some(20_000.0),
volume_min_percentile: None,
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.v_min, 0.0);
assert_eq!(bounds.v_max, 20_000.0);
}
#[test]
fn absolute_max_above_forebay_gets_clamped() {
let config = FphaConfig {
fitting_window: Some(FittingWindow {
volume_min_hm3: Some(1_000.0),
volume_max_hm3: Some(50_000.0),
volume_min_percentile: None,
volume_max_percentile: None,
}),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.v_min, 1_000.0);
assert_eq!(bounds.v_max, 34_116.0);
}
#[test]
fn discretization_all_none_defaults_to_five() {
let config = default_config();
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.n_volume_points, 5);
assert_eq!(bounds.n_flow_points, 5);
assert_eq!(bounds.n_spillage_points, 5);
}
#[test]
fn discretization_explicit_values_passed_through() {
let config = FphaConfig {
volume_discretization_points: Some(8),
turbine_discretization_points: Some(6),
spillage_discretization_points: Some(10),
max_planes_per_hydro: Some(20),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.n_volume_points, 8);
assert_eq!(bounds.n_flow_points, 6);
assert_eq!(bounds.n_spillage_points, 10);
assert_eq!(bounds.max_planes_per_hydro, 20);
}
#[test]
fn volume_discretization_one_returns_error() {
let config = FphaConfig {
volume_discretization_points: Some(1),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
match &err {
FphaFittingError::InsufficientDiscretization {
dimension, value, ..
} => {
assert_eq!(dimension, "volume");
assert_eq!(*value, 1);
}
other => panic!("expected InsufficientDiscretization, got: {other:?}"),
}
}
#[test]
fn volume_discretization_zero_returns_error() {
let config = FphaConfig {
volume_discretization_points: Some(0),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(
err,
FphaFittingError::InsufficientDiscretization { ref dimension, value: 0, .. }
if dimension == "volume"
),
"expected InsufficientDiscretization for volume=0, got: {err:?}"
);
}
#[test]
fn turbine_discretization_one_returns_error() {
let config = FphaConfig {
turbine_discretization_points: Some(1),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(
err,
FphaFittingError::InsufficientDiscretization { ref dimension, value: 1, .. }
if dimension == "turbine"
),
"expected InsufficientDiscretization for turbine=1, got: {err:?}"
);
}
#[test]
fn spillage_discretization_one_returns_error() {
let config = FphaConfig {
spillage_discretization_points: Some(1),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(
err,
FphaFittingError::InsufficientDiscretization { ref dimension, value: 1, .. }
if dimension == "spillage"
),
"expected InsufficientDiscretization for spillage=1, got: {err:?}"
);
}
#[test]
fn max_planes_per_hydro_none_defaults_to_ten() {
let config = default_config();
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.max_planes_per_hydro, 10);
}
#[test]
fn max_planes_per_hydro_explicit_value() {
let config = FphaConfig {
max_planes_per_hydro: Some(5),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let bounds = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap();
assert_eq!(bounds.max_planes_per_hydro, 5);
}
#[test]
fn max_planes_per_hydro_zero_returns_error() {
let config = FphaConfig {
max_planes_per_hydro: Some(0),
..default_config()
};
let hydro = make_hydro(0.0, 34_116.0);
let forebay = sobradinho_forebay();
let err = resolve_fitting_bounds(&config, &hydro, &forebay).unwrap_err();
assert!(
matches!(
err,
FphaFittingError::InsufficientDiscretization { ref dimension, value: 0, .. }
if dimension == "max_planes_per_hydro"
),
"expected InsufficientDiscretization for max_planes_per_hydro=0, got: {err:?}"
);
}
fn ac005_production_function() -> ProductionFunction {
let forebay = sloped_forebay();
let tailrace = linear_tailrace_5_5_at_3000();
let losses = HydraulicLossesModel::Constant { value_m: 2.0 };
let efficiency = EfficiencyModel::Constant { value: 0.92 };
ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&efficiency),
12_600.0,
"TestPlant".to_owned(),
)
}
#[test]
fn tangent_plane_at_known_operating_point_coefficients() {
let pf = ac005_production_function();
let (v0, q0, s0) = (10_000.0_f64, 3000.0_f64, 0.0_f64);
let plane = compute_tangent_plane(&pf, v0, q0, s0)
.expect("should return Some for valid operating point");
let ke = 9.81e-3_f64 * 0.92_f64;
let expected_phi = ke * 3000.0 * 392.5;
let expected_dv = ke * 3000.0 * 2e-3;
let expected_dq = ke * 387.0;
let expected_ds = -ke * 5.5;
let expected_gamma_0 =
expected_phi - expected_dv * v0 - expected_dq * q0 - expected_ds * s0;
assert!(
(plane.gamma_v - expected_dv).abs() < 1e-10,
"gamma_v={}, expected={}",
plane.gamma_v,
expected_dv
);
assert!(
(plane.gamma_q - expected_dq).abs() < 1e-10,
"gamma_q={}, expected={}",
plane.gamma_q,
expected_dq
);
assert!(
(plane.gamma_s - expected_ds).abs() < 1e-10,
"gamma_s={}, expected={}",
plane.gamma_s,
expected_ds
);
assert!(
(plane.gamma_0 - expected_gamma_0).abs() < 1e-6,
"gamma_0={}, expected={}",
plane.gamma_0,
expected_gamma_0
);
}
#[test]
fn tangent_plane_identity_at_operating_point() {
let pf = ac005_production_function();
let (v0, q0, s0) = (10_000.0_f64, 3000.0_f64, 0.0_f64);
let plane = compute_tangent_plane(&pf, v0, q0, s0)
.expect("should return Some for valid operating point");
let phi = pf.evaluate(v0, q0, s0);
let g_at_tangent = plane.evaluate(v0, q0, s0);
assert!(
(g_at_tangent - phi).abs() < 1e-10,
"tangent-point identity failed: plane.evaluate={g_at_tangent}, phi={phi}, diff={}",
(g_at_tangent - phi).abs()
);
}
#[test]
fn tangent_plane_identity_at_second_operating_point() {
let pf = ac005_production_function();
let (v0, q0, s0) = (5_000.0_f64, 1500.0_f64, 200.0_f64);
let plane = compute_tangent_plane(&pf, v0, q0, s0)
.expect("should return Some for valid operating point");
let phi = pf.evaluate(v0, q0, s0);
let g_at_tangent = plane.evaluate(v0, q0, s0);
assert!(
(g_at_tangent - phi).abs() < 1e-10,
"tangent-point identity failed: plane.evaluate={g_at_tangent}, phi={phi}, diff={}",
(g_at_tangent - phi).abs()
);
}
#[test]
fn tangent_plane_identity_with_spillage() {
let pf = ac005_production_function();
let (v0, q0, s0) = (8_000.0_f64, 2000.0_f64, 500.0_f64);
let plane = compute_tangent_plane(&pf, v0, q0, s0)
.expect("should return Some for valid operating point");
let phi = pf.evaluate(v0, q0, s0);
let g_at_tangent = plane.evaluate(v0, q0, s0);
assert!(
(g_at_tangent - phi).abs() < 1e-10,
"tangent-point identity failed: plane.evaluate={g_at_tangent}, phi={phi}, diff={}",
(g_at_tangent - phi).abs()
);
}
#[test]
fn compute_tangent_plane_zero_flow_returns_none() {
let pf = ac005_production_function();
let result = compute_tangent_plane(&pf, 10_000.0, 0.0, 0.0);
assert!(result.is_none(), "expected None for q=0, got {result:?}");
}
#[test]
fn compute_tangent_plane_negative_flow_returns_none() {
let pf = ac005_production_function();
let result = compute_tangent_plane(&pf, 10_000.0, -100.0, 0.0);
assert!(result.is_none(), "expected None for q<0, got {result:?}");
}
#[test]
fn compute_tangent_plane_zero_production_returns_none() {
let forebay = sloped_forebay(); let giant_tailrace = TailraceModel::Polynomial {
coefficients: vec![500.0], };
let pf = ProductionFunction::new(
forebay,
Some(&giant_tailrace),
None,
None,
12_600.0,
"TestPlant".to_owned(),
);
let result = compute_tangent_plane(&pf, 10_000.0, 3000.0, 0.0);
assert!(result.is_none(), "expected None for phi<=0, got {result:?}");
}
#[test]
fn raw_hyperplane_evaluate_linear_combination() {
let plane = RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.01,
gamma_q: 3.5,
gamma_s: -0.05,
};
let expected = 100.0 + 0.01 * 500.0 + 3.5 * 200.0 + (-0.05) * 50.0;
assert!(
(plane.evaluate(500.0, 200.0, 50.0) - expected).abs() < 1e-10,
"evaluate mismatch: got {}, expected {expected}",
plane.evaluate(500.0, 200.0, 50.0)
);
}
#[test]
fn gamma_v_positive_for_positive_net_head() {
let pf = ac005_production_function();
let plane = compute_tangent_plane(&pf, 10_000.0, 3000.0, 0.0).expect("should return Some");
assert!(
plane.gamma_v > 0.0,
"gamma_v must be > 0 for positive net head, got {}",
plane.gamma_v
);
}
#[test]
fn gamma_s_nonpositive_with_tailrace() {
let pf = ac005_production_function();
let plane = compute_tangent_plane(&pf, 10_000.0, 3000.0, 0.0).expect("should return Some");
assert!(
plane.gamma_s <= 0.0,
"gamma_s must be <= 0 with tailrace, got {}",
plane.gamma_s
);
}
#[test]
fn raw_hyperplane_implements_debug_clone_copy() {
let original = RawHyperplane {
gamma_0: 1.0,
gamma_v: 2.0,
gamma_q: 3.0,
gamma_s: 4.0,
};
let copy_a = original;
let copy_b = original;
let _debug_str = format!("{original:?}");
assert!((copy_a.gamma_0 - copy_b.gamma_0).abs() < 1e-15);
assert!((copy_a.gamma_v - copy_b.gamma_v).abs() < 1e-15);
assert!((copy_a.gamma_q - copy_b.gamma_q).abs() < 1e-15);
assert!((copy_a.gamma_s - copy_b.gamma_s).abs() < 1e-15);
}
fn sampling_production_function() -> ProductionFunction {
let forebay = sloped_forebay();
let tailrace = linear_tailrace_5_5_at_3000();
let losses = HydraulicLossesModel::Constant { value_m: 2.0 };
let efficiency = EfficiencyModel::Constant { value: 0.92 };
ProductionFunction::new(
forebay,
Some(&tailrace),
Some(&losses),
Some(&efficiency),
3000.0,
"SamplingPlant".to_owned(),
)
}
fn fitting_bounds_5x5x5() -> FittingBounds {
FittingBounds {
v_min: 0.0,
v_max: 10_000.0,
n_volume_points: 5,
n_flow_points: 5,
n_spillage_points: 5,
max_planes_per_hydro: 10,
}
}
#[test]
fn sample_tangent_planes_count_between_100_and_125_for_5x5x5() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
assert!(
(100..=125).contains(&planes.len()),
"expected 100..=125 planes, got {}",
planes.len()
);
}
#[test]
fn sample_tangent_planes_count_at_most_n_v_times_n_q_times_n_s() {
let pf = sampling_production_function();
let bounds = FittingBounds {
v_min: 0.0,
v_max: 10_000.0,
n_volume_points: 3,
n_flow_points: 2,
n_spillage_points: 2,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
assert!(
planes.len() <= 3 * 2 * 2,
"expected at most 12 planes, got {}",
planes.len()
);
}
#[test]
fn sample_tangent_planes_flow_grid_avoids_zero_q() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
for (idx, plane) in planes.iter().enumerate() {
assert!(
plane.gamma_q >= 0.0,
"plane {idx}: gamma_q={} should be >= 0",
plane.gamma_q
);
}
}
#[test]
fn sample_tangent_planes_spillage_grid_starts_at_zero() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
let any_negative_s = planes.iter().any(|p| p.gamma_s < -1e-12);
assert!(
any_negative_s,
"expected at least one plane with gamma_s < 0 (spillage dimension active)"
);
}
#[test]
fn eliminate_redundant_strictly_reduces_count_for_non_trivial_geometry() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
assert!(!planes.is_empty(), "sampling must produce planes");
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
assert!(
non_redundant.len() < planes.len(),
"expected strict reduction: {} -> {}",
planes.len(),
non_redundant.len()
);
}
#[test]
fn eliminate_redundant_envelope_upper_bounds_production_function() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
let n_v = bounds.n_volume_points;
let n_q = bounds.n_flow_points;
let n_s = bounds.n_spillage_points;
let v_range = bounds.v_max - bounds.v_min;
#[allow(clippy::cast_possible_truncation)]
let v_denom = f64::from((n_v - 1) as u32);
let q_min = (pf.max_turbined_m3s * 0.01_f64).max(1.0_f64);
let q_range = pf.max_turbined_m3s - q_min;
#[allow(clippy::cast_possible_truncation)]
let q_denom = f64::from((n_q - 1) as u32);
let s_max = pf.max_turbined_m3s * 0.5_f64;
#[allow(clippy::cast_possible_truncation)]
let s_denom = f64::from((n_s - 1) as u32);
for i in 0..n_v {
#[allow(clippy::cast_possible_truncation)]
let v = bounds.v_min + f64::from(i as u32) * v_range / v_denom;
for j in 0..n_q {
#[allow(clippy::cast_possible_truncation)]
let q = q_min + f64::from(j as u32) * q_range / q_denom;
for k in 0..n_s {
#[allow(clippy::cast_possible_truncation)]
let s = f64::from(k as u32) * s_max / s_denom;
let phi = pf.evaluate(v, q, s);
let max_plane = non_redundant
.iter()
.map(|p| p.evaluate(v, q, s))
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max_plane >= phi - 1e-8,
"envelope violated at (v={v}, q={q}, s={s}): \
max_plane={max_plane} < phi={phi}"
);
}
}
}
}
#[test]
fn eliminate_redundant_constant_head_produces_one_plane() {
let forebay = flat_forebay_400m();
let pf =
ProductionFunction::new(forebay, None, None, None, 1000.0, "ConstantHead".to_owned());
let bounds = FittingBounds {
v_min: 0.0,
v_max: 20_000.0,
n_volume_points: 5,
n_flow_points: 5,
n_spillage_points: 5,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
assert!(
!planes.is_empty(),
"constant-head should produce some planes"
);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
assert_eq!(
non_redundant.len(),
1,
"constant-head function is linear in q: expected 1 surviving plane, got {}",
non_redundant.len()
);
}
#[test]
fn eliminate_redundant_empty_input_returns_empty() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let result = eliminate_redundant(&[], &pf, &bounds);
assert!(result.is_empty(), "expected empty output for empty input");
}
#[test]
fn eliminate_redundant_spillage_planes_can_survive() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
let any_nonzero_s = non_redundant.iter().any(|p| p.gamma_s.abs() > 1e-12);
assert!(
any_nonzero_s,
"expected at least one surviving plane with non-zero gamma_s"
);
}
#[test]
fn eliminate_redundant_output_is_subset_of_input() {
let pf = sampling_production_function();
let bounds = FittingBounds {
v_min: 0.0,
v_max: 10_000.0,
n_volume_points: 2,
n_flow_points: 2,
n_spillage_points: 2,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
for surviving in &non_redundant {
let found = planes.iter().any(|p| {
(p.gamma_0 - surviving.gamma_0).abs() < 1e-15
&& (p.gamma_v - surviving.gamma_v).abs() < 1e-15
&& (p.gamma_q - surviving.gamma_q).abs() < 1e-15
&& (p.gamma_s - surviving.gamma_s).abs() < 1e-15
});
assert!(found, "surviving plane not found in input: {surviving:?}");
}
}
use super::{compute_max_approximation_error, select_planes};
fn non_redundant_planes_for_selection()
-> (Vec<RawHyperplane>, ProductionFunction, FittingBounds) {
let pf = sampling_production_function();
let bounds = FittingBounds {
v_min: 0.0,
v_max: 10_000.0,
n_volume_points: 7,
n_flow_points: 5,
n_spillage_points: 5,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
assert!(
planes.len() > bounds.max_planes_per_hydro,
"fixture sanity: need > {} planes for selection tests, got {}",
bounds.max_planes_per_hydro,
planes.len()
);
(planes, pf, bounds)
}
#[test]
fn select_planes_reduces_to_target_count() {
let (planes, pf, bounds) = non_redundant_planes_for_selection();
assert!(
planes.len() > bounds.max_planes_per_hydro,
"fixture must have > {} planes; got {}",
bounds.max_planes_per_hydro,
planes.len()
);
let selected = select_planes(&planes, &pf, &bounds);
assert_eq!(
selected.len(),
bounds.max_planes_per_hydro,
"expected exactly {} planes, got {}",
bounds.max_planes_per_hydro,
selected.len()
);
}
#[test]
fn select_planes_approximation_error_not_catastrophically_worse() {
let (planes, pf, bounds) = non_redundant_planes_for_selection();
let full_error = compute_max_approximation_error(&planes, &pf, &bounds);
let selected = select_planes(&planes, &pf, &bounds);
let selected_error = compute_max_approximation_error(&selected, &pf, &bounds);
let threshold = if full_error < 1e-12 {
1e-6
} else {
2.0 * full_error
};
assert!(
selected_error <= threshold,
"selected error {selected_error} > 2× full error {full_error}"
);
}
#[test]
fn select_planes_passthrough_when_input_is_small() {
let pf = sampling_production_function();
let bounds = FittingBounds {
v_min: 0.0,
v_max: 10_000.0,
n_volume_points: 2,
n_flow_points: 2,
n_spillage_points: 2,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
assert!(
non_redundant.len() <= bounds.max_planes_per_hydro,
"fixture should have <= 10 planes; got {}",
non_redundant.len()
);
let selected = select_planes(&non_redundant, &pf, &bounds);
assert_eq!(
selected.len(),
non_redundant.len(),
"passthrough: expected all {} planes, got {}",
non_redundant.len(),
selected.len()
);
for (i, (a, b)) in non_redundant.iter().zip(selected.iter()).enumerate() {
assert!(
(a.gamma_0 - b.gamma_0).abs() < 1e-15
&& (a.gamma_v - b.gamma_v).abs() < 1e-15
&& (a.gamma_q - b.gamma_q).abs() < 1e-15
&& (a.gamma_s - b.gamma_s).abs() < 1e-15,
"plane {i} differs in passthrough path"
);
}
}
#[test]
fn select_planes_preserves_envelope_property() {
let (planes, pf, bounds) = non_redundant_planes_for_selection();
let selected = select_planes(&planes, &pf, &bounds);
let n_v = bounds.n_volume_points;
let n_q = bounds.n_flow_points;
let n_s = bounds.n_spillage_points;
let v_range = bounds.v_max - bounds.v_min;
#[allow(clippy::cast_possible_truncation)]
let v_denom = f64::from((n_v - 1) as u32);
let q_min = (pf.max_turbined_m3s * 0.01_f64).max(1.0_f64);
let q_range = pf.max_turbined_m3s - q_min;
#[allow(clippy::cast_possible_truncation)]
let q_denom = f64::from((n_q - 1) as u32);
let s_max = pf.max_turbined_m3s * 0.5_f64;
#[allow(clippy::cast_possible_truncation)]
let s_denom = f64::from((n_s - 1) as u32);
for i in 0..n_v {
#[allow(clippy::cast_possible_truncation)]
let v = bounds.v_min + f64::from(i as u32) * v_range / v_denom;
for j in 0..n_q {
#[allow(clippy::cast_possible_truncation)]
let q = q_min + f64::from(j as u32) * q_range / q_denom;
for k in 0..n_s {
#[allow(clippy::cast_possible_truncation)]
let s = f64::from(k as u32) * s_max / s_denom;
let phi = pf.evaluate(v, q, s);
let max_plane = selected
.iter()
.map(|p| p.evaluate(v, q, s))
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max_plane >= phi - 1e-8,
"envelope violated after selection at (v={v}, q={q}, s={s}): \
max_plane={max_plane} < phi={phi}"
);
}
}
}
}
#[test]
fn select_planes_empty_input_returns_empty() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let result = select_planes(&[], &pf, &bounds);
assert!(result.is_empty(), "expected empty output for empty input");
}
#[test]
fn select_planes_single_plane_returns_unchanged() {
let pf = sampling_production_function();
let bounds = FittingBounds {
v_min: 0.0,
v_max: 10_000.0,
n_volume_points: 5,
n_flow_points: 5,
n_spillage_points: 5,
max_planes_per_hydro: 1,
};
let plane = RawHyperplane {
gamma_0: 50.0,
gamma_v: 0.001,
gamma_q: 3.0,
gamma_s: -0.01,
};
let result = select_planes(&[plane], &pf, &bounds);
assert_eq!(result.len(), 1, "expected 1 plane, got {}", result.len());
assert!(
(result[0].gamma_0 - plane.gamma_0).abs() < 1e-15,
"plane must be returned unchanged"
);
}
#[test]
fn compute_max_approximation_error_is_zero_for_linear_production_function() {
let forebay = flat_forebay_400m();
let pf =
ProductionFunction::new(forebay, None, None, None, 1000.0, "ConstantHead".to_owned());
let bounds = FittingBounds {
v_min: 0.0,
v_max: 20_000.0,
n_volume_points: 5,
n_flow_points: 5,
n_spillage_points: 5,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
assert_eq!(
non_redundant.len(),
1,
"constant-head should yield exactly 1 plane"
);
let err = compute_max_approximation_error(&non_redundant, &pf, &bounds);
assert!(
err < 1e-8,
"expected near-zero error for linear production function, got {err}"
);
}
#[test]
fn compute_max_approximation_error_empty_planes_returns_zero() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let err = compute_max_approximation_error(&[], &pf, &bounds);
assert!(
err.abs() < 1e-15,
"expected 0.0 for empty plane set, got {err}"
);
}
#[test]
fn compute_max_approximation_error_is_non_negative() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
let err = compute_max_approximation_error(&non_redundant, &pf, &bounds);
assert!(err >= 0.0, "error must be non-negative, got {err}");
}
#[test]
fn select_planes_output_is_subset_of_input() {
let (planes, pf, bounds) = non_redundant_planes_for_selection();
let selected = select_planes(&planes, &pf, &bounds);
for surviving in &selected {
let found = planes.iter().any(|p| {
(p.gamma_0 - surviving.gamma_0).abs() < 1e-15
&& (p.gamma_v - surviving.gamma_v).abs() < 1e-15
&& (p.gamma_q - surviving.gamma_q).abs() < 1e-15
&& (p.gamma_s - surviving.gamma_s).abs() < 1e-15
});
assert!(found, "selected plane not found in input: {surviving:?}");
}
}
#[test]
fn compute_kappa_in_valid_range_for_realistic_geometry() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
let selected = select_planes(&non_redundant, &pf, &bounds);
let kappa = compute_kappa(&selected, &pf, &bounds);
assert!(kappa > 0.0, "kappa must be strictly positive, got {kappa}");
assert!(kappa <= 1.0, "kappa must be <= 1.0, got {kappa}");
}
#[test]
fn compute_kappa_in_range_for_realistic_geometry() {
let forebay = flat_forebay_400m();
let pf = ProductionFunction::new(
forebay,
None,
None,
Some(&EfficiencyModel::Constant { value: 0.92 }),
3_000.0,
"FlatHeadPlant".to_owned(),
);
let bounds = FittingBounds {
v_min: 0.0,
v_max: 20_000.0,
n_volume_points: 5,
n_flow_points: 5,
n_spillage_points: 5,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
let selected = select_planes(&non_redundant, &pf, &bounds);
let kappa = compute_kappa(&selected, &pf, &bounds);
assert!(kappa > 0.0, "kappa must be strictly positive, got {kappa}");
assert!(kappa <= 1.0, "kappa must be <= 1.0, got {kappa}");
assert!(
kappa >= 0.95,
"kappa={kappa} < 0.95 for a constant-head (physically realistic) geometry"
);
}
#[test]
fn compute_kappa_is_one_for_linear_production_function() {
let forebay = flat_forebay_400m();
let pf =
ProductionFunction::new(forebay, None, None, None, 1000.0, "ConstantHead".to_owned());
let bounds = FittingBounds {
v_min: 0.0,
v_max: 20_000.0,
n_volume_points: 5,
n_flow_points: 5,
n_spillage_points: 5,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
assert_eq!(
non_redundant.len(),
1,
"expected 1 plane for linear function"
);
let kappa = compute_kappa(&non_redundant, &pf, &bounds);
assert!(
(kappa - 1.0).abs() < 1e-8,
"kappa must be 1.0 for linear production function, got {kappa}"
);
}
#[test]
fn compute_kappa_less_than_one_for_nonlinear_production_function() {
let pf = sampling_production_function();
let bounds = FittingBounds {
v_min: 0.0,
v_max: 10_000.0,
n_volume_points: 3,
n_flow_points: 3,
n_spillage_points: 3,
max_planes_per_hydro: 10,
};
let planes = sample_tangent_planes(&pf, &bounds);
let non_redundant = eliminate_redundant(&planes, &pf, &bounds);
let kappa = compute_kappa(&non_redundant, &pf, &bounds);
assert!(kappa > 0.0, "kappa must be positive, got {kappa}");
assert!(kappa <= 1.0, "kappa must be <= 1.0, got {kappa}");
}
#[test]
fn compute_kappa_empty_planes_returns_one() {
let pf = sampling_production_function();
let bounds = fitting_bounds_5x5x5();
let kappa = compute_kappa(&[], &pf, &bounds);
assert!(
(kappa - 1.0).abs() < 1e-15,
"expected kappa=1.0 for empty planes, got {kappa}"
);
}
#[test]
fn validate_fitted_planes_valid_input_returns_ok() {
let planes = vec![
RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 3.5,
gamma_s: -0.01,
},
RawHyperplane {
gamma_0: 200.0,
gamma_v: 0.002,
gamma_q: 3.8,
gamma_s: -0.02,
},
];
let result = validate_fitted_planes(&planes, 0.985, "TestHydro");
assert!(result.is_ok(), "expected Ok(()), got {result:?}");
}
#[test]
fn validate_fitted_planes_zero_kappa_returns_invalid_kappa() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 3.5,
gamma_s: -0.01,
}];
let err = validate_fitted_planes(&planes, 0.0, "TestHydro").unwrap_err();
assert!(
matches!(err, FphaFittingError::InvalidKappa { kappa, .. } if kappa == 0.0),
"expected InvalidKappa with kappa=0.0, got: {err:?}"
);
}
#[test]
fn validate_fitted_planes_kappa_above_one_returns_invalid_kappa() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 3.5,
gamma_s: -0.01,
}];
let err = validate_fitted_planes(&planes, 1.001, "TestHydro").unwrap_err();
assert!(
matches!(err, FphaFittingError::InvalidKappa { .. }),
"expected InvalidKappa for kappa=1.001, got: {err:?}"
);
}
#[test]
fn validate_fitted_planes_negative_kappa_returns_invalid_kappa() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 3.5,
gamma_s: -0.01,
}];
let err = validate_fitted_planes(&planes, -0.5, "TestHydro").unwrap_err();
assert!(
matches!(err, FphaFittingError::InvalidKappa { .. }),
"expected InvalidKappa for kappa=-0.5, got: {err:?}"
);
}
#[test]
fn validate_fitted_planes_empty_planes_returns_no_hyperplanes() {
let err = validate_fitted_planes(&[], 0.99, "TestHydro").unwrap_err();
assert!(
matches!(err, FphaFittingError::NoHyperplanesProduced { .. }),
"expected NoHyperplanesProduced, got: {err:?}"
);
}
#[test]
fn validate_fitted_planes_negative_gamma_v_returns_invalid_coefficient() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: -0.01, gamma_q: 3.5,
gamma_s: -0.01,
}];
let err = validate_fitted_planes(&planes, 0.98, "TestHydro").unwrap_err();
assert!(
matches!(
err,
FphaFittingError::InvalidCoefficient { plane_index: 0, ref detail, .. }
if detail.contains("gamma_v")
),
"expected InvalidCoefficient for gamma_v < 0, got: {err:?}"
);
}
#[test]
fn validate_fitted_planes_negative_gamma_q_returns_invalid_coefficient() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: -0.01, gamma_s: -0.01,
}];
let err = validate_fitted_planes(&planes, 0.98, "TestHydro").unwrap_err();
assert!(
matches!(
err,
FphaFittingError::InvalidCoefficient { plane_index: 0, ref detail, .. }
if detail.contains("gamma_q")
),
"expected InvalidCoefficient for gamma_q < 0, got: {err:?}"
);
}
#[test]
fn validate_fitted_planes_positive_gamma_s_returns_invalid_coefficient() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 3.5,
gamma_s: 0.01, }];
let err = validate_fitted_planes(&planes, 0.98, "TestHydro").unwrap_err();
assert!(
matches!(
err,
FphaFittingError::InvalidCoefficient { plane_index: 0, ref detail, .. }
if detail.contains("gamma_s")
),
"expected InvalidCoefficient for gamma_s > 0, got: {err:?}"
);
}
#[test]
fn validate_fitted_planes_near_zero_gamma_v_within_tolerance_passes() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: -1e-11, gamma_q: 3.5,
gamma_s: -0.01,
}];
let result = validate_fitted_planes(&planes, 0.99, "TestHydro");
assert!(
result.is_ok(),
"near-zero gamma_v within tolerance should pass: {result:?}"
);
}
#[test]
fn validate_fitted_planes_near_zero_gamma_s_within_tolerance_passes() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 3.5,
gamma_s: 1e-11, }];
let result = validate_fitted_planes(&planes, 0.99, "TestHydro");
assert!(
result.is_ok(),
"near-zero gamma_s within tolerance should pass: {result:?}"
);
}
#[test]
fn validate_fitted_planes_kappa_exactly_one_passes() {
let planes = vec![RawHyperplane {
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 3.5,
gamma_s: -0.01,
}];
let result = validate_fitted_planes(&planes, 1.0, "TestHydro");
assert!(result.is_ok(), "kappa=1.0 should pass: {result:?}");
}
fn make_sobradinho_hydro() -> Hydro {
let zero_penalties = HydroPenalties {
spillage_cost: 0.0,
diversion_cost: 0.0,
fpha_turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
};
Hydro {
id: EntityId::from(1),
name: "Sobradinho".to_owned(),
bus_id: EntityId::from(10),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 34_116.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::Fpha,
min_turbined_m3s: 0.0,
max_turbined_m3s: 3_000.0,
min_generation_mw: 0.0,
max_generation_mw: 1_000.0,
tailrace: Some(TailraceModel::Polynomial {
coefficients: vec![0.0, 0.001_f64],
}),
hydraulic_losses: Some(cobre_core::HydraulicLossesModel::Constant { value_m: 2.0 }),
efficiency: Some(EfficiencyModel::Constant { value: 0.92 }),
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: zero_penalties,
}
}
#[test]
fn fit_fpha_planes_sobradinho_style_end_to_end() {
let rows = sobradinho_rows();
let hydro = make_sobradinho_hydro();
let config = FphaConfig {
source: "computed".to_owned(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
};
let result =
fit_fpha_planes(&rows, &hydro, &config).expect("fit_fpha_planes should succeed");
let planes = &result.planes;
assert!(
(3..=10).contains(&planes.len()),
"expected 3–10 planes, got {}",
planes.len()
);
for (idx, plane) in planes.iter().enumerate() {
assert!(
plane.gamma_v >= -1e-10,
"plane {idx}: gamma_v={} must be >= 0",
plane.gamma_v
);
assert!(
plane.gamma_q >= -1e-10,
"plane {idx}: gamma_q={} must be >= 0",
plane.gamma_q
);
assert!(
plane.gamma_s <= 1e-10,
"plane {idx}: gamma_s={} must be <= 0",
plane.gamma_s
);
}
}
#[test]
fn fit_fpha_planes_intercepts_are_kappa_scaled() {
let rows = sobradinho_rows();
let hydro = make_sobradinho_hydro();
let config = FphaConfig {
source: "computed".to_owned(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
};
let result = fit_fpha_planes(&rows, &hydro, &config).expect("fit should succeed");
for (idx, plane) in result.planes.iter().enumerate() {
assert!(
plane.intercept.is_finite(),
"plane {idx}: intercept must be finite, got {}",
plane.intercept
);
}
}
#[test]
fn fit_fpha_planes_linear_function_produces_one_plane_with_kappa_one() {
let flat_rows = vec![
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 0.0,
height_m: 400.0,
area_km2: 0.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 20_000.0,
height_m: 400.0,
area_km2: 0.0,
},
];
let zero_penalties = HydroPenalties {
spillage_cost: 0.0,
diversion_cost: 0.0,
fpha_turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
};
let hydro = Hydro {
id: EntityId::from(1),
name: "FlatHydro".to_owned(),
bus_id: EntityId::from(10),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 20_000.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::Fpha,
min_turbined_m3s: 0.0,
max_turbined_m3s: 1_000.0,
min_generation_mw: 0.0,
max_generation_mw: 4_000.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: zero_penalties,
};
let config = FphaConfig {
source: "computed".to_owned(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
};
let result = fit_fpha_planes(&flat_rows, &hydro, &config).expect("fit should succeed");
assert_eq!(
result.planes.len(),
1,
"linear function must yield 1 plane, got {}",
result.planes.len()
);
}
#[test]
fn fit_fpha_planes_propagates_forebay_error_on_insufficient_rows() {
let rows = vec![HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 0.0,
height_m: 386.5,
area_km2: 0.0,
}];
let hydro = make_sobradinho_hydro();
let config = FphaConfig {
source: "computed".to_owned(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
};
let err = fit_fpha_planes(&rows, &hydro, &config).unwrap_err();
assert!(
matches!(err, FphaFittingError::InsufficientPoints { count: 1, .. }),
"expected InsufficientPoints with count=1, got: {err:?}"
);
}
#[test]
fn display_invalid_kappa_contains_name_and_value() {
let err = FphaFittingError::InvalidKappa {
hydro_name: "Itaipu".to_owned(),
kappa: 0.0,
};
let msg = err.to_string();
assert!(msg.contains("Itaipu"), "should contain hydro name: {msg}");
assert!(msg.contains('0'), "should contain kappa value: {msg}");
}
#[test]
fn display_no_hyperplanes_produced_contains_name() {
let err = FphaFittingError::NoHyperplanesProduced {
hydro_name: "Serra da Mesa".to_owned(),
};
let msg = err.to_string();
assert!(
msg.contains("Serra da Mesa"),
"should contain hydro name: {msg}"
);
}
#[test]
fn display_invalid_coefficient_contains_name_and_index_and_detail() {
let err = FphaFittingError::InvalidCoefficient {
hydro_name: "Furnas".to_owned(),
plane_index: 3,
detail: "gamma_v is negative".to_owned(),
};
let msg = err.to_string();
assert!(msg.contains("Furnas"), "should contain hydro name: {msg}");
assert!(msg.contains('3'), "should contain plane index: {msg}");
assert!(msg.contains("gamma_v"), "should contain detail: {msg}");
}
#[test]
fn fit_fpha_planes_result_kappa_in_range_and_intercept_consistent() {
let rows = sobradinho_rows();
let hydro = make_sobradinho_hydro();
let config = FphaConfig {
source: "computed".to_owned(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
};
let result = fit_fpha_planes(&rows, &hydro, &config).expect("fit should succeed");
assert!(
result.kappa > 0.0,
"kappa must be positive, got {}",
result.kappa
);
assert!(
result.kappa <= 1.0,
"kappa must be <= 1.0, got {}",
result.kappa
);
for (idx, plane) in result.planes.iter().enumerate() {
let raw_gamma_0 = plane.intercept / result.kappa;
let reconstructed_intercept = raw_gamma_0 * result.kappa;
assert!(
(plane.intercept - reconstructed_intercept).abs() < 1e-12,
"plane {idx}: intercept round-trip failed: {} vs {}",
plane.intercept,
reconstructed_intercept
);
}
}
}