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}