lake-elevation 0.1.0

Reservoir and lake elevation math: percent-of-pool, storage conversions, datum conversions (ft<->m), and full-pool / conservation-pool calculations.
Documentation
//! # lake-elevation
//!
//! Reservoir and lake elevation math: percent-of-pool, storage (acre-foot / cubic-meter)
//! conversions, datum conversions (feet <-> meters), and the full-pool / conservation-pool
//! calculations used by USACE, USGS, and Bureau of Reclamation reservoir operations.
//!
//! The same math behind the [LakeLevelNow](https://lakelevelnow.com/) water-level tracker.
//!
//! Key conventions:
//! - Elevations are in feet or meters above a datum (e.g. NGVD29 / NAVD88).
//! - A reservoir's *conservation pool* is the normal operating range between a minimum
//!   (dead/inactive pool) elevation and the *full-pool* (top of conservation) elevation.
//! - *Percent of pool* is the fraction of that usable range currently filled.
//! - Storage is measured in *acre-feet* (1 acre-foot = the volume covering 1 acre 1 ft deep,
//!   exactly 43,560 ft^3) or cubic meters (1 ft^3 = 0.028316846592 m^3, exact per 1959
//!   international yard agreement: 1 ft = 0.3048 m exactly).
//!
//! ```
//! use lake_elevation::{percent_of_pool, feet_to_meters};
//! assert!((percent_of_pool(100.0, 0.0, 100.0) - 100.0).abs() < 1e-9); // at full pool
//! assert!((feet_to_meters(1.0) - 0.3048).abs() < 1e-12);             // exact by definition
//! ```

/// Exact meters per foot under the 1959 international yard agreement (1 ft = 0.3048 m exactly).
pub const METERS_PER_FOOT: f64 = 0.3048;
/// Exact feet per meter (1 / 0.3048).
pub const FEET_PER_METER: f64 = 1.0 / 0.3048;
/// Cubic feet in one acre-foot (43,560 exactly: 1 acre x 1 ft = 43,560 ft^2 x 1 ft).
pub const CUBIC_FEET_PER_ACRE_FOOT: f64 = 43_560.0;
/// Cubic meters in one cubic foot (0.3048^3 = 0.028316846592 exactly, per 1959 yard agreement).
pub const CUBIC_METERS_PER_CUBIC_FOOT: f64 = 0.028316846592;
/// Acre-feet per cubic meter (1 / (43,560 * 0.028316846592)).
pub const ACRE_FEET_PER_CUBIC_METER: f64 = 1.0 / (43_560.0 * 0.028316846592);

// ---------------------------------------------------------------------------
// Datum / unit conversions
// ---------------------------------------------------------------------------

/// Convert an elevation in feet to meters (exact: 1 ft = 0.3048 m).
pub fn feet_to_meters(feet: f64) -> f64 {
    feet * METERS_PER_FOOT
}

/// Convert an elevation in meters to feet (exact inverse).
pub fn meters_to_feet(meters: f64) -> f64 {
    meters * FEET_PER_METER
}

/// Apply a datum shift expressed in feet. A positive `offset_ft` raises the elevation;
/// e.g. converting NGVD29 -> NAVD88 on the US west coast often subtracts ~2-3 ft (negative offset).
pub fn shift_datum_feet(elevation_ft: f64, offset_ft: f64) -> f64 {
    elevation_ft + offset_ft
}

/// Apply a datum shift expressed in meters.
pub fn shift_datum_meters(elevation_m: f64, offset_m: f64) -> f64 {
    elevation_m + offset_m
}

// ---------------------------------------------------------------------------
// Storage conversions
// ---------------------------------------------------------------------------

/// Convert acre-feet to cubic meters.
pub fn acre_feet_to_cubic_meters(acre_feet: f64) -> f64 {
    acre_feet * CUBIC_FEET_PER_ACRE_FOOT * CUBIC_METERS_PER_CUBIC_FOOT
}

/// Convert cubic meters to acre-feet.
pub fn cubic_meters_to_acre_feet(cubic_meters: f64) -> f64 {
    cubic_meters * ACRE_FEET_PER_CUBIC_METER
}

// ---------------------------------------------------------------------------
// Percent-of-pool (conservation pool math)
// ---------------------------------------------------------------------------

/// Percent of the conservation pool currently filled.
///
/// `current`, `min`, and `full` are elevations (any consistent unit). Returns a value
/// in [0, 100] when `current` is within the pool range. Returns negative (below min) or
/// above 100 (above full / in flood pool) without clamping so callers can detect spills.
///
/// Returns `f64::NAN` if `full <= min` (degenerate pool definition).
///
/// ```
/// use lake_elevation::percent_of_pool;
/// // empty conservation pool
/// assert!((percent_of_pool(800.0, 800.0, 900.0) - 0.0).abs() < 1e-9);
/// // exactly full
/// assert!((percent_of_pool(900.0, 800.0, 900.0) - 100.0).abs() < 1e-9);
/// // halfway
/// assert!((percent_of_pool(850.0, 800.0, 900.0) - 50.0).abs() < 1e-9);
/// ```
pub fn percent_of_pool(current: f64, min: f64, full: f64) -> f64 {
    if full <= min {
        return f64::NAN;
    }
    (current - min) / (full - min) * 100.0
}

/// Linear storage estimate within the conservation pool assuming a prismatic (constant
/// surface-area) approximation. Returns the approximate volume in acre-feet between
/// `min` and `current`, capped at `full` so a positive number never exceeds total storage.
///
/// `surface_area_acres` is the assumed reservoir surface area. For real reservoirs the
/// area grows with elevation; this is the simplest first-order estimate and matches the
/// convention used for rough capacity figures.
pub fn storage_in_pool_acre_feet(
    current: f64,
    min: f64,
    full: f64,
    surface_area_acres: f64,
) -> f64 {
    if surface_area_acres < 0.0 {
        return f64::NAN;
    }
    if full <= min {
        return f64::NAN;
    }
    let clamped = current.clamp(min, full);
    // acre-feet = surface_area(acres) * depth(ft)  (1 acre * 1 ft = 1 acre-foot)
    surface_area_acres * (clamped - min)
}

/// Total conservation-pool storage (acre-feet) under the prismatic approximation.
pub fn conservation_pool_storage_acre_feet(
    min: f64,
    full: f64,
    surface_area_acres: f64,
) -> f64 {
    storage_in_pool_acre_feet(full, min, full, surface_area_acres)
}

/// Deficit to full pool in feet. Negative means the lake is above full (flood pool).
pub fn feet_below_full(current: f64, full: f64) -> f64 {
    full - current
}

/// Whether the lake is in its flood pool (above full / top of conservation).
pub fn is_in_flood_pool(current: f64, full: f64) -> bool {
    current > full
}

/// Whether the lake is below its minimum (inactive / dead) pool elevation.
pub fn is_below_min_pool(current: f64, min: f64) -> bool {
    current < min
}

#[cfg(test)]
mod tests {
    use super::*;

    // --- unit / datum conversions (exact) ---

    #[test]
    fn one_foot_is_exactly_0_3048_m() {
        assert!((feet_to_meters(1.0) - 0.3048).abs() < 1e-12);
    }

    #[test]
    fn one_meter_is_exactly_inverse() {
        // 1 / 0.3048 = 3.2808398950131...
        assert!((meters_to_feet(1.0) - (1.0 / 0.3048)).abs() < 1e-12);
    }

    #[test]
    fn round_trip_feet_meters() {
        for v in [0.0, 100.0, 1000.37, -5.5] {
            assert!((meters_to_feet(feet_to_meters(v)) - v).abs() < 1e-9);
        }
    }

    #[test]
    fn datum_shift_feet_adds_offset() {
        // NGVD29 1000 ft -> NAVD88 with a -2.7 ft offset (typical western US)
        assert!((shift_datum_feet(1000.0, -2.7) - 997.3).abs() < 1e-9);
    }

    #[test]
    fn datum_shift_meters_adds_offset() {
        assert!((shift_datum_meters(300.0, 0.5) - 300.5).abs() < 1e-9);
    }

    #[test]
    fn datum_shift_through_units_is_consistent() {
        // shifting 1 m in feet-units should equal shifting 1 m in meters-units, then converting
        let shifted_ft = shift_datum_feet(1000.0, meters_to_feet(1.0));
        let shifted_m = shift_datum_meters(feet_to_meters(1000.0), 1.0);
        assert!((shifted_ft - meters_to_feet(shifted_m)).abs() < 1e-6);
    }

    // --- storage conversions ---

    #[test]
    fn one_acre_foot_in_cubic_meters() {
        // 43,560 ft^3 * 0.028316846592 = 1233.4818375475... m^3
        let expected = 43_560.0 * CUBIC_METERS_PER_CUBIC_FOOT;
        assert!((acre_feet_to_cubic_meters(1.0) - expected).abs() < 1e-6);
        assert!((acre_feet_to_cubic_meters(1.0) - 1233.48183754752).abs() < 1e-6);
    }

