Skip to main content

use_work/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Mechanical work helpers.
5
6pub mod prelude;
7
8fn finite(value: f64) -> Option<f64> {
9    value.is_finite().then_some(value)
10}
11
12/// Computes mechanical work from a constant force and displacement.
13///
14/// Formula: `W = F * d`
15///
16/// Returns `None` when either input is not finite or when the computed result is not finite.
17/// Negative force and displacement values are allowed.
18///
19/// # Examples
20///
21/// ```rust
22/// use use_work::work;
23///
24/// assert_eq!(work(10.0, 2.0), Some(20.0));
25/// assert_eq!(work(-10.0, 2.0), Some(-20.0));
26/// ```
27#[must_use]
28pub fn work(force: f64, displacement: f64) -> Option<f64> {
29    if !force.is_finite() || !displacement.is_finite() {
30        return None;
31    }
32
33    finite(force * displacement)
34}
35
36/// Computes mechanical work when the force is applied at an angle to the displacement.
37///
38/// Formula: `W = F * d * cos(theta)`
39///
40/// Returns `None` when any input is not finite or when the computed result is not finite.
41/// The `angle_radians` input is interpreted in radians.
42///
43/// # Examples
44///
45/// ```rust
46/// use use_work::work_at_angle;
47///
48/// let result = work_at_angle(10.0, 2.0, 0.0).unwrap();
49///
50/// assert_eq!(result, 20.0);
51/// ```
52#[must_use]
53pub fn work_at_angle(force: f64, displacement: f64, angle_radians: f64) -> Option<f64> {
54    if !force.is_finite() || !displacement.is_finite() || !angle_radians.is_finite() {
55        return None;
56    }
57
58    finite(force * displacement * angle_radians.cos())
59}
60
61/// Computes mechanical work when the applied-force angle is given in degrees.
62///
63/// This function converts `angle_degrees` to radians internally and then delegates to
64/// [`work_at_angle`].
65#[must_use]
66pub fn work_at_angle_degrees(force: f64, displacement: f64, angle_degrees: f64) -> Option<f64> {
67    work_at_angle(force, displacement, angle_degrees.to_radians())
68}
69
70/// Computes the force required to perform a given amount of work over a displacement.
71///
72/// Formula: `F = W / d`
73///
74/// Returns `None` when `displacement` is zero, when either input is not finite, or when the
75/// computed result is not finite.
76#[must_use]
77pub fn force_from_work(work: f64, displacement: f64) -> Option<f64> {
78    if !work.is_finite() || !displacement.is_finite() || displacement == 0.0 {
79        return None;
80    }
81
82    finite(work / displacement)
83}
84
85/// Computes the displacement implied by a work value and a constant force.
86///
87/// Formula: `d = W / F`
88///
89/// Returns `None` when `force` is zero, when either input is not finite, or when the computed
90/// result is not finite.
91#[must_use]
92pub fn displacement_from_work(work: f64, force: f64) -> Option<f64> {
93    if !work.is_finite() || !force.is_finite() || force == 0.0 {
94        return None;
95    }
96
97    finite(work / force)
98}
99
100/// Computes the net work from a slice of work contributions.
101///
102/// Returns `Some(0.0)` for an empty slice. Returns `None` when any input is not finite or when
103/// the computed result is not finite.
104///
105/// # Examples
106///
107/// ```rust
108/// use use_work::net_work;
109///
110/// assert_eq!(net_work(&[10.0, -2.0, 5.0]), Some(13.0));
111/// assert_eq!(net_work(&[]), Some(0.0));
112/// ```
113#[must_use]
114pub fn net_work(works: &[f64]) -> Option<f64> {
115    let mut total = 0.0;
116
117    for &value in works {
118        if !value.is_finite() {
119            return None;
120        }
121
122        total += value;
123
124        if !total.is_finite() {
125            return None;
126        }
127    }
128
129    Some(total)
130}
131
132/// Approximates work from aligned force and displacement samples.
133///
134/// Formula: `W = Σ(F_i * d_i)`
135///
136/// Returns `None` when the slice lengths differ, when any value is not finite, or when the
137/// computed result is not finite. Returns `Some(0.0)` for two empty slices.
138#[must_use]
139pub fn work_from_force_samples(displacements: &[f64], forces: &[f64]) -> Option<f64> {
140    if displacements.len() != forces.len() {
141        return None;
142    }
143
144    let mut total = 0.0;
145
146    for (&displacement, &force) in displacements.iter().zip(forces.iter()) {
147        if !displacement.is_finite() || !force.is_finite() {
148            return None;
149        }
150
151        total += force * displacement;
152
153        if !total.is_finite() {
154            return None;
155        }
156    }
157
158    Some(total)
159}
160
161/// Computes net work from the change in kinetic energy.
162///
163/// Formula: `W_net = KE_final - KE_initial`
164///
165/// Returns `None` when either kinetic energy is negative, when any input is not finite, or when
166/// the computed result is not finite.
167///
168/// # Examples
169///
170/// ```rust
171/// use use_work::work_from_kinetic_energy_change;
172///
173/// assert_eq!(work_from_kinetic_energy_change(5.0, 12.0), Some(7.0));
174/// assert_eq!(work_from_kinetic_energy_change(12.0, 5.0), Some(-7.0));
175/// ```
176#[must_use]
177pub fn work_from_kinetic_energy_change(
178    initial_kinetic_energy: f64,
179    final_kinetic_energy: f64,
180) -> Option<f64> {
181    if !initial_kinetic_energy.is_finite()
182        || !final_kinetic_energy.is_finite()
183        || initial_kinetic_energy < 0.0
184        || final_kinetic_energy < 0.0
185    {
186        return None;
187    }
188
189    finite(final_kinetic_energy - initial_kinetic_energy)
190}
191
192/// Computes final kinetic energy from an initial kinetic energy and applied work.
193///
194/// Formula: `KE_final = KE_initial + W`
195///
196/// Returns `None` when `initial_kinetic_energy` is negative, when any input is not finite, when
197/// the computed result is negative, or when the computed result is not finite.
198#[must_use]
199pub fn final_kinetic_energy_from_work(initial_kinetic_energy: f64, work: f64) -> Option<f64> {
200    if !initial_kinetic_energy.is_finite() || !work.is_finite() || initial_kinetic_energy < 0.0 {
201        return None;
202    }
203
204    let result = initial_kinetic_energy + work;
205
206    if result < 0.0 {
207        return None;
208    }
209
210    finite(result)
211}
212
213/// Computes initial kinetic energy from a final kinetic energy and applied work.
214///
215/// Formula: `KE_initial = KE_final - W`
216///
217/// Returns `None` when `final_kinetic_energy` is negative, when any input is not finite, when
218/// the computed result is negative, or when the computed result is not finite.
219#[must_use]
220pub fn initial_kinetic_energy_from_work(final_kinetic_energy: f64, work: f64) -> Option<f64> {
221    if !final_kinetic_energy.is_finite() || !work.is_finite() || final_kinetic_energy < 0.0 {
222        return None;
223    }
224
225    let result = final_kinetic_energy - work;
226
227    if result < 0.0 {
228        return None;
229    }
230
231    finite(result)
232}
233
234/// Computes work done by an ideal spring force over a displacement interval.
235///
236/// Formula: `W = 0.5 * k * (x_initial^2 - x_final^2)`
237///
238/// Returns `None` when `spring_constant` is negative, when any input is not finite, or when the
239/// computed result is not finite.
240///
241/// # Examples
242///
243/// ```rust
244/// use use_work::spring_work;
245///
246/// assert_eq!(spring_work(100.0, 0.5, 0.0), Some(12.5));
247/// assert_eq!(spring_work(100.0, 0.0, 0.5), Some(-12.5));
248/// ```
249#[must_use]
250pub fn spring_work(
251    spring_constant: f64,
252    initial_displacement: f64,
253    final_displacement: f64,
254) -> Option<f64> {
255    if !spring_constant.is_finite()
256        || !initial_displacement.is_finite()
257        || !final_displacement.is_finite()
258        || spring_constant < 0.0
259    {
260        return None;
261    }
262
263    let initial_squared = initial_displacement * initial_displacement;
264    let final_squared = final_displacement * final_displacement;
265
266    finite(0.5 * spring_constant * (initial_squared - final_squared))
267}
268
269/// Computes the spring potential energy stored at a displacement.
270///
271/// Formula: `U = 0.5 * k * x^2`
272///
273/// Returns `None` when `spring_constant` is negative, when any input is not finite, or when the
274/// computed result is not finite.
275#[must_use]
276pub fn spring_potential_energy(spring_constant: f64, displacement: f64) -> Option<f64> {
277    if !spring_constant.is_finite() || !displacement.is_finite() || spring_constant < 0.0 {
278        return None;
279    }
280
281    finite(0.5 * spring_constant * displacement.powi(2))
282}
283
284/// Computes work done against gravity near a surface.
285///
286/// Formula: `W = m * g * h`
287///
288/// Returns `None` when `mass` is negative, when any input is not finite, or when the computed
289/// result is not finite. Negative heights are allowed.
290///
291/// # Examples
292///
293/// ```rust
294/// use use_work::work_against_gravity;
295///
296/// let work = work_against_gravity(2.0, 9.806_65, 10.0).unwrap();
297///
298/// assert!((work - 196.133).abs() < 1e-12);
299/// ```
300#[must_use]
301pub fn work_against_gravity(
302    mass: f64,
303    gravitational_acceleration: f64,
304    height: f64,
305) -> Option<f64> {
306    if !mass.is_finite()
307        || !gravitational_acceleration.is_finite()
308        || !height.is_finite()
309        || mass < 0.0
310    {
311        return None;
312    }
313
314    finite(mass * gravitational_acceleration * height)
315}
316
317/// Computes work done by gravity near a surface.
318///
319/// Formula: `W = -m * g * Δh`
320///
321/// Returns `None` when `mass` is negative, when any input is not finite, or when the computed
322/// result is not finite. Negative height changes are allowed.
323#[must_use]
324pub fn work_by_gravity(
325    mass: f64,
326    gravitational_acceleration: f64,
327    height_change: f64,
328) -> Option<f64> {
329    if !mass.is_finite()
330        || !gravitational_acceleration.is_finite()
331        || !height_change.is_finite()
332        || mass < 0.0
333    {
334        return None;
335    }
336
337    finite(-mass * gravitational_acceleration * height_change)
338}
339
340/// Computes work done by kinetic friction.
341///
342/// Formula: `W = -f_k * abs(d)`
343///
344/// Returns `None` when `friction_force_magnitude` is negative, when any input is not finite, or
345/// when the computed result is not finite.
346#[must_use]
347pub fn work_by_friction(friction_force_magnitude: f64, displacement: f64) -> Option<f64> {
348    if !friction_force_magnitude.is_finite()
349        || !displacement.is_finite()
350        || friction_force_magnitude < 0.0
351    {
352        return None;
353    }
354
355    finite(-friction_force_magnitude * displacement.abs())
356}
357
358/// Constant-force work inputs for repeated calculations.
359#[derive(Debug, Clone, Copy, PartialEq)]
360pub struct ConstantForceWork {
361    pub force: f64,
362    pub displacement: f64,
363}
364
365impl ConstantForceWork {
366    /// Creates a constant-force work helper from finite inputs.
367    ///
368    /// Returns `None` when either input is not finite.
369    #[must_use]
370    pub const fn new(force: f64, displacement: f64) -> Option<Self> {
371        if !force.is_finite() || !displacement.is_finite() {
372            return None;
373        }
374
375        Some(Self {
376            force,
377            displacement,
378        })
379    }
380
381    /// Computes the work represented by this constant-force relationship.
382    ///
383    /// # Examples
384    ///
385    /// ```rust
386    /// use use_work::ConstantForceWork;
387    ///
388    /// let helper = ConstantForceWork::new(10.0, 2.0).unwrap();
389    ///
390    /// assert_eq!(helper.work(), Some(20.0));
391    /// ```
392    #[must_use]
393    pub fn work(&self) -> Option<f64> {
394        work(self.force, self.displacement)
395    }
396
397    /// Computes the work represented by this constant-force relationship at an angle.
398    #[must_use]
399    pub fn work_at_angle(&self, angle_radians: f64) -> Option<f64> {
400        work_at_angle(self.force, self.displacement, angle_radians)
401    }
402}
403
404#[cfg(test)]
405#[allow(clippy::float_cmp)]
406mod tests {
407    use super::{
408        ConstantForceWork, displacement_from_work, final_kinetic_energy_from_work, force_from_work,
409        initial_kinetic_energy_from_work, net_work, spring_potential_energy, spring_work, work,
410        work_against_gravity, work_at_angle, work_at_angle_degrees, work_by_friction,
411        work_by_gravity, work_from_force_samples, work_from_kinetic_energy_change,
412    };
413
414    fn approx_eq(left: f64, right: f64, tolerance: f64) {
415        let delta = (left - right).abs();
416
417        assert!(
418            delta <= tolerance,
419            "left={left} right={right} delta={delta} tolerance={tolerance}"
420        );
421    }
422
423    #[test]
424    fn work_handles_basic_cases() {
425        assert_eq!(work(10.0, 2.0), Some(20.0));
426        assert_eq!(work(-10.0, 2.0), Some(-20.0));
427        assert_eq!(work(10.0, -2.0), Some(-20.0));
428    }
429
430    #[test]
431    fn angled_work_handles_radians_and_degrees() {
432        assert_eq!(work_at_angle(10.0, 2.0, 0.0), Some(20.0));
433        approx_eq(work_at_angle_degrees(10.0, 2.0, 60.0).unwrap(), 10.0, 1e-12);
434        approx_eq(work_at_angle_degrees(10.0, 2.0, 90.0).unwrap(), 0.0, 1e-10);
435    }
436
437    #[test]
438    fn inverse_helpers_require_non_zero_divisors() {
439        assert_eq!(force_from_work(20.0, 2.0), Some(10.0));
440        assert_eq!(force_from_work(20.0, 0.0), None);
441        assert_eq!(displacement_from_work(20.0, 10.0), Some(2.0));
442        assert_eq!(displacement_from_work(20.0, 0.0), None);
443    }
444
445    #[test]
446    fn net_work_and_force_samples_cover_common_cases() {
447        assert_eq!(net_work(&[10.0, -2.0, 5.0]), Some(13.0));
448        assert_eq!(net_work(&[]), Some(0.0));
449        assert_eq!(
450            work_from_force_samples(&[1.0, 2.0, 3.0], &[10.0, 20.0, 30.0]),
451            Some(140.0)
452        );
453        assert_eq!(work_from_force_samples(&[1.0], &[10.0, 20.0]), None);
454    }
455
456    #[test]
457    fn work_energy_relationships_cover_forward_and_inverse_paths() {
458        assert_eq!(work_from_kinetic_energy_change(5.0, 12.0), Some(7.0));
459        assert_eq!(work_from_kinetic_energy_change(12.0, 5.0), Some(-7.0));
460        assert_eq!(work_from_kinetic_energy_change(-1.0, 5.0), None);
461
462        assert_eq!(final_kinetic_energy_from_work(5.0, 7.0), Some(12.0));
463        assert_eq!(final_kinetic_energy_from_work(5.0, -10.0), None);
464
465        assert_eq!(initial_kinetic_energy_from_work(12.0, 7.0), Some(5.0));
466        assert_eq!(initial_kinetic_energy_from_work(5.0, 10.0), None);
467    }
468
469    #[test]
470    fn spring_helpers_cover_energy_and_work() {
471        assert_eq!(spring_potential_energy(100.0, 0.5), Some(12.5));
472        assert_eq!(spring_work(100.0, 0.5, 0.0), Some(12.5));
473        assert_eq!(spring_work(100.0, 0.0, 0.5), Some(-12.5));
474        assert_eq!(spring_work(-100.0, 0.5, 0.0), None);
475    }
476
477    #[test]
478    fn gravity_and_friction_helpers_cover_common_cases() {
479        approx_eq(
480            work_against_gravity(2.0, 9.806_65, 10.0).unwrap(),
481            196.133,
482            1e-12,
483        );
484        approx_eq(
485            work_by_gravity(2.0, 9.806_65, 10.0).unwrap(),
486            -196.133,
487            1e-12,
488        );
489
490        assert_eq!(work_by_friction(5.0, 10.0), Some(-50.0));
491        assert_eq!(work_by_friction(5.0, -10.0), Some(-50.0));
492        assert_eq!(work_by_friction(-5.0, 10.0), None);
493    }
494
495    #[test]
496    fn constant_force_work_requires_finite_inputs() {
497        assert_eq!(
498            ConstantForceWork::new(10.0, 2.0).unwrap().work(),
499            Some(20.0)
500        );
501        assert_eq!(ConstantForceWork::new(f64::NAN, 2.0), None);
502    }
503}