Skip to main content

lib/bb/estimator/ahrs/math.ex

# SPDX-FileCopyrightText: 2026 Gus Workman
# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: MIT

defmodule BB.Estimator.Ahrs.Math do
  @moduledoc """
  Stateless mathematical utilities shared across the AHRS filters.

  All inputs and outputs are in SI units (rad/s for angular velocity,
  m/s² for linear acceleration, radians for angles). Unit conversion is
  the responsibility of the sensor driver populating the inbound
  `BB.Message.Sensor.Imu` payload.

  Ported from [gworkman/ahrs](https://github.com/gworkman/ahrs)'
  `Ahrs.Math`.
  """

  alias BB.Estimator.Ahrs.Quaternion, as: Q

  @gravity 9.80665
  @gimbal_lock_threshold 0.99999

  @doc "Standard gravity in m/s²."
  @spec gravity() :: float()
  def gravity, do: @gravity

  @doc """
  Converts a quaternion to Euler angles using the Z-Y-X Tait-Bryan
  convention. Returns `{roll, pitch, yaw}` in radians, unless `:degrees`
  is passed.
  """
  @spec quaternion_to_euler(Q.t(), keyword()) ::
          {roll :: float(), pitch :: float(), yaw :: float()}
  def quaternion_to_euler(%Q{w: w, x: x, y: y, z: z}, opts \\ []) do
    units = Keyword.get(opts, :units, :radians)

    sinp = 2.0 * (w * y - z * x)

    {roll, pitch, yaw} =
      if abs(sinp) >= @gimbal_lock_threshold do
        p = if sinp < 0, do: -:math.pi() / 2, else: :math.pi() / 2
        r = normalize_angle(2.0 * :math.atan2(x, w))
        {r, p, 0.0}
      else
        sinr_cosp = 2.0 * (w * x + y * z)
        cosr_cosp = 1.0 - 2.0 * (x * x + y * y)
        r = :math.atan2(sinr_cosp, cosr_cosp)

        p = :math.asin(max(-1.0, min(1.0, sinp)))

        siny_cosp = 2.0 * (w * z + x * y)
        cosy_cosp = 1.0 - 2.0 * (y * y + z * z)
        y = :math.atan2(siny_cosp, cosy_cosp)

        {r, p, y}
      end

    format_output({roll, pitch, yaw}, units)
  end

  @doc "Converts Euler angles in radians to a quaternion (Z-Y-X Tait-Bryan)."
  @spec euler_to_quaternion(roll :: float(), pitch :: float(), yaw :: float()) :: Q.t()
  def euler_to_quaternion(roll, pitch, yaw) do
    cr = :math.cos(roll * 0.5)
    sr = :math.sin(roll * 0.5)
    cp = :math.cos(pitch * 0.5)
    sp = :math.sin(pitch * 0.5)
    cy = :math.cos(yaw * 0.5)
    sy = :math.sin(yaw * 0.5)

    %Q{
      w: cr * cp * cy + sr * sp * sy,
      x: sr * cp * cy - cr * sp * sy,
      y: cr * sp * cy + sr * cp * sy,
      z: cr * cp * sy - sr * sp * cy
    }
  end

  defp format_output({r, p, y}, :radians), do: {r, p, y}

  defp format_output({r, p, y}, :degrees) do
    factor = 180.0 / :math.pi()
    {r * factor, p * factor, y * factor}
  end

  @doc """
  Calculates roll and pitch in radians from a 3-axis acceleration
  vector. Yaw is unobservable from acceleration alone.

  Returns `{roll, pitch}`. The accel components are in any unit; only
  their relative magnitudes matter.
  """
  @spec accel_to_tilt(float(), float(), float()) :: {roll :: float(), pitch :: float()}
  def accel_to_tilt(ax, ay, az) do
    case :math.sqrt(ax * ax + ay * ay + az * az) do
      norm when norm == 0.0 ->
        {0.0, 0.0}

      _norm ->
        roll = :math.atan2(ay, az)
        pitch = :math.atan2(-ax, :math.sqrt(ay * ay + az * az))
        {roll, pitch}
    end
  end

  @doc """
  Rotates a 3D `{x, y, z}` vector by a quaternion. Equivalent to
  `q * v * conjugate(q)`.
  """
  @spec rotate_vector({float(), float(), float()}, Q.t()) :: {float(), float(), float()}
  def rotate_vector({vx, vy, vz}, %Q{w: qw, x: qx, y: qy, z: qz}) do
    tx = 2.0 * (qy * vz - qz * vy)
    ty = 2.0 * (qz * vx - qx * vz)
    tz = 2.0 * (qx * vy - qy * vx)

    {
      vx + qw * tx + qy * tz - qz * ty,
      vy + qw * ty + qz * tx - qx * tz,
      vz + qw * tz + qx * ty - qy * tx
    }
  end

  @doc "Normalises an angle to the range `(-π, π]`."
  @spec normalize_angle(float()) :: float()
  def normalize_angle(angle) do
    two_pi = 2.0 * :math.pi()
    a = :math.fmod(angle + :math.pi(), two_pi)
    if a <= 0, do: a + :math.pi(), else: a - :math.pi()
  end

  @doc """
  Returns `true` if the magnitude of an accel vector in m/s² is within
  `threshold` of standard gravity, where `threshold` is a fractional
  value (e.g. `0.1` means ±10% around 1 g).

  Used by Madgwick and Mahony to reject readings dominated by linear
  acceleration rather than gravity.
  """
  @spec gravity_only?(float(), float(), float(), float()) :: boolean()
  def gravity_only?(ax, ay, az, threshold) do
    norm_g = :math.sqrt(ax * ax + ay * ay + az * az) / @gravity
    abs(norm_g - 1.0) <= threshold
  end
end