FastQuat 1.0b2__tar.gz → 1.0b3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {fastquat-1.0b2 → fastquat-1.0b3}/PKG-INFO +1 -1
  2. {fastquat-1.0b2 → fastquat-1.0b3}/src/fastquat/quaternion.py +30 -32
  3. {fastquat-1.0b2 → fastquat-1.0b3}/tests/test_pow.py +48 -0
  4. {fastquat-1.0b2 → fastquat-1.0b3}/.github/workflows/ci.yml +0 -0
  5. {fastquat-1.0b2 → fastquat-1.0b3}/.github/workflows/release.yml +0 -0
  6. {fastquat-1.0b2 → fastquat-1.0b3}/.gitignore +0 -0
  7. {fastquat-1.0b2 → fastquat-1.0b3}/.pre-commit-config.yaml +0 -0
  8. {fastquat-1.0b2 → fastquat-1.0b3}/.readthedocs.yaml +0 -0
  9. {fastquat-1.0b2 → fastquat-1.0b3}/README.md +0 -0
  10. {fastquat-1.0b2 → fastquat-1.0b3}/docs/.gitignore +0 -0
  11. {fastquat-1.0b2 → fastquat-1.0b3}/docs/Makefile +0 -0
  12. {fastquat-1.0b2 → fastquat-1.0b3}/docs/README.md +0 -0
  13. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/api/quaternion.md +0 -0
  14. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/conf.py +0 -0
  15. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/development.md +0 -0
  16. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/index.md +0 -0
  17. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/user-guide/getting-started.ipynb +0 -0
  18. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/user-guide/installation.md +0 -0
  19. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/user-guide/tutorial-rotations.ipynb +0 -0
  20. {fastquat-1.0b2 → fastquat-1.0b3}/docs/source/user-guide/tutorial-slerp.ipynb +0 -0
  21. {fastquat-1.0b2 → fastquat-1.0b3}/pyproject.toml +0 -0
  22. {fastquat-1.0b2 → fastquat-1.0b3}/src/fastquat/__init__.py +0 -0
  23. {fastquat-1.0b2 → fastquat-1.0b3}/tests/conftest.py +0 -0
  24. {fastquat-1.0b2 → fastquat-1.0b3}/tests/test_base.py +0 -0
  25. {fastquat-1.0b2 → fastquat-1.0b3}/tests/test_indexing.py +0 -0
  26. {fastquat-1.0b2 → fastquat-1.0b3}/tests/test_math.py +0 -0
  27. {fastquat-1.0b2 → fastquat-1.0b3}/tests/test_rotation.py +0 -0
  28. {fastquat-1.0b2 → fastquat-1.0b3}/tests/test_tensor.py +0 -0
  29. {fastquat-1.0b2 → fastquat-1.0b3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: FastQuat
3
- Version: 1.0b2
3
+ Version: 1.0b3
4
4
  Summary: High-performance quaternions with JAX support
5
5
  Project-URL: homepage, https://fastquat.readthedocs.io
6
6
  Project-URL: repository, https://github.com/CMBSciPol/fastquat
@@ -455,34 +455,31 @@ class Quaternion:
455
455
  def log(self) -> Self:
456
456
  """Compute quaternion logarithm.
457
457
 
458
- For a quaternion q = ‖q‖ * (cos(θ) + sin(θ)v), the logarithm is:
459
- log(q) = log(‖q‖) + θ * v
458
+ For a quaternion q = |q| * (cos(θ) + sin(θ)v), the logarithm is:
459
+ log(q) = log(|q|) + θ * v
460
460
 
461
461
  For the zero quaternion, returns (-inf, 0, 0, 0).
462
462
 
463
463
  Returns:
464
464
  The logarithm of the quaternion
465
465
  """
466
- q_norm = abs(self)
467
-
468
- # Normalize manually to handle zero quaternion (returns 0 instead of NaN)
469
- safe_norm = jnp.where(q_norm == 0, 1.0, q_norm)
470
- unit_wxyz = self.wxyz / jnp.expand_dims(safe_norm, axis=-1)
471
-
472
- # For unit quaternion q = cos(θ) + sin(θ)v, compute θ and v
473
- # θ = arccos(w) and v = vector/|vector|
474
- unit_w = unit_wxyz[..., 0]
475
- unit_vector = unit_wxyz[..., 1:]
476
- theta = jnp.arccos(jnp.clip(unit_w, -1.0, 1.0))
477
- vector_norm = jnp.linalg.norm(unit_vector, axis=-1)
478
-
479
- # Handle case where vector is zero (real quaternion)
480
- inv_vector_norm = jnp.where(vector_norm == 0, 0.0, 1 / vector_norm)
481
- unit_vector = unit_vector * inv_vector_norm[..., None]
482
-
483
- # log(q) = log(|q|) + θ * v
484
- log_norm = jnp.log(q_norm)
485
- log_q_vector = theta[..., None] * unit_vector
466
+ scalar_part = self.w
467
+ vector_part = self.vector
468
+ vector_norm_sq = jnp.sum(vector_part**2, axis=-1)
469
+ is_real = vector_norm_sq == 0
470
+
471
+ # log(q) = log(|q|) + θ * v/|v|, with θ = atan2(|v|, s).
472
+ # The where guards keep the gradients finite for real quaternions (|v| = 0).
473
+ log_norm = 0.5 * jnp.log(scalar_part**2 + vector_norm_sq)
474
+ safe_vector_norm = jnp.sqrt(jnp.where(is_real, 1.0, vector_norm_sq))
475
+ safe_scalar_part = jnp.where(scalar_part == 0, 1.0, scalar_part)
476
+ # θ/|v| tends to 1/s when |v| → 0 (s > 0)
477
+ theta_over_vector_norm = jnp.where(
478
+ is_real,
479
+ 1 / safe_scalar_part,
480
+ jnp.arctan2(safe_vector_norm, scalar_part) / safe_vector_norm,
481
+ )
482
+ log_q_vector = theta_over_vector_norm[..., None] * vector_part
486
483
 
487
484
  return Quaternion.from_scalar_vector(log_norm, log_q_vector)
488
485
 
@@ -490,26 +487,27 @@ class Quaternion:
490
487
  """Compute quaternion exponential.
491
488
 
492
489
  For a quaternion q = s + v, the exponential is:
493
- exp(q) = exp(s) * (cos(‖v‖) + sin(‖v‖) * v/‖v‖)
490
+ exp(q) = exp(s) * (cos(|v|) + sin(|v|) * v/|v|)
494
491
 
495
492
  Returns:
496
493
  The exponential of the quaternion
497
494
  """
498
495
  scalar_part = self.w
499
496
  vector_part = self.vector
500
- vector_norm = jnp.linalg.norm(vector_part, axis=-1)
497
+ vector_norm_sq = jnp.sum(vector_part**2, axis=-1)
498
+ is_real = vector_norm_sq == 0
501
499
 
502
- # exp(s + v) = exp(s) * (cos(|v|) + sin(|v|) * v/|v|)
500
+ # The where guard keeps the gradients finite for real quaternions (|v| = 0).
501
+ vector_norm = jnp.where(is_real, 0.0, jnp.sqrt(jnp.where(is_real, 1.0, vector_norm_sq)))
503
502
  exp_scalar = jnp.exp(scalar_part)
504
- cos_vnorm = jnp.cos(vector_norm)
505
- sin_vnorm = jnp.sin(vector_norm)
506
-
507
- # Handle case where |v| = 0 (real quaternion)
508
- inv_vector_norm = jnp.where(vector_norm == 0, 0.0, 1 / vector_norm)
509
- unit_v = vector_part * jnp.expand_dims(inv_vector_norm, -1)
503
+ # sin(|v|)/|v| and cos(|v|) = 1 - |v|²/2 (sin(|v|/2)/(|v|/2))², written with sinc
504
+ # so that the first and second derivatives are exact at |v| = 0
505
+ sinc_vnorm = jnp.sinc(vector_norm / jnp.pi)
506
+ sinc_half_vnorm = jnp.sinc(vector_norm / (2 * jnp.pi))
507
+ cos_vnorm = 1 - 0.5 * vector_norm_sq * sinc_half_vnorm**2
510
508
 
511
509
  result_w = exp_scalar * cos_vnorm
512
- result_vector = exp_scalar * jnp.expand_dims(sin_vnorm, -1) * unit_v
510
+ result_vector = jnp.expand_dims(exp_scalar * sinc_vnorm, -1) * vector_part
513
511
 
514
512
  return Quaternion.from_scalar_vector(result_w, result_vector)
515
513
 
@@ -1,5 +1,6 @@
1
1
  import jax
2
2
  import jax.numpy as jnp
3
+ import jax.test_util
3
4
  import pytest
4
5
 
5
6
  from fastquat import Quaternion
@@ -386,3 +387,50 @@ def test_pow_zero_quaternion(cast_type, do_jit):
386
387
  # 0^n for n > 0 should give 0
387
388
  result_0_2 = func(zero_q, cast_type(2.0))
388
389
  assert jnp.allclose(result_0_2.wxyz, zero_q.wxyz)
390
+
391
+
392
+ # Gradients of logarithm and exponential
393
+ @pytest.mark.parametrize(
394
+ 'wxyz',
395
+ [
396
+ [0.0, 0.0, 0.0, 0.0], # pure zero: exp at the identity
397
+ [0.3, 0.0, 0.0, 0.0], # real
398
+ [0.0, 0.1, -0.2, 0.3], # pure imaginary
399
+ [0.5, 0.1, -0.2, 0.3],
400
+ ],
401
+ )
402
+ def test_exp_grads(wxyz, enable_x64):
403
+ """Test exp derivatives up to second order, including at a zero vector part."""
404
+ jax.test_util.check_grads(
405
+ lambda a: Quaternion.from_array(a).exp().wxyz, (jnp.array(wxyz),), order=2
406
+ )
407
+
408
+
409
+ @pytest.mark.parametrize(
410
+ 'wxyz',
411
+ [
412
+ [1.0, 0.0, 0.0, 0.0], # identity
413
+ [2.0, 0.0, 0.0, 0.0], # real
414
+ [0.0, 0.1, -0.2, 0.3], # pure imaginary
415
+ [0.5, 0.1, -0.2, 0.3],
416
+ ],
417
+ )
418
+ def test_log_grads(wxyz, enable_x64):
419
+ """Test log derivatives, including at a zero vector part."""
420
+ jax.test_util.check_grads(
421
+ lambda a: Quaternion.from_array(a).log().wxyz, (jnp.array(wxyz),), order=1
422
+ )
423
+
424
+
425
+ def test_exp_jacobian_at_zero():
426
+ """Test that d exp(v)/dv at v = 0 maps the vector part onto itself."""
427
+ jac = jax.jacobian(lambda v: Quaternion.from_scalar_vector(0.0, v).exp().wxyz)(jnp.zeros(3))
428
+ expected = jnp.concatenate([jnp.zeros((1, 3)), jnp.eye(3)])
429
+ assert jnp.allclose(jac, expected)
430
+
431
+
432
+ def test_log_jacobian_at_identity():
433
+ """Test that d log(1 + v)/dv at v = 0 maps the vector part onto itself."""
434
+ jac = jax.jacobian(lambda v: Quaternion.from_scalar_vector(1.0, v).log().wxyz)(jnp.zeros(3))
435
+ expected = jnp.concatenate([jnp.zeros((1, 3)), jnp.eye(3)])
436
+ assert jnp.allclose(jac, expected)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes