1

I'm writing a sine implementation for my own fixed-point library without using pre-computed tables or library functions, so just basic mathematical operations (add, subtract, multiply, divide). I want to calculate this sine value to the full precision of whatever fixed point type the result is in, e.g. one with 16 fractional bits.

As far as I know the Taylor series is the only way of doing this, and it gets more precise the more terms I add, but how do I determine when to stop adding terms? Is it enough to just check if the next term would be smaller than the target precision? Is it even practical to use the Taylor series in this way or do I need to use something else?

I'm using C and want to make the number of fractional bits of my fixed-point type (or types) configurable, which is why I need to be able to generalize my stopping condition in this way.

Wingblade
  • 9,585
  • 10
  • 35
  • 48
  • 1
    Is the sine argument degrees or radians? Is the argument also fixed-point? What is the range of arguments? – chux - Reinstate Monica Feb 02 '18 at 18:36
  • @chux I'm probably going with either full or quarter rotations, so a value of 1 would correspond to `2*PI` radians or `PI/2` radians respectively, this is to avoid involving a PI constant in my calculations (which would be necessary for radians), it's apparently quite common to do this for fixed point. – Wingblade Feb 02 '18 at 18:43
  • 1) You probably *want* to use tables of precomputed numbers. But it's not immediately obvious what tables, and how to use them. The algorithm requires careful design. 2) You probably do not want to use Taylor series for anything except for precomputing a few carefully selected entries for the tables. If you use it, you do it with variable precision BigFloat-numbers, not with ordinary floats. – Andrey Tyukin Feb 02 '18 at 18:44
  • @AndreyTyukin It's possible I'll switch to a table later in order to optimize, for now I need maximum useful precision even if it's slower. Not that this is likely to make a big difference seeing as processors are very fast these days while memory access is often comparatively slow. – Wingblade Feb 02 '18 at 18:56
  • 2
    It's not so much about speed, it's about the fact that adding more terms to the Taylor series can give you garbage results rather quickly. Take a look at this: https://en.wikipedia.org/wiki/CORDIC It's eternally old, but it's a really cool algorithm, I strongly encourage you to take a look at it. It's not so hard to implement, and it gives you answers exact to machine precision quite quickly. Also related: https://stackoverflow.com/questions/345085/how-do-trigonometric-functions-work/345117#345117 – Andrey Tyukin Feb 02 '18 at 19:02
  • To meet 16 fractional bits., certainly, at most, a 5 term equation is needed. – chux - Reinstate Monica Feb 02 '18 at 19:02
  • @AndreyTyukin Yes, CORDIC is likely OP's best approach. – chux - Reinstate Monica Feb 02 '18 at 19:03
  • 1
    @AndreyTyukin Agree/disagree about "adding more terms to the Taylor series can give you garbage results rather quickly." Much depends on order and the nature of the series. Sine works well in reverse summing. – chux - Reinstate Monica Feb 02 '18 at 19:06
  • @AndreyTyukin Maybe I will have to take a closer look at CORDIC, yea. Can you explain how come Taylor produces garbage results when adding more terms? Shouldn't more terms make the result better, not worse? – Wingblade Feb 02 '18 at 19:06
  • Calculating functions like sine accurately is actually a complicated problem. Taylor series is not the only way. Chebyshev polynomials and minimax polynomials can also be used. Typically, the polynomial would be pre-computed using specialized software, so there would be no run-time decision about how many terms to use. Additionally, there is the problem of reducing the argument modulo 2π accurately and precisely. And, once you have done the reduction and evaluated the polynomial, you have to consider all the errors involved: reduction error, polynomial error, evaluation error.… – Eric Postpischil Feb 02 '18 at 19:14
  • … Consequently, it is difficult to get even a faithfully rounded result (one of the two neighboring representable values), let alone a correctly rounded result (the nearest representable value according to the rounding rules). The proper techniques cannot be covered in a Stack Overflow answer. Just to start, what is the domain for which you need the function? Will all the inputs be in the interval (−π, +π), or might they be larger? How much larger? How accurate a result do you need? – Eric Postpischil Feb 02 '18 at 19:17
  • Since you want run-time configurability of precision, you cannot use prepared polynomials except by having a set of them to cover various precisions and selecting one at run-time. But the Taylor series for sine is well-behaved; you can compute a bound on the sum of the remaining terms and stop when the bound is less than an amount that would affect your result by more than you care. However, you have to account for all the errors that occur during computation, as mentioned above. – Eric Postpischil Feb 02 '18 at 19:21
  • You say you want to calculate sine to the “full precision” of the type. What does that mean? Normally, extended precision has to be used within a sine routine in order to return the expected precision of the result. Suppose, using decimal for the moment, you had calculated the sine so far as .30144999, and you need to return a four-digit result. You might need to return .3014 or .3015, but you cannot tell which is closer until you calculate the sine more precisely; maybe there is an additional .00000002 coming that would put you over. What if you calculate more and get .301449999999? … – Eric Postpischil Feb 02 '18 at 19:24
  • … In general, it is hard to know how much you need to calculate to get a **correctly rounded** result. Your routine might come upon a situation where that decision between rounding up or down, which depends on which side of a single point the sine is, requires calculating the function to many, many digits. Do you want to get a result to that “full precision”, or is it okay if your result is sometimes not the true nearest representable value? How far away do you want to allow it to be? – Eric Postpischil Feb 02 '18 at 19:26
  • @chux Yes, you're right, if you move the input to [-pi,+pi] range, and then reverse-sum, you'll probably get quite good results, at least with something like `double`, because the series for `sin` happens to converge very rapidly. But it's a not entirely trivial analysis for every new function. "Does it work well for arctan?" - I don't know... would have to look it up somewhere, or calculate a little bit... – Andrey Tyukin Feb 02 '18 at 19:26
  • @EricPostpischil I think I see the problem you're getting at. Maybe what I'm hoping to do isn't actually possible. What I want is to have the maximum error (i.e. how "far away" the returned result is from the true value) be smaller than the resolution of my fixed point type, so in case of 16 fractional bits that'd mean less than `1/65536`. I also don't mind doubling up the precision for intermediate calculations (so going from 16 to 32 fractional bits) or truncating instead of proper rounding, so long as the maximum error is less than `1/65536` (that is for 16 fractional bits). – Wingblade Feb 02 '18 at 19:49
  • @EricPostpischil Also, I mean configurable via a preprocessor `#define`, not at run-time. Though I don't think C's preprocessor is clever enough to generate entire lookup-tables at compile-time, and even if it could I'd still need to be able to calculate Taylor to the desired precision to create such a table. – Wingblade Feb 02 '18 at 19:53
  • @Wingblade See [Table-maker's dilemma](https://en.wikipedia.org/wiki/Rounding#Table-maker's_dilemma). Likely the best code will attain is +/- 2/65536. – chux - Reinstate Monica Feb 02 '18 at 21:07

