Skip to main content

lake_elevation/
lib.rs

1//! # lake-elevation
2//!
3//! Reservoir and lake elevation math: percent-of-pool, storage (acre-foot / cubic-meter)
4//! conversions, datum conversions (feet <-> meters), and the full-pool / conservation-pool
5//! calculations used by USACE, USGS, and Bureau of Reclamation reservoir operations.
6//!
7//! The same math behind the [LakeLevelNow](https://lakelevelnow.com/) water-level tracker.
8//!
9//! Key conventions:
10//! - Elevations are in feet or meters above a datum (e.g. NGVD29 / NAVD88).
11//! - A reservoir's *conservation pool* is the normal operating range between a minimum
12//!   (dead/inactive pool) elevation and the *full-pool* (top of conservation) elevation.
13//! - *Percent of pool* is the fraction of that usable range currently filled.
14//! - Storage is measured in *acre-feet* (1 acre-foot = the volume covering 1 acre 1 ft deep,
15//!   exactly 43,560 ft^3) or cubic meters (1 ft^3 = 0.028316846592 m^3, exact per 1959
16//!   international yard agreement: 1 ft = 0.3048 m exactly).
17//!
18//! ```
19//! use lake_elevation::{percent_of_pool, feet_to_meters};
20//! assert!((percent_of_pool(100.0, 0.0, 100.0) - 100.0).abs() < 1e-9); // at full pool
21//! assert!((feet_to_meters(1.0) - 0.3048).abs() < 1e-12);             // exact by definition
22//! ```
23
24/// Exact meters per foot under the 1959 international yard agreement (1 ft = 0.3048 m exactly).
25pub const METERS_PER_FOOT: f64 = 0.3048;
26/// Exact feet per meter (1 / 0.3048).
27pub const FEET_PER_METER: f64 = 1.0 / 0.3048;
28/// Cubic feet in one acre-foot (43,560 exactly: 1 acre x 1 ft = 43,560 ft^2 x 1 ft).
29pub const CUBIC_FEET_PER_ACRE_FOOT: f64 = 43_560.0;
30/// Cubic meters in one cubic foot (0.3048^3 = 0.028316846592 exactly, per 1959 yard agreement).
31pub const CUBIC_METERS_PER_CUBIC_FOOT: f64 = 0.028316846592;
32/// Acre-feet per cubic meter (1 / (43,560 * 0.028316846592)).
33pub const ACRE_FEET_PER_CUBIC_METER: f64 = 1.0 / (43_560.0 * 0.028316846592);
34
35// ---------------------------------------------------------------------------
36// Datum / unit conversions
37// ---------------------------------------------------------------------------
38
39/// Convert an elevation in feet to meters (exact: 1 ft = 0.3048 m).
40pub fn feet_to_meters(feet: f64) -> f64 {
41    feet * METERS_PER_FOOT
42}
43
44/// Convert an elevation in meters to feet (exact inverse).
45pub fn meters_to_feet(meters: f64) -> f64 {
46    meters * FEET_PER_METER
47}
48
49/// Apply a datum shift expressed in feet. A positive `offset_ft` raises the elevation;
50/// e.g. converting NGVD29 -> NAVD88 on the US west coast often subtracts ~2-3 ft (negative offset).
51pub fn shift_datum_feet(elevation_ft: f64, offset_ft: f64) -> f64 {
52    elevation_ft + offset_ft
53}
54
55/// Apply a datum shift expressed in meters.
56pub fn shift_datum_meters(elevation_m: f64, offset_m: f64) -> f64 {
57    elevation_m + offset_m
58}
59
60// ---------------------------------------------------------------------------
61// Storage conversions
62// ---------------------------------------------------------------------------
63
64/// Convert acre-feet to cubic meters.
65pub fn acre_feet_to_cubic_meters(acre_feet: f64) -> f64 {
66    acre_feet * CUBIC_FEET_PER_ACRE_FOOT * CUBIC_METERS_PER_CUBIC_FOOT
67}
68
69/// Convert cubic meters to acre-feet.
70pub fn cubic_meters_to_acre_feet(cubic_meters: f64) -> f64 {
71    cubic_meters * ACRE_FEET_PER_CUBIC_METER
72}
73
74// ---------------------------------------------------------------------------
75// Percent-of-pool (conservation pool math)
76// ---------------------------------------------------------------------------
77
78/// Percent of the conservation pool currently filled.
79///
80/// `current`, `min`, and `full` are elevations (any consistent unit). Returns a value
81/// in [0, 100] when `current` is within the pool range. Returns negative (below min) or
82/// above 100 (above full / in flood pool) without clamping so callers can detect spills.
83///
84/// Returns `f64::NAN` if `full <= min` (degenerate pool definition).
85///
86/// ```
87/// use lake_elevation::percent_of_pool;
88/// // empty conservation pool
89/// assert!((percent_of_pool(800.0, 800.0, 900.0) - 0.0).abs() < 1e-9);
90/// // exactly full
91/// assert!((percent_of_pool(900.0, 800.0, 900.0) - 100.0).abs() < 1e-9);
92/// // halfway
93/// assert!((percent_of_pool(850.0, 800.0, 900.0) - 50.0).abs() < 1e-9);
94/// ```
95pub fn percent_of_pool(current: f64, min: f64, full: f64) -> f64 {
96    if full <= min {
97        return f64::NAN;
98    }
99    (current - min) / (full - min) * 100.0
100}
101
102/// Linear storage estimate within the conservation pool assuming a prismatic (constant
103/// surface-area) approximation. Returns the approximate volume in acre-feet between
104/// `min` and `current`, capped at `full` so a positive number never exceeds total storage.
105///
106/// `surface_area_acres` is the assumed reservoir surface area. For real reservoirs the
107/// area grows with elevation; this is the simplest first-order estimate and matches the
108/// convention used for rough capacity figures.
109pub fn storage_in_pool_acre_feet(
110    current: f64,
111    min: f64,
112    full: f64,
113    surface_area_acres: f64,
114) -> f64 {
115    if surface_area_acres < 0.0 {
116        return f64::NAN;
117    }
118    if full <= min {
119        return f64::NAN;
120    }
121    let clamped = current.clamp(min, full);
122    // acre-feet = surface_area(acres) * depth(ft)  (1 acre * 1 ft = 1 acre-foot)
123    surface_area_acres * (clamped - min)
124}
125
126/// Total conservation-pool storage (acre-feet) under the prismatic approximation.
127pub fn conservation_pool_storage_acre_feet(
128    min: f64,
129    full: f64,
130    surface_area_acres: f64,
131) -> f64 {
132    storage_in_pool_acre_feet(full, min, full, surface_area_acres)
133}
134
135/// Deficit to full pool in feet. Negative means the lake is above full (flood pool).
136pub fn feet_below_full(current: f64, full: f64) -> f64 {
137    full - current
138}
139
140/// Whether the lake is in its flood pool (above full / top of conservation).
141pub fn is_in_flood_pool(current: f64, full: f64) -> bool {
142    current > full
143}
144
145/// Whether the lake is below its minimum (inactive / dead) pool elevation.
146pub fn is_below_min_pool(current: f64, min: f64) -> bool {
147    current < min
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    // --- unit / datum conversions (exact) ---
155
156    #[test]
157    fn one_foot_is_exactly_0_3048_m() {
158        assert!((feet_to_meters(1.0) - 0.3048).abs() < 1e-12);
159    }
160
161    #[test]
162    fn one_meter_is_exactly_inverse() {
163        // 1 / 0.3048 = 3.2808398950131...
164        assert!((meters_to_feet(1.0) - (1.0 / 0.3048)).abs() < 1e-12);
165    }
166
167    #[test]
168    fn round_trip_feet_meters() {
169        for v in [0.0, 100.0, 1000.37, -5.5] {
170            assert!((meters_to_feet(feet_to_meters(v)) - v).abs() < 1e-9);
171        }
172    }
173
174    #[test]
175    fn datum_shift_feet_adds_offset() {
176        // NGVD29 1000 ft -> NAVD88 with a -2.7 ft offset (typical western US)
177        assert!((shift_datum_feet(1000.0, -2.7) - 997.3).abs() < 1e-9);
178    }
179
180    #[test]
181    fn datum_shift_meters_adds_offset() {
182        assert!((shift_datum_meters(300.0, 0.5) - 300.5).abs() < 1e-9);
183    }
184
185    #[test]
186    fn datum_shift_through_units_is_consistent() {
187        // shifting 1 m in feet-units should equal shifting 1 m in meters-units, then converting
188        let shifted_ft = shift_datum_feet(1000.0, meters_to_feet(1.0));
189        let shifted_m = shift_datum_meters(feet_to_meters(1000.0), 1.0);
190        assert!((shifted_ft - meters_to_feet(shifted_m)).abs() < 1e-6);
191    }
192
193    // --- storage conversions ---
194
195    #[test]
196    fn one_acre_foot_in_cubic_meters() {
197        // 43,560 ft^3 * 0.028316846592 = 1233.4818375475... m^3
198        let expected = 43_560.0 * CUBIC_METERS_PER_CUBIC_FOOT;
199        assert!((acre_feet_to_cubic_meters(1.0) - expected).abs() < 1e-6);
200        assert!((acre_feet_to_cubic_meters(1.0) - 1233.48183754752).abs() < 1e-6);
201    }
202
203    #[test]
204    fn cubic_meters_to_acre_feet_round_trip() {
205        for v in [0.0, 1.0, 1000.0, 1_234_567.89] {
206            assert!((cubic_meters_to_acre_feet(acre_feet_to_cubic_meters(v)) - v).abs() < 1e-6);
207        }
208    }
209
210    // --- percent of pool ---
211
212    #[test]
213    fn percent_at_min_is_zero() {
214        assert!((percent_of_pool(800.0, 800.0, 900.0) - 0.0).abs() < 1e-9);
215    }
216
217    #[test]
218    fn percent_at_full_is_hundred() {
219        assert!((percent_of_pool(900.0, 800.0, 900.0) - 100.0).abs() < 1e-9);
220    }
221
222    #[test]
223    fn percent_at_midpoint_is_fifty() {
224        assert!((percent_of_pool(850.0, 800.0, 900.0) - 50.0).abs() < 1e-9);
225    }
226
227    #[test]
228    fn percent_reports_overspill_unclamped() {
229        // 10 ft above full -> 110% (flood pool), not capped
230        assert!((percent_of_pool(910.0, 800.0, 900.0) - 110.0).abs() < 1e-9);
231    }
232
233    #[test]
234    fn percent_reports_below_min_negative() {
235        // 10 ft below min -> -10%, not clamped to 0
236        assert!((percent_of_pool(790.0, 800.0, 900.0) - (-10.0)).abs() < 1e-9);
237    }
238
239    #[test]
240    fn percent_degenerate_pool_is_nan() {
241        assert!(percent_of_pool(5.0, 10.0, 10.0).is_nan()); // full == min
242        assert!(percent_of_pool(5.0, 10.0, 5.0).is_nan()); //  full < min
243    }
244
245    #[test]
246    fn percent_works_in_meters_too() {
247        // units are arbitrary as long as consistent
248        assert!((percent_of_pool(300.0, 290.0, 310.0) - 50.0).abs() < 1e-9);
249    }
250
251    // --- prismatic storage estimate ---
252
253    #[test]
254    fn storage_empty_pool_is_zero() {
255        // 1000-acre lake, 0 ft of usable depth filled
256        assert!((storage_in_pool_acre_feet(800.0, 800.0, 900.0, 1000.0) - 0.0).abs() < 1e-9);
257    }
258
259    #[test]
260    fn storage_full_pool_matches_total() {
261        // 1000-acre lake, 100 ft usable depth -> 100,000 acre-feet
262        assert!((storage_in_pool_acre_feet(900.0, 800.0, 900.0, 1000.0) - 100_000.0).abs() < 1e-6);
263        assert!((conservation_pool_storage_acre_feet(800.0, 900.0, 1000.0) - 100_000.0).abs() < 1e-6);
264    }
265
266    #[test]
267    fn storage_clamps_overspill() {
268        // 10 ft above full must NOT add storage beyond the pool
269        let s = storage_in_pool_acre_feet(910.0, 800.0, 900.0, 1000.0);
270        assert!((s - 100_000.0).abs() < 1e-6);
271    }
272
273    #[test]
274    fn storage_clamps_below_min() {
275        let s = storage_in_pool_acre_feet(790.0, 800.0, 900.0, 1000.0);
276        assert!((s - 0.0).abs() < 1e-9);
277    }
278
279    #[test]
280    fn storage_midpoint_is_half_total() {
281        let s = storage_in_pool_acre_feet(850.0, 800.0, 900.0, 1000.0);
282        assert!((s - 50_000.0).abs() < 1e-6);
283    }
284
285    #[test]
286    fn storage_negative_area_is_nan() {
287        assert!(storage_in_pool_acre_feet(850.0, 800.0, 900.0, -1.0).is_nan());
288    }
289
290    // --- pool-status helpers ---
291
292    #[test]
293    fn feet_below_full_positive_when_low() {
294        assert!((feet_below_full(895.0, 900.0) - 5.0).abs() < 1e-9);
295    }
296
297    #[test]
298    fn feet_below_full_negative_when_in_flood_pool() {
299        assert!((feet_below_full(910.0, 900.0) - (-10.0)).abs() < 1e-9);
300    }
301
302    #[test]
303    fn flood_pool_detection() {
304        assert!(is_in_flood_pool(901.0, 900.0));
305        assert!(!is_in_flood_pool(900.0, 900.0)); // exactly full is NOT flood pool
306        assert!(!is_in_flood_pool(899.9, 900.0));
307    }
308
309    #[test]
310    fn below_min_pool_detection() {
311        assert!(is_below_min_pool(799.0, 800.0));
312        assert!(!is_below_min_pool(800.0, 800.0)); // exactly min is NOT below
313        assert!(!is_below_min_pool(801.0, 800.0));
314    }
315}