decimal_scaled/trig.rs
1//! Trigonometric, hyperbolic, and angle-conversion methods for [`I128`].
2//!
3//! # Methods
4//!
5//! Fifteen methods, all routed through the f64 bridge:
6//!
7//! - **Forward trig (radians input):** [`I128::sin`] / [`I128::cos`] /
8//! [`I128::tan`].
9//! - **Inverse trig (returns radians):** [`I128::asin`] / [`I128::acos`]
10//! / [`I128::atan`] / [`I128::atan2`].
11//! - **Hyperbolic:** [`I128::sinh`] / [`I128::cosh`] / [`I128::tanh`] /
12//! [`I128::asinh`] / [`I128::acosh`] / [`I128::atanh`].
13//! - **Angle conversions:** [`I128::to_degrees`] / [`I128::to_radians`].
14//!
15//! # Feature gating
16//!
17//! Every method here calls an inherent `f64` method (`f64::sin`,
18//! `f64::cos`, `f64::tan`, `f64::asin`, `f64::acos`, `f64::atan`,
19//! `f64::atan2`, `f64::sinh`, `f64::cosh`, `f64::tanh`, `f64::asinh`,
20//! `f64::acosh`, `f64::atanh`, `f64::to_degrees`, `f64::to_radians`),
21//! which are only available in `std` — they delegate to platform or
22//! hardware intrinsics that are not in `core`. The whole module is
23//! gated `#[cfg(feature = "std")]` at the `mod trig;` declaration in
24//! `lib.rs` rather than repeating the gate on each method.
25//!
26//! `no_std` users that need trigonometric or hyperbolic functions can
27//! compose them externally via `libm` or hardware-specific intrinsics.
28//!
29//! # Precision
30//!
31//! All methods in this module are **Lossy**: the `I128` value is
32//! converted to `f64` via `to_f64_lossy`, the corresponding `f64`
33//! intrinsic is applied, and the result is converted back via
34//! `from_f64_lossy`. The f64 round-trip introduces up to one LSB of
35//! quantisation error per conversion step.
36//!
37//! IEEE 754 mandates correct rounding for `f64::sqrt` but not for
38//! transcendental functions. In practice mainstream libm implementations
39//! (glibc, msvcrt, macOS libm, musl) produce bit-identical results for
40//! identical inputs, so results are bit-deterministic on supported
41//! platforms in practice.
42//!
43//! # `atan2` signature
44//!
45//! `f64::atan2(self, other)` treats `self` as `y` and `other` as `x`.
46//! This module matches that signature exactly so generic numeric code
47//! calling `y.atan2(x)` works with `T = I128`.
48
49use crate::core_type::I128;
50
51impl<const SCALE: u32> I128<SCALE> {
52 // ── Forward trig (radians input) ──────────────────────────────────
53
54 /// Sine of `self`, where `self` is in radians.
55 ///
56 /// # Precision
57 ///
58 /// Lossy: involves f64 at some point; result may lose precision.
59 ///
60 /// # Examples
61 ///
62 /// ```ignore
63 /// # #[cfg(feature = "std")]
64 /// # {
65 /// use decimal_scaled::I128s12;
66 /// // sin(0) == 0 (bit-exact: f64::sin(0.0) == 0.0).
67 /// assert_eq!(I128s12::ZERO.sin(), I128s12::ZERO);
68 /// # }
69 /// ```
70 #[inline]
71 #[must_use]
72 pub fn sin(self) -> Self {
73 Self::from_f64_lossy(self.to_f64_lossy().sin())
74 }
75
76 /// Cosine of `self`, where `self` is in radians.
77 ///
78 /// # Precision
79 ///
80 /// Lossy: involves f64 at some point; result may lose precision.
81 ///
82 /// # Examples
83 ///
84 /// ```ignore
85 /// # #[cfg(feature = "std")]
86 /// # {
87 /// use decimal_scaled::I128s12;
88 /// // cos(0) == 1 (bit-exact: f64::cos(0.0) == 1.0).
89 /// assert_eq!(I128s12::ZERO.cos(), I128s12::ONE);
90 /// # }
91 /// ```
92 #[inline]
93 #[must_use]
94 pub fn cos(self) -> Self {
95 Self::from_f64_lossy(self.to_f64_lossy().cos())
96 }
97
98 /// Tangent of `self`, where `self` is in radians.
99 ///
100 /// `f64::tan` returns very large magnitudes near odd multiples of
101 /// `pi/2` and infinity at the limit. Inputs that drive the f64
102 /// result outside `[I128::MIN, I128::MAX]` saturate per
103 /// [`Self::from_f64_lossy`].
104 ///
105 /// # Precision
106 ///
107 /// Lossy: involves f64 at some point; result may lose precision.
108 ///
109 /// # Examples
110 ///
111 /// ```ignore
112 /// # #[cfg(feature = "std")]
113 /// # {
114 /// use decimal_scaled::I128s12;
115 /// // tan(0) == 0 (bit-exact: f64::tan(0.0) == 0.0).
116 /// assert_eq!(I128s12::ZERO.tan(), I128s12::ZERO);
117 /// # }
118 /// ```
119 #[inline]
120 #[must_use]
121 pub fn tan(self) -> Self {
122 Self::from_f64_lossy(self.to_f64_lossy().tan())
123 }
124
125 // ── Inverse trig (returns radians) ────────────────────────────────
126
127 /// Arcsine of `self`. Returns radians in `[-pi/2, pi/2]`.
128 ///
129 /// `f64::asin` returns NaN for inputs outside `[-1, 1]`, which
130 /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
131 ///
132 /// # Precision
133 ///
134 /// Lossy: involves f64 at some point; result may lose precision.
135 ///
136 /// # Examples
137 ///
138 /// ```ignore
139 /// # #[cfg(feature = "std")]
140 /// # {
141 /// use decimal_scaled::I128s12;
142 /// // asin(0) == 0.
143 /// assert_eq!(I128s12::ZERO.asin(), I128s12::ZERO);
144 /// # }
145 /// ```
146 #[inline]
147 #[must_use]
148 pub fn asin(self) -> Self {
149 Self::from_f64_lossy(self.to_f64_lossy().asin())
150 }
151
152 /// Arccosine of `self`. Returns radians in `[0, pi]`.
153 ///
154 /// `f64::acos` returns NaN for inputs outside `[-1, 1]`, which
155 /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
156 ///
157 /// # Precision
158 ///
159 /// Lossy: involves f64 at some point; result may lose precision.
160 ///
161 /// # Examples
162 ///
163 /// ```ignore
164 /// # #[cfg(feature = "std")]
165 /// # {
166 /// use decimal_scaled::{I128s12, DecimalConsts};
167 /// // acos(1) == 0.
168 /// assert_eq!(I128s12::ONE.acos(), I128s12::ZERO);
169 /// # }
170 /// ```
171 #[inline]
172 #[must_use]
173 pub fn acos(self) -> Self {
174 Self::from_f64_lossy(self.to_f64_lossy().acos())
175 }
176
177 /// Arctangent of `self`. Returns radians in `(-pi/2, pi/2)`.
178 ///
179 /// Defined for the entire real line.
180 ///
181 /// # Precision
182 ///
183 /// Lossy: involves f64 at some point; result may lose precision.
184 ///
185 /// # Examples
186 ///
187 /// ```ignore
188 /// # #[cfg(feature = "std")]
189 /// # {
190 /// use decimal_scaled::I128s12;
191 /// // atan(0) == 0.
192 /// assert_eq!(I128s12::ZERO.atan(), I128s12::ZERO);
193 /// # }
194 /// ```
195 #[inline]
196 #[must_use]
197 pub fn atan(self) -> Self {
198 Self::from_f64_lossy(self.to_f64_lossy().atan())
199 }
200
201 /// Four-quadrant arctangent of `self` (`y`) over `other` (`x`).
202 /// Returns radians in `(-pi, pi]`.
203 ///
204 /// Signature matches `f64::atan2(self, other)`: the receiver is
205 /// `y` and the argument is `x`.
206 ///
207 /// # Precision
208 ///
209 /// Lossy: involves f64 at some point; result may lose precision.
210 ///
211 /// # Examples
212 ///
213 /// ```ignore
214 /// # #[cfg(feature = "std")]
215 /// # {
216 /// use decimal_scaled::{I128s12, DecimalConsts};
217 /// // atan2(1, 1) ~= pi/4 (45 degrees, first quadrant).
218 /// let one = I128s12::ONE;
219 /// let result = one.atan2(one); // approximately I128s12::quarter_pi()
220 /// # }
221 /// ```
222 #[inline]
223 #[must_use]
224 pub fn atan2(self, other: Self) -> Self {
225 Self::from_f64_lossy(self.to_f64_lossy().atan2(other.to_f64_lossy()))
226 }
227
228 // ── Hyperbolic ────────────────────────────────────────────────────
229
230 /// Hyperbolic sine of `self`.
231 ///
232 /// Defined for the entire real line. Saturates at large magnitudes
233 /// per [`Self::from_f64_lossy`].
234 ///
235 /// # Precision
236 ///
237 /// Lossy: involves f64 at some point; result may lose precision.
238 ///
239 /// # Examples
240 ///
241 /// ```ignore
242 /// # #[cfg(feature = "std")]
243 /// # {
244 /// use decimal_scaled::I128s12;
245 /// // sinh(0) == 0.
246 /// assert_eq!(I128s12::ZERO.sinh(), I128s12::ZERO);
247 /// # }
248 /// ```
249 #[inline]
250 #[must_use]
251 pub fn sinh(self) -> Self {
252 Self::from_f64_lossy(self.to_f64_lossy().sinh())
253 }
254
255 /// Hyperbolic cosine of `self`.
256 ///
257 /// Defined for the entire real line; result is always >= 1.
258 /// Saturates at large magnitudes per [`Self::from_f64_lossy`].
259 ///
260 /// # Precision
261 ///
262 /// Lossy: involves f64 at some point; result may lose precision.
263 ///
264 /// # Examples
265 ///
266 /// ```ignore
267 /// # #[cfg(feature = "std")]
268 /// # {
269 /// use decimal_scaled::I128s12;
270 /// // cosh(0) == 1.
271 /// assert_eq!(I128s12::ZERO.cosh(), I128s12::ONE);
272 /// # }
273 /// ```
274 #[inline]
275 #[must_use]
276 pub fn cosh(self) -> Self {
277 Self::from_f64_lossy(self.to_f64_lossy().cosh())
278 }
279
280 /// Hyperbolic tangent of `self`.
281 ///
282 /// Defined for the entire real line; range is `(-1, 1)`.
283 ///
284 /// # Precision
285 ///
286 /// Lossy: involves f64 at some point; result may lose precision.
287 ///
288 /// # Examples
289 ///
290 /// ```ignore
291 /// # #[cfg(feature = "std")]
292 /// # {
293 /// use decimal_scaled::I128s12;
294 /// // tanh(0) == 0.
295 /// assert_eq!(I128s12::ZERO.tanh(), I128s12::ZERO);
296 /// # }
297 /// ```
298 #[inline]
299 #[must_use]
300 pub fn tanh(self) -> Self {
301 Self::from_f64_lossy(self.to_f64_lossy().tanh())
302 }
303
304 /// Inverse hyperbolic sine of `self`.
305 ///
306 /// Defined for the entire real line.
307 ///
308 /// # Precision
309 ///
310 /// Lossy: involves f64 at some point; result may lose precision.
311 ///
312 /// # Examples
313 ///
314 /// ```ignore
315 /// # #[cfg(feature = "std")]
316 /// # {
317 /// use decimal_scaled::I128s12;
318 /// // asinh(0) == 0.
319 /// assert_eq!(I128s12::ZERO.asinh(), I128s12::ZERO);
320 /// # }
321 /// ```
322 #[inline]
323 #[must_use]
324 pub fn asinh(self) -> Self {
325 Self::from_f64_lossy(self.to_f64_lossy().asinh())
326 }
327
328 /// Inverse hyperbolic cosine of `self`.
329 ///
330 /// `f64::acosh` returns NaN for inputs less than 1, which
331 /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
332 ///
333 /// # Precision
334 ///
335 /// Lossy: involves f64 at some point; result may lose precision.
336 ///
337 /// # Examples
338 ///
339 /// ```ignore
340 /// # #[cfg(feature = "std")]
341 /// # {
342 /// use decimal_scaled::I128s12;
343 /// // acosh(1) == 0.
344 /// assert_eq!(I128s12::ONE.acosh(), I128s12::ZERO);
345 /// # }
346 /// ```
347 #[inline]
348 #[must_use]
349 pub fn acosh(self) -> Self {
350 Self::from_f64_lossy(self.to_f64_lossy().acosh())
351 }
352
353 /// Inverse hyperbolic tangent of `self`.
354 ///
355 /// `f64::atanh` returns NaN for inputs outside `(-1, 1)`, which
356 /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
357 ///
358 /// # Precision
359 ///
360 /// Lossy: involves f64 at some point; result may lose precision.
361 ///
362 /// # Examples
363 ///
364 /// ```ignore
365 /// # #[cfg(feature = "std")]
366 /// # {
367 /// use decimal_scaled::I128s12;
368 /// // atanh(0) == 0.
369 /// assert_eq!(I128s12::ZERO.atanh(), I128s12::ZERO);
370 /// # }
371 /// ```
372 #[inline]
373 #[must_use]
374 pub fn atanh(self) -> Self {
375 Self::from_f64_lossy(self.to_f64_lossy().atanh())
376 }
377
378 // ── Angle conversions ─────────────────────────────────────────────
379
380 /// Convert radians to degrees: `self * (180 / pi)`.
381 ///
382 /// Routed through `f64::to_degrees` so results match the de facto
383 /// reference produced by the rest of the Rust ecosystem. Multiplying
384 /// by a precomputed `I128` factor derived from `I128::pi()` would
385 /// diverge from f64 by a 1-LSB rescale rounding without any
386 /// practical determinism gain, since the f64 bridge is already the
387 /// precision floor.
388 ///
389 /// # Precision
390 ///
391 /// Lossy: involves f64 at some point; result may lose precision.
392 ///
393 /// # Examples
394 ///
395 /// ```ignore
396 /// # #[cfg(feature = "std")]
397 /// # {
398 /// use decimal_scaled::I128s12;
399 /// // to_degrees(0) == 0.
400 /// assert_eq!(I128s12::ZERO.to_degrees(), I128s12::ZERO);
401 /// # }
402 /// ```
403 #[inline]
404 #[must_use]
405 pub fn to_degrees(self) -> Self {
406 Self::from_f64_lossy(self.to_f64_lossy().to_degrees())
407 }
408
409 /// Convert degrees to radians: `self * (pi / 180)`.
410 ///
411 /// Routed through `f64::to_radians`. See [`Self::to_degrees`] for
412 /// the rationale.
413 ///
414 /// # Precision
415 ///
416 /// Lossy: involves f64 at some point; result may lose precision.
417 ///
418 /// # Examples
419 ///
420 /// ```ignore
421 /// # #[cfg(feature = "std")]
422 /// # {
423 /// use decimal_scaled::I128s12;
424 /// // to_radians(0) == 0.
425 /// assert_eq!(I128s12::ZERO.to_radians(), I128s12::ZERO);
426 /// # }
427 /// ```
428 #[inline]
429 #[must_use]
430 pub fn to_radians(self) -> Self {
431 Self::from_f64_lossy(self.to_f64_lossy().to_radians())
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use crate::consts::DecimalConsts;
438 use crate::core_type::I128s12;
439
440 // Allow 2 LSB of tolerance for single f64 round-trip operations.
441 const TWO_LSB: i128 = 2;
442
443 // Allow 4 LSB of tolerance for operations that chain multiple trig
444 // calls, each adding up to 1 LSB of quantisation slack.
445 const FOUR_LSB: i128 = 4;
446
447 // Allow 32 LSB when comparing angle-conversion results against exact
448 // integer targets (180, 90, 45 degrees). The I128::pi() constant has
449 // more digits than f64 can represent; the rounding error multiplies
450 // by ~57.3 during the degrees conversion, landing within ~30 LSB of
451 // the exact integer at SCALE = 12.
452 const ANGLE_TOLERANCE_LSB: i128 = 32;
453
454 fn within_lsb(actual: I128s12, expected: I128s12, lsb: i128) -> bool {
455 let diff = (actual.to_bits() - expected.to_bits()).abs();
456 diff <= lsb
457 }
458
459 // ── Forward trig ──────────────────────────────────────────────────
460
461 /// `sin(0) == 0` -- bit-exact via `f64::sin(0.0) == 0.0`.
462 #[test]
463 fn sin_zero_is_zero() {
464 assert_eq!(I128s12::ZERO.sin(), I128s12::ZERO);
465 }
466
467 /// `cos(0) == 1` -- bit-exact via `f64::cos(0.0) == 1.0`.
468 #[test]
469 fn cos_zero_is_one() {
470 assert_eq!(I128s12::ZERO.cos(), I128s12::ONE);
471 }
472
473 /// `tan(0) == 0` -- bit-exact via `f64::tan(0.0) == 0.0`.
474 #[test]
475 fn tan_zero_is_zero() {
476 assert_eq!(I128s12::ZERO.tan(), I128s12::ZERO);
477 }
478
479 /// Pythagorean identity: `sin^2(x) + cos^2(x) ~= 1` within 4 LSB
480 /// for representative values of `x`. Values are chosen to be well
481 /// away from any well-known mathematical constant.
482 #[test]
483 fn sin_squared_plus_cos_squared_is_one() {
484 for raw in [
485 1_234_567_890_123_i128, // ~1.234567...
486 -2_345_678_901_234_i128, // ~-2.345678...
487 500_000_000_000_i128, // 0.5
488 -500_000_000_000_i128, // -0.5
489 4_567_891_234_567_i128, // ~4.567891...
490 ] {
491 let x = I128s12::from_bits(raw);
492 let s = x.sin();
493 let c = x.cos();
494 let sum = (s * s) + (c * c);
495 assert!(
496 within_lsb(sum, I128s12::ONE, FOUR_LSB),
497 "sin^2 + cos^2 != 1 for raw={raw}: got bits {} (delta {})",
498 sum.to_bits(),
499 (sum.to_bits() - I128s12::ONE.to_bits()).abs(),
500 );
501 }
502 }
503
504 // ── Inverse trig ──────────────────────────────────────────────────
505
506 /// `asin(0) == 0` -- bit-exact.
507 #[test]
508 fn asin_zero_is_zero() {
509 assert_eq!(I128s12::ZERO.asin(), I128s12::ZERO);
510 }
511
512 /// `acos(1) == 0` -- bit-exact via `f64::acos(1.0) == 0.0`.
513 #[test]
514 fn acos_one_is_zero() {
515 assert_eq!(I128s12::ONE.acos(), I128s12::ZERO);
516 }
517
518 /// `acos(0) ~= pi/2` within 4 LSB.
519 #[test]
520 fn acos_zero_is_half_pi() {
521 let result = I128s12::ZERO.acos();
522 assert!(
523 within_lsb(result, I128s12::half_pi(), FOUR_LSB),
524 "acos(0) bits {}, half_pi bits {}",
525 result.to_bits(),
526 I128s12::half_pi().to_bits(),
527 );
528 }
529
530 /// `atan(0) == 0` -- bit-exact via `f64::atan(0.0) == 0.0`.
531 #[test]
532 fn atan_zero_is_zero() {
533 assert_eq!(I128s12::ZERO.atan(), I128s12::ZERO);
534 }
535
536 /// Round-trip identity: `asin(sin(x)) ~= x` for `x` in
537 /// `[-pi/2, pi/2]`. Values stay within the principal branch.
538 #[test]
539 fn asin_of_sin_round_trip() {
540 for raw in [
541 123_456_789_012_i128, // ~0.123456...
542 -123_456_789_012_i128, // ~-0.123456...
543 456_789_012_345_i128, // ~0.456789...
544 -456_789_012_345_i128, // ~-0.456789...
545 1_234_567_890_123_i128, // ~1.234567... (well inside pi/2)
546 -1_234_567_890_123_i128, // ~-1.234567...
547 ] {
548 let x = I128s12::from_bits(raw);
549 let recovered = x.sin().asin();
550 assert!(
551 within_lsb(recovered, x, FOUR_LSB),
552 "asin(sin(x)) != x for raw={raw}: got bits {} (delta {})",
553 recovered.to_bits(),
554 (recovered.to_bits() - x.to_bits()).abs(),
555 );
556 }
557 }
558
559 // ── atan2 ─────────────────────────────────────────────────────────
560
561 /// `atan2(1, 1) ~= pi/4` (first-quadrant 45 degrees).
562 #[test]
563 fn atan2_first_quadrant_diagonal() {
564 let one = I128s12::ONE;
565 let result = one.atan2(one);
566 assert!(
567 within_lsb(result, I128s12::quarter_pi(), TWO_LSB),
568 "atan2(1, 1) bits {}, quarter_pi bits {}",
569 result.to_bits(),
570 I128s12::quarter_pi().to_bits(),
571 );
572 }
573
574 /// `atan2(-1, -1) ~= -3*pi/4` (third-quadrant correctness).
575 #[test]
576 fn atan2_third_quadrant_diagonal() {
577 let neg_one = -I128s12::ONE;
578 let result = neg_one.atan2(neg_one);
579 let three = I128s12::from_int(3);
580 let expected = -(I128s12::quarter_pi() * three);
581 assert!(
582 within_lsb(result, expected, TWO_LSB),
583 "atan2(-1, -1) bits {}, expected -3pi/4 bits {}",
584 result.to_bits(),
585 expected.to_bits(),
586 );
587 }
588
589 /// `atan2(1, -1) ~= 3*pi/4` (second-quadrant correctness).
590 #[test]
591 fn atan2_second_quadrant_diagonal() {
592 let one = I128s12::ONE;
593 let neg_one = -I128s12::ONE;
594 let result = one.atan2(neg_one);
595 let three = I128s12::from_int(3);
596 let expected = I128s12::quarter_pi() * three;
597 assert!(
598 within_lsb(result, expected, TWO_LSB),
599 "atan2(1, -1) bits {}, expected 3pi/4 bits {}",
600 result.to_bits(),
601 expected.to_bits(),
602 );
603 }
604
605 /// `atan2(-1, 1) ~= -pi/4` (fourth-quadrant correctness).
606 #[test]
607 fn atan2_fourth_quadrant_diagonal() {
608 let one = I128s12::ONE;
609 let neg_one = -I128s12::ONE;
610 let result = neg_one.atan2(one);
611 let expected = -I128s12::quarter_pi();
612 assert!(
613 within_lsb(result, expected, TWO_LSB),
614 "atan2(-1, 1) bits {}, expected -pi/4 bits {}",
615 result.to_bits(),
616 expected.to_bits(),
617 );
618 }
619
620 /// `atan2(0, 1) == 0` (positive x-axis is bit-exact).
621 #[test]
622 fn atan2_positive_x_axis_is_zero() {
623 let zero = I128s12::ZERO;
624 let one = I128s12::ONE;
625 assert_eq!(zero.atan2(one), I128s12::ZERO);
626 }
627
628 // ── Hyperbolic ────────────────────────────────────────────────────
629
630 /// `sinh(0) == 0` -- bit-exact via `f64::sinh(0.0) == 0.0`.
631 #[test]
632 fn sinh_zero_is_zero() {
633 assert_eq!(I128s12::ZERO.sinh(), I128s12::ZERO);
634 }
635
636 /// `cosh(0) == 1` -- bit-exact via `f64::cosh(0.0) == 1.0`.
637 #[test]
638 fn cosh_zero_is_one() {
639 assert_eq!(I128s12::ZERO.cosh(), I128s12::ONE);
640 }
641
642 /// `tanh(0) == 0` -- bit-exact via `f64::tanh(0.0) == 0.0`.
643 #[test]
644 fn tanh_zero_is_zero() {
645 assert_eq!(I128s12::ZERO.tanh(), I128s12::ZERO);
646 }
647
648 /// `asinh(0) == 0` -- bit-exact.
649 #[test]
650 fn asinh_zero_is_zero() {
651 assert_eq!(I128s12::ZERO.asinh(), I128s12::ZERO);
652 }
653
654 /// `acosh(1) == 0` -- bit-exact via `f64::acosh(1.0) == 0.0`.
655 #[test]
656 fn acosh_one_is_zero() {
657 assert_eq!(I128s12::ONE.acosh(), I128s12::ZERO);
658 }
659
660 /// `atanh(0) == 0` -- bit-exact.
661 #[test]
662 fn atanh_zero_is_zero() {
663 assert_eq!(I128s12::ZERO.atanh(), I128s12::ZERO);
664 }
665
666 /// Identity: `cosh^2(x) - sinh^2(x) == 1` within 4 LSB for
667 /// representative values of `x`.
668 #[test]
669 fn cosh_squared_minus_sinh_squared_is_one() {
670 for raw in [
671 500_000_000_000_i128, // 0.5
672 -500_000_000_000_i128, // -0.5
673 1_234_567_890_123_i128, // ~1.234567
674 -1_234_567_890_123_i128, // ~-1.234567
675 2_500_000_000_000_i128, // 2.5
676 ] {
677 let x = I128s12::from_bits(raw);
678 let ch = x.cosh();
679 let sh = x.sinh();
680 let diff = (ch * ch) - (sh * sh);
681 assert!(
682 within_lsb(diff, I128s12::ONE, FOUR_LSB),
683 "cosh^2 - sinh^2 != 1 for raw={raw}: got bits {} (delta {})",
684 diff.to_bits(),
685 (diff.to_bits() - I128s12::ONE.to_bits()).abs(),
686 );
687 }
688 }
689
690 // ── Angle conversions ─────────────────────────────────────────────
691
692 /// `to_degrees(pi) ~= 180` within `ANGLE_TOLERANCE_LSB`. The
693 /// tolerance is dominated by f64's limited precision on `pi`,
694 /// amplified by ~57.3 during the degrees conversion.
695 #[test]
696 fn to_degrees_pi_is_180() {
697 let pi = I128s12::pi();
698 let result = pi.to_degrees();
699 let expected = I128s12::from_int(180);
700 assert!(
701 within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
702 "to_degrees(pi) bits {}, expected 180 bits {} (delta {})",
703 result.to_bits(),
704 expected.to_bits(),
705 (result.to_bits() - expected.to_bits()).abs(),
706 );
707 }
708
709 /// `to_radians(180) ~= pi` within `ANGLE_TOLERANCE_LSB`.
710 #[test]
711 fn to_radians_180_is_pi() {
712 let one_eighty = I128s12::from_int(180);
713 let result = one_eighty.to_radians();
714 let expected = I128s12::pi();
715 assert!(
716 within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
717 "to_radians(180) bits {}, expected pi bits {} (delta {})",
718 result.to_bits(),
719 expected.to_bits(),
720 (result.to_bits() - expected.to_bits()).abs(),
721 );
722 }
723
724 /// `to_degrees(0) == 0` -- bit-exact (0 * anything == 0).
725 #[test]
726 fn to_degrees_zero_is_zero() {
727 assert_eq!(I128s12::ZERO.to_degrees(), I128s12::ZERO);
728 }
729
730 /// `to_radians(0) == 0` -- bit-exact.
731 #[test]
732 fn to_radians_zero_is_zero() {
733 assert_eq!(I128s12::ZERO.to_radians(), I128s12::ZERO);
734 }
735
736 /// Round-trip: `to_radians(to_degrees(x)) ~= x` within 4 LSB
737 /// (two f64 round-trips).
738 #[test]
739 fn to_radians_to_degrees_round_trip() {
740 for raw in [
741 500_000_000_000_i128, // 0.5
742 -500_000_000_000_i128, // -0.5
743 1_234_567_890_123_i128, // ~1.234567
744 -2_345_678_901_234_i128, // ~-2.345678
745 ] {
746 let x = I128s12::from_bits(raw);
747 let recovered = x.to_degrees().to_radians();
748 assert!(
749 within_lsb(recovered, x, FOUR_LSB),
750 "to_radians(to_degrees(x)) != x for raw={raw}: got bits {} (delta {})",
751 recovered.to_bits(),
752 (recovered.to_bits() - x.to_bits()).abs(),
753 );
754 }
755 }
756
757 /// `to_degrees(half_pi) ~= 90` within `ANGLE_TOLERANCE_LSB`.
758 #[test]
759 fn to_degrees_half_pi_is_90() {
760 let result = I128s12::half_pi().to_degrees();
761 let expected = I128s12::from_int(90);
762 assert!(
763 within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
764 "to_degrees(half_pi) bits {}, expected 90 bits {} (delta {})",
765 result.to_bits(),
766 expected.to_bits(),
767 (result.to_bits() - expected.to_bits()).abs(),
768 );
769 }
770
771 /// `to_degrees(quarter_pi) ~= 45` within `ANGLE_TOLERANCE_LSB`.
772 #[test]
773 fn to_degrees_quarter_pi_is_45() {
774 let result = I128s12::quarter_pi().to_degrees();
775 let expected = I128s12::from_int(45);
776 assert!(
777 within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
778 "to_degrees(quarter_pi) bits {}, expected 45 bits {} (delta {})",
779 result.to_bits(),
780 expected.to_bits(),
781 (result.to_bits() - expected.to_bits()).abs(),
782 );
783 }
784
785 // ── Cross-method consistency ──────────────────────────────────────
786
787 /// `tan(x) ~= sin(x) / cos(x)` within 4 LSB for `x` away from
788 /// odd multiples of `pi/2`.
789 #[test]
790 fn tan_matches_sin_over_cos() {
791 for raw in [
792 500_000_000_000_i128, // 0.5
793 -500_000_000_000_i128, // -0.5
794 1_000_000_000_000_i128, // 1.0 (cos(1.0) ~= 0.54, safe)
795 -1_000_000_000_000_i128, // -1.0
796 123_456_789_012_i128, // ~0.123456
797 ] {
798 let x = I128s12::from_bits(raw);
799 let t = x.tan();
800 let sc = x.sin() / x.cos();
801 assert!(
802 within_lsb(t, sc, FOUR_LSB),
803 "tan(x) != sin/cos for raw={raw}: tan bits {}, sin/cos bits {}",
804 t.to_bits(),
805 sc.to_bits(),
806 );
807 }
808 }
809
810 /// `tanh(x) ~= sinh(x) / cosh(x)` within 4 LSB. `cosh` is always
811 /// positive so there is no divide-by-zero risk.
812 #[test]
813 fn tanh_matches_sinh_over_cosh() {
814 for raw in [
815 500_000_000_000_i128, // 0.5
816 -500_000_000_000_i128, // -0.5
817 1_234_567_890_123_i128, // ~1.234567
818 -2_345_678_901_234_i128, // ~-2.345678
819 ] {
820 let x = I128s12::from_bits(raw);
821 let t = x.tanh();
822 let sc = x.sinh() / x.cosh();
823 assert!(
824 within_lsb(t, sc, FOUR_LSB),
825 "tanh(x) != sinh/cosh for raw={raw}: tanh bits {}, sinh/cosh bits {}",
826 t.to_bits(),
827 sc.to_bits(),
828 );
829 }
830 }
831}