use super::geometry::FittingBounds;
use super::grid::build_grid;
use super::production::ProductionFunction;
use crate::hull::{HullError, Hyperplane3d, convex_hull_3d};
#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(clippy::struct_field_names)]
pub(crate) struct RawPlane {
pub gamma_0: f64,
pub gamma_v: f64,
pub gamma_q: f64,
pub gamma_s: f64,
}
impl RawPlane {
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
}
}
const EPS_NZ: f64 = 1e-9;
const GAMMA_V_EPS: f64 = 1e-6;
fn build_cloud(pf: &ProductionFunction, bounds: &FittingBounds) -> Vec<[f64; 3]> {
let grid = build_grid(pf, bounds);
let mut cloud = Vec::with_capacity(grid.v_points.len() * grid.q_points.len());
for &v in &grid.v_points {
for &q in &grid.q_points {
let generation = pf.evaluate_capped(v, q, 0.0);
cloud.push([v, q, generation]);
}
}
cloud
}
fn facet_to_plane(facet: &Hyperplane3d) -> RawPlane {
let nz = facet.normal[2];
RawPlane {
gamma_0: -facet.offset / nz,
gamma_v: -facet.normal[0] / nz,
gamma_q: -facet.normal[1] / nz,
gamma_s: 0.0,
}
}
pub(crate) fn fit_hull_planes(
pf: &ProductionFunction,
bounds: &FittingBounds,
) -> Result<Vec<RawPlane>, HullError> {
let cloud = build_cloud(pf, bounds);
let facets = convex_hull_3d(&cloud)?;
let mut planes: Vec<RawPlane> = facets
.iter()
.filter(|f| f.normal[2] > EPS_NZ)
.map(facet_to_plane)
.collect();
if bounds.single_volume {
for plane in &mut planes {
if plane.gamma_v.abs() <= GAMMA_V_EPS {
plane.gamma_v = 0.0;
}
}
}
let mut seen: Vec<(u64, u64, u64)> = Vec::with_capacity(planes.len());
planes.retain(|p| {
let key = (
p.gamma_0.to_bits(),
p.gamma_v.to_bits(),
p.gamma_q.to_bits(),
);
if seen.contains(&key) {
false
} else {
seen.push(key);
true
}
});
Ok(planes)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::similar_names
)]
mod tests {
use cobre_core::{EfficiencyModel, EntityId, HydraulicLossesModel, TailraceModel};
use cobre_io::extensions::HydroGeometryRow;
use super::super::geometry::{FittingBounds, ForebayTable};
use super::super::production::{ProductionFunction, TailraceSource};
use super::{RawPlane, build_cloud, fit_hull_planes};
use crate::hull::HullError;
fn row(volume_hm3: f64, height_m: f64) -> HydroGeometryRow {
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3,
height_m,
area_km2: 0.0,
}
}
fn test_bounds() -> FittingBounds {
FittingBounds {
v_min: 0.0,
v_max: 30_000.0,
n_volume_points: 6,
n_flow_points: 6,
n_spillage_points: 2,
max_planes_per_hydro: 10,
single_volume: false,
}
}
fn concave_production_function() -> ProductionFunction {
let rows = vec![
row(0.0, 380.0),
row(10_000.0, 396.0),
row(20_000.0, 404.0),
row(30_000.0, 408.0),
];
let forebay = ForebayTable::new(&rows, "Concave").expect("valid VHA curve");
let tailrace = TailraceModel::Polynomial {
coefficients: vec![0.0, 0.0008, -2e-8],
};
ProductionFunction::new(
forebay,
TailraceSource::Entity(Some(tailrace)),
Some(&HydraulicLossesModel::Constant { value_m: 2.0 }),
Some(&EfficiencyModel::Constant { value: 0.92 }),
3_000.0,
"Concave".to_owned(),
)
}
fn sloped_production_function() -> ProductionFunction {
let rows = vec![row(0.0, 380.0), row(30_000.0, 410.0)];
let forebay = ForebayTable::new(&rows, "Sloped").expect("valid VHA curve");
ProductionFunction::new(
forebay,
TailraceSource::Entity(None),
None,
None,
3_000.0,
"Sloped".to_owned(),
)
}
fn flat_production_function() -> ProductionFunction {
let rows = vec![row(0.0, 400.0), row(30_000.0, 400.0)];
let forebay = ForebayTable::new(&rows, "Flat").expect("valid VHA curve");
ProductionFunction::new(
forebay,
TailraceSource::Entity(None),
None,
None,
3_000.0,
"Flat".to_owned(),
)
}
fn run_of_river_production_function() -> ProductionFunction {
let rows = vec![row(0.0, 400.0), row(30_000.0, 400.0)];
let forebay = ForebayTable::new(&rows, "RunOfRiver").expect("valid VHA curve");
let tailrace = TailraceModel::Polynomial {
coefficients: vec![0.0, 0.0008, -2e-8],
};
ProductionFunction::new(
forebay,
TailraceSource::Entity(Some(tailrace)),
Some(&HydraulicLossesModel::Constant { value_m: 2.0 }),
Some(&EfficiencyModel::Constant { value: 0.92 }),
3_000.0,
"RunOfRiver".to_owned(),
)
}
fn single_volume_bounds() -> FittingBounds {
FittingBounds {
v_min: 14_998.0,
v_max: 15_002.0,
n_volume_points: 2,
n_flow_points: 6,
n_spillage_points: 2,
max_planes_per_hydro: 10,
single_volume: true,
}
}
fn envelope_max(planes: &[RawPlane], v: f64, q: f64) -> f64 {
planes
.iter()
.map(|p| p.gamma_0 + p.gamma_v * v + p.gamma_q * q)
.fold(f64::NEG_INFINITY, f64::max)
}
#[test]
fn upper_envelope_is_outer_approximation_on_concave_function() {
let pf = concave_production_function();
let bounds = test_bounds();
let planes = fit_hull_planes(&pf, &bounds).expect("concave cloud yields a valid hull");
assert!(
!planes.is_empty(),
"expected at least one upper-envelope plane"
);
let cloud = build_cloud(&pf, &bounds);
for point in &cloud {
let [v, q, generation] = *point;
let envelope = envelope_max(&planes, v, q);
assert!(
envelope >= generation - 1e-8,
"outer-approximation violated at (v={v}, q={q}): envelope={envelope} < generation={generation}"
);
}
}
#[test]
fn upper_envelope_planes_have_valid_coefficient_signs() {
let pf = concave_production_function();
let bounds = test_bounds();
let planes = fit_hull_planes(&pf, &bounds).expect("valid hull");
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_eq!(
plane.gamma_s, 0.0,
"plane {idx}: gamma_s must be 0.0 from the hull fitter"
);
}
}
#[test]
fn cloud_samples_grid_at_zero_spillage_with_zero_flow_floor() {
let pf = concave_production_function();
let bounds = test_bounds();
let cloud = build_cloud(&pf, &bounds);
assert_eq!(
cloud.len(),
bounds.n_volume_points * bounds.n_flow_points,
"cloud must be exactly the grid nodes, with no appended point"
);
for &[v, q, generation] in &cloud {
assert_eq!(
generation,
pf.evaluate_capped(v, q, 0.0),
"cloud generation at (v={v}, q={q}) must be the capped value at spillage = 0"
);
}
let zero_flow: Vec<_> = cloud.iter().filter(|p| p[1] == 0.0).collect();
assert_eq!(
zero_flow.len(),
bounds.n_volume_points,
"one zero-flow node per volume point"
);
for p in zero_flow {
assert_eq!(p[2], 0.0, "production is zero at zero flow");
}
}
#[test]
fn capacity_ceiling_clips_cloud_and_yields_flat_cap_plane() {
let cap = 5_000.0_f64;
let pf = concave_production_function().with_max_generation_mw(cap);
let bounds = test_bounds();
let v_top = bounds.v_max;
let q_top = pf.max_turbined_m3s;
assert!(
pf.evaluate(v_top, q_top, 0.0) > cap,
"fixture must exceed the ceiling for this test to bite"
);
assert_eq!(pf.evaluate_capped(v_top, q_top, 0.0), cap);
for &[_, _, generation] in &build_cloud(&pf, &bounds) {
assert!(
generation <= cap + 1e-9,
"cloud generation {generation} must not exceed cap {cap}"
);
}
let planes = fit_hull_planes(&pf, &bounds).expect("hull fit succeeds");
assert!(
planes.iter().any(|p| p.gamma_v.abs() < 1e-6
&& p.gamma_q.abs() < 1e-6
&& (p.gamma_0 - cap).abs() < 1.0),
"expected a flat generation <= cap plane near {cap}, got {planes:?}"
);
}
#[test]
fn shuffle_invariant_plane_vec_is_bit_identical() {
let pf = concave_production_function();
let bounds = test_bounds();
let reference = fit_hull_planes(&pf, &bounds).expect("valid hull");
let again = fit_hull_planes(&pf, &bounds).expect("valid hull");
assert_eq!(reference.len(), again.len());
for (a, b) in reference.iter().zip(&again) {
assert_eq!(a.gamma_0.to_bits(), b.gamma_0.to_bits());
assert_eq!(a.gamma_v.to_bits(), b.gamma_v.to_bits());
assert_eq!(a.gamma_q.to_bits(), b.gamma_q.to_bits());
}
}
#[test]
fn shuffle_invariant_across_explicit_cloud_orderings() {
use crate::hull::convex_hull_3d;
let pf = concave_production_function();
let bounds = test_bounds();
let cloud = build_cloud(&pf, &bounds);
let mut reversed = cloud.clone();
reversed.reverse();
let f1 = convex_hull_3d(&cloud).expect("valid hull");
let f2 = convex_hull_3d(&reversed).expect("valid hull");
assert_eq!(
f1, f2,
"convex_hull_3d facet order must be cloud-order invariant"
);
}
#[test]
fn sloped_function_yields_outer_approximation_planes() {
let pf = sloped_production_function();
let bounds = test_bounds();
let result = fit_hull_planes(&pf, &bounds);
match result {
Ok(planes) => {
assert!(!planes.is_empty(), "sloped function must yield >= 1 plane");
let cloud = build_cloud(&pf, &bounds);
for point in &cloud {
let [v, q, generation] = *point;
assert!(
envelope_max(&planes, v, q) >= generation - 1e-8,
"outer approximation violated at (v={v}, q={q})"
);
}
}
Err(e) => panic!("sloped function should yield a valid hull, got {e:?}"),
}
}
#[test]
fn flat_function_either_yields_a_plane_or_typed_degenerate() {
let pf = flat_production_function();
let bounds = test_bounds();
match fit_hull_planes(&pf, &bounds) {
Ok(planes) => {
assert!(
!planes.is_empty(),
"a successful flat-function fit must not be an empty Ok"
);
}
Err(HullError::Degenerate) => {
}
Err(other) => panic!("unexpected hull error for flat function: {other:?}"),
}
}
#[test]
fn single_volume_fit_yields_gamma_v_exactly_zero() {
let pf = run_of_river_production_function();
let bounds = single_volume_bounds();
let planes = fit_hull_planes(&pf, &bounds).expect("single-volume cloud yields a hull");
assert!(
!planes.is_empty(),
"single-volume fit must yield >= 1 plane"
);
for (idx, plane) in planes.iter().enumerate() {
assert_eq!(
plane.gamma_v.to_bits(),
0.0_f64.to_bits(),
"plane {idx}: gamma_v={} must be exactly 0.0",
plane.gamma_v
);
}
}
#[test]
fn single_volume_envelope_is_outer_approximation_in_q() {
let pf = run_of_river_production_function();
let bounds = single_volume_bounds();
let planes = fit_hull_planes(&pf, &bounds).expect("single-volume cloud yields a hull");
let cloud = build_cloud(&pf, &bounds);
for point in &cloud {
let [v, q, generation] = *point;
let envelope = envelope_max(&planes, v, q);
assert!(
envelope >= generation - 1e-8,
"outer-approximation violated at (v={v}, q={q}): envelope={envelope} < generation={generation}"
);
}
}
#[test]
fn single_volume_planes_have_valid_signs() {
let pf = run_of_river_production_function();
let bounds = single_volume_bounds();
let planes = fit_hull_planes(&pf, &bounds).expect("single-volume cloud yields a hull");
for (idx, plane) in planes.iter().enumerate() {
assert_eq!(plane.gamma_v, 0.0, "plane {idx}: gamma_v must be 0.0");
assert!(
plane.gamma_q >= -1e-10,
"plane {idx}: gamma_q={} must be >= 0",
plane.gamma_q
);
assert_eq!(plane.gamma_s, 0.0, "plane {idx}: gamma_s must be 0.0");
}
}
#[test]
fn single_volume_shuffle_invariant_plane_vec_is_bit_identical() {
use crate::hull::convex_hull_3d;
let pf = run_of_river_production_function();
let bounds = single_volume_bounds();
let cloud = build_cloud(&pf, &bounds);
let mut reversed = cloud.clone();
reversed.reverse();
let f1 = convex_hull_3d(&cloud).expect("valid hull");
let f2 = convex_hull_3d(&reversed).expect("valid hull");
assert_eq!(
f1, f2,
"convex_hull_3d facet order must be cloud-order invariant"
);
let reference = fit_hull_planes(&pf, &bounds).expect("valid hull");
let again = fit_hull_planes(&pf, &bounds).expect("valid hull");
assert_eq!(reference.len(), again.len());
for (a, b) in reference.iter().zip(&again) {
assert_eq!(a.gamma_0.to_bits(), b.gamma_0.to_bits());
assert_eq!(a.gamma_v.to_bits(), b.gamma_v.to_bits());
assert_eq!(a.gamma_q.to_bits(), b.gamma_q.to_bits());
}
}
}