2 Answers2

0

Following is not a fixed point solution, but a floating point one. So it may provide insight for a fixed point one.

It does use a library function remainder() for range reduction, but that could also be replaced once detailed coding goals are expressed.

It uses recursion to add the smaller terms together first. With higher precision floating point, this may make for a deep stack excursion.

The recursion termination here is a test with 1.0, which makes sense for floating point. I'd expect a compare against an epsilon for fixed point.

static double sin_r_helper(double term, double xx, unsigned i) {
  if (1.0 + term == 1.0) return term;
  return term - sin_r_helper(term * xx / ((i + 1) * (i + 2)), xx, i + 2);
}

double sin_r(double x /* radians */) {
  x = remainder(x, 2 * M_PI);
  return x * sin_r_helper(1.0, x * x, 1);
}
chux - Reinstate Monica
  • 143,097
  • 13
  • 135
  • 256
0

Calculating sine to full precision of given fixed point type, when to stop?

This part is somewhat easy. The approximate error in limiting sine(x) code to N polynomial terms is about the value of the N+1th term of the Taylor series.

For a limited range of x [0 ... π/4] about 3 terms needed for 15 bits. See sine series definitions.

4th term is (π/4)7/7! or 3.7e-5 or about 1 part in 214.7. Trig identities can handle the rest of the number range.