    #[test]
    fn cubic_meters_to_acre_feet_round_trip() {
        for v in [0.0, 1.0, 1000.0, 1_234_567.89] {
            assert!((cubic_meters_to_acre_feet(acre_feet_to_cubic_meters(v)) - v).abs() < 1e-6);
        }
    }

    // --- percent of pool ---

    #[test]
    fn percent_at_min_is_zero() {
        assert!((percent_of_pool(800.0, 800.0, 900.0) - 0.0).abs() < 1e-9);
    }

    #[test]
    fn percent_at_full_is_hundred() {
        assert!((percent_of_pool(900.0, 800.0, 900.0) - 100.0).abs() < 1e-9);
    }

    #[test]
    fn percent_at_midpoint_is_fifty() {
        assert!((percent_of_pool(850.0, 800.0, 900.0) - 50.0).abs() < 1e-9);
    }

    #[test]
    fn percent_reports_overspill_unclamped() {
        // 10 ft above full -> 110% (flood pool), not capped
        assert!((percent_of_pool(910.0, 800.0, 900.0) - 110.0).abs() < 1e-9);
    }

    #[test]
    fn percent_reports_below_min_negative() {
        // 10 ft below min -> -10%, not clamped to 0
        assert!((percent_of_pool(790.0, 800.0, 900.0) - (-10.0)).abs() < 1e-9);
    }

    #[test]
    fn percent_degenerate_pool_is_nan() {
        assert!(percent_of_pool(5.0, 10.0, 10.0).is_nan()); // full == min
        assert!(percent_of_pool(5.0, 10.0, 5.0).is_nan()); //  full < min
    }

    #[test]
    fn percent_works_in_meters_too() {
        // units are arbitrary as long as consistent
        assert!((percent_of_pool(300.0, 290.0, 310.0) - 50.0).abs() < 1e-9);
    }

    // --- prismatic storage estimate ---

    #[test]
    fn storage_empty_pool_is_zero() {
        // 1000-acre lake, 0 ft of usable depth filled
        assert!((storage_in_pool_acre_feet(800.0, 800.0, 900.0, 1000.0) - 0.0).abs() < 1e-9);
    }

    #[test]
    fn storage_full_pool_matches_total() {
        // 1000-acre lake, 100 ft usable depth -> 100,000 acre-feet
        assert!((storage_in_pool_acre_feet(900.0, 800.0, 900.0, 1000.0) - 100_000.0).abs() < 1e-6);
        assert!((conservation_pool_storage_acre_feet(800.0, 900.0, 1000.0) - 100_000.0).abs() < 1e-6);
    }

    #[test]
    fn storage_clamps_overspill() {
        // 10 ft above full must NOT add storage beyond the pool
        let s = storage_in_pool_acre_feet(910.0, 800.0, 900.0, 1000.0);
        assert!((s - 100_000.0).abs() < 1e-6);
    }

    #[test]
    fn storage_clamps_below_min() {
        let s = storage_in_pool_acre_feet(790.0, 800.0, 900.0, 1000.0);
        assert!((s - 0.0).abs() < 1e-9);
    }

    #[test]
    fn storage_midpoint_is_half_total() {
        let s = storage_in_pool_acre_feet(850.0, 800.0, 900.0, 1000.0);
        assert!((s - 50_000.0).abs() < 1e-6);
    }

    #[test]
    fn storage_negative_area_is_nan() {
        assert!(storage_in_pool_acre_feet(850.0, 800.0, 900.0, -1.0).is_nan());
    }

    // --- pool-status helpers ---

    #[test]
    fn feet_below_full_positive_when_low() {
        assert!((feet_below_full(895.0, 900.0) - 5.0).abs() < 1e-9);
    }

    #[test]
    fn feet_below_full_negative_when_in_flood_pool() {
        assert!((feet_below_full(910.0, 900.0) - (-10.0)).abs() < 1e-9);
    }

    #[test]
    fn flood_pool_detection() {
        assert!(is_in_flood_pool(901.0, 900.0));
        assert!(!is_in_flood_pool(900.0, 900.0)); // exactly full is NOT flood pool
        assert!(!is_in_flood_pool(899.9, 900.0));
    }

    #[test]
    fn below_min_pool_detection() {
        assert!(is_below_min_pool(799.0, 800.0));
        assert!(!is_below_min_pool(800.0, 800.0)); // exactly min is NOT below
        assert!(!is_below_min_pool(801.0, 800.0));
    }
}