use cobre_core::{HydraulicLossesModel, Hydro, TailraceModel};
use cobre_io::extensions::{FphaColumnLayout, HydroGeometryRow};
use super::error::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,
#[allow(dead_code)]
pub n_spillage_points: usize,
#[allow(dead_code)]
pub max_planes_per_hydro: usize,
pub single_volume: bool,
}
const V_EPS: f64 = 1e-9;
const V_SYNTH_ABS: f64 = 1.0;
pub(crate) fn resolve_fitting_bounds(
config: &FphaColumnLayout,
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_max < v_min {
return Err(FphaFittingError::EmptyFittingWindow {
hydro_name: hydro_name.clone(),
v_min,
v_max,
});
}
#[allow(clippy::cast_sign_loss)]
let requested_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;
let single_volume = (v_max - v_min) <= V_EPS || requested_volume_points == 1;
if single_volume {
let v0 = v_min;
let useful = (hydro.max_storage_hm3 - hydro.min_storage_hm3).max(0.0);
let half_width = (0.005 * useful).max(V_SYNTH_ABS);
let (v_lo, v_hi) = if (forebay.v_max() - forebay.v_min()) <= V_EPS {
(v0 - half_width, v0 + half_width)
} else {
(
(v0 - half_width).clamp(forebay.v_min(), forebay.v_max()),
(v0 + half_width).clamp(forebay.v_min(), forebay.v_max()),
)
};
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,
});
}
return Ok(FittingBounds {
v_min: v_lo,
v_max: v_hi,
n_volume_points: 2,
n_flow_points,
n_spillage_points,
max_planes_per_hydro: max_planes,
single_volume: true,
});
}
let n_volume_points = requested_volume_points;
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,
single_volume: false,
})
}
#[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.is_empty() {
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 {
if self.volumes.len() == 1 {
return self.heights[0];
}
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])
}
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_losses(
model: &HydraulicLossesModel,
gross_head: f64,
_turbined_m3s: f64,
) -> f64 {
match model {
HydraulicLossesModel::Factor { value } => value * gross_head,
HydraulicLossesModel::Constant { value_m } => *value_m,
}
}
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)
}