In trying to create a uint16_t sin_f(uint16_t) an early problem is how to scale the input and output.

The result of math sine is [-1.0 ... +1.0]. By only calculating the first 90 degrees on sine, the range is [0.0 ... +1.0]. Scaling this by a power-of-2 could use [0 ... 65536], but the ends are inclusive so that result will not fit in a uint16_t. Perhaps use [0 ... 32768]?

OP implied 1.0 is one revolution, 360 degrees or 2*π radians. So input is a 16-bit fraction [0 ... 65535].


Below is a modest attempt that uses a 4 term polynomial. Terms were found via some excel curve fitting techniques and are not necessarily optimal. Its has 2 known problems: result in the range [0 ... 32769] (could tweak the scale a bit to fix) and worst case off-by-4 result. (off by 2 was my goal.) It does offer some idea to OP of what is involved. As other say, this is not trivial and a dynamic solution to variant fixed widths looks to be very hard. A constant 16 bit fixed point was hard enough.

#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include <assert.h>

uint16_t mul16(uint16_t a, uint16_t b, int shift) {
  uint32_t y32 = a;
  y32 *= b;
  y32 >>= shift - 1;
  if (y32 & 1) {
    y32 >>= 1;
    y32 += 1;
  } else {
    y32 >>= 1;
  }

  if (y32 > 0xFFFF) {
    printf("@@@@@@ %u %u %llu %d\n", 1u*a, 1u*b, 1llu*y32, shift);
    exit(0);
  }
  uint16_t y16 = (uint16_t) y32;
  return y16;
}

// 0 to 0x4000  (map to 0 to 90 degrees or 0 to pi/2 R)
// pseudo code:  = x*(1 - b*x^2 + c*x^4 - d*x^6)
uint16_t sine_fixed(uint16_t x) {
  const uint16_t t3 = 53902; // 431214.77/8;
  const uint16_t t5 = 64636; // 129272.18/2;
  const uint16_t t7 = 58833; // 58833.22

  uint16_t xx = mul16(x, x, 16);
  uint16_t term = 51472; // 2*pi*65536 / 8
  uint16_t sum = term;

  term = mul16(mul16(term, xx, 16 - 3), t3, 16 - 0);
  sum = (uint16_t) (sum - term);

  term = mul16(mul16(term, xx, 16 - 1), t5, 16 - 0);
  sum = (uint16_t) (sum + term);

  term = mul16(mul16(term, xx, 16 - 0), t7, 16 - 0);
  sum = (uint16_t) (sum - term);

  uint16_t y = mul16(x, sum, 16 - 3 + 1);
  return y;
}

Test code

#include <math.h>
#include <stdio.h>
#include <stdlib.h>

void sin_fixed_test(uint16_t x) {
  double pi = acos(-1);
  uint16_t y = sine_fixed(x);
  double X = x * 2*pi / 65536.0;
  double Y = sin(X);
  long ye =  lrint(Y * 65536.0/2);
  //printf("sine(%5u) --> %5u, expect %5ld\n", 1u * x, 1u * y, ye);
  long diff = labs(ye - y);
  static long diff_max = 0;
  if (diff > diff_max) {
    diff_max = diff;
    printf("sine(%5u) --> %5u, expect %5ld !!!\n", 1u * x, 1u * y, ye);
  }
}

void sin_fixed_tests() {
  sin_fixed_test(15887);
  for (uint16_t x = 0; x <= 65536u / 4u; x += 1) {
    sin_fixed_test(x);
  }
}

int main() {
  sin_fixed_tests();
  return 0;
}
chux - Reinstate Monica
  • 143,097
  • 13
  • 135
  • 256