diff --git a/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs b/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs
index 5a9dc6a03..38fe31cb6 100644
--- a/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs
+++ b/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs
@@ -62,58 +62,126 @@ private static bool TryPulling(TrigTable table, Complex arg,
return false;
}
- internal static bool PullSin(Complex arg,
+ ///
+ /// Every angle in the tables below is written 2pi/n, so they name nothing at
+ /// all between 2pi/5 and 2pi/3 -- the whole lower half of the circle
+ /// is missing, and most of the second quadrant with it. The identities each Pull
+ /// tries reach some of what is missing and not the rest, which is why
+ /// sin(2pi/3) was answered exactly while its neighbour sin(5pi/3), the
+ /// same value negated, was left as written. Turning the argument by half a circle
+ /// and taking the sign back is what the tables are short of. It is applied once,
+ /// around the lookups rather than inside them, so it cannot turn back and loop.
+ /// https://github.com/asc-community/AngouriMath/issues/743
+ ///
+ private static Complex HalfTurn(Complex arg) => arg - (Real)MathS.DecimalConst.pi;
+
+ ///
+ /// Tries a lookup on the argument and then on the argument turned by half a circle,
+ /// taking the sign back over the turn if . Sine
+ /// and cosine both change sign; the tangent's period *is* half a circle, so it does
+ /// not.
+ ///
+ private static bool OrHalfTurn(System.Func lookup,
+ Complex arg, bool oddOverHalfTurn,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res)
{
- if (TryPulling(TableSin, arg, out res))
- return true;
- if (TryPulling(TableSin, (Real)MathS.DecimalConst.pi - arg, out res))
+ if (lookup(arg) is (true, { } here))
+ {
+ res = here;
return true;
- if (TryPulling(TableCos, arg * 2, out res))
+ }
+ if (lookup(HalfTurn(arg)) is (true, { } turned))
{
- res = MathS.Sqrt((1 - res) / 2);
- if (Number.Sin(arg) is Real real && real < 0)
- res *= -1;
+ res = oddOverHalfTurn ? -turned : turned;
return true;
}
+ res = null;
return false;
}
- internal static bool PullCos(Complex arg,
+ /// Sine read off the table itself, which is where the exact values are.
+ private static (bool, Entity?) SinFromTable(Complex arg)
+ {
+ if (TryPulling(TableSin, arg, out var res))
+ return (true, res);
+ // sin(x) = sin(pi - x)
+ if (TryPulling(TableSin, (Real)MathS.DecimalConst.pi - arg, out res))
+ return (true, res);
+ return (false, null);
+ }
+
+ ///
+ /// Sine built out of the cosine of the doubled angle. Tried only after every table
+ /// reading has been, because what it builds is a nested radical where the table has
+ /// a flat one: at 6pi/5 it gives sqrt((1 + (sqrt(5) - 1)/4) / 2) for a value the
+ /// table names as (sqrt(5) + 1)/4, and the roots of x^5 = 1 then come back written
+ /// two different ways.
+ ///
+ private static (bool, Entity?) SinFromDoubledAngle(Complex arg)
+ {
+ // sin(x) = +-sqrt((1 - cos(2x)) / 2)
+ if (!TryPulling(TableCos, arg * 2, out var res))
+ return (false, null);
+ res = MathS.Sqrt((1 - res) / 2);
+ if (Number.Sin(arg) is Real real && real < 0)
+ res *= -1;
+ return (true, res);
+ }
+
+ internal static bool PullSin(Complex arg,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res)
+ => OrHalfTurn(SinFromTable, arg, oddOverHalfTurn: true, out res)
+ || OrHalfTurn(SinFromDoubledAngle, arg, oddOverHalfTurn: true, out res);
+
+ private static (bool, Entity?) CosFromTable(Complex arg)
{
- if (TryPulling(TableCos, arg, out res))
- return true;
+ if (TryPulling(TableCos, arg, out var res))
+ return (true, res);
+ // cos(x) = cos(-x)
if (TryPulling(TableCos, -1 * arg, out res))
- return true;
- if (TryPulling(TableCos, arg * 2, out res))
- {
- res = MathS.Sqrt((1 + res) / 2);
- if (Number.Cos(arg) is Real real && real < 0)
- res *= -1;
- return true;
- }
- return false;
+ return (true, res);
+ return (false, null);
}
- internal static bool PullTan(Complex arg,
+ private static (bool, Entity?) CosFromDoubledAngle(Complex arg)
+ {
+ // cos(x) = +-sqrt((1 + cos(2x)) / 2)
+ if (!TryPulling(TableCos, arg * 2, out var res))
+ return (false, null);
+ res = MathS.Sqrt((1 + res) / 2);
+ if (Number.Cos(arg) is Real real && real < 0)
+ res *= -1;
+ return (true, res);
+ }
+
+ internal static bool PullCos(Complex arg,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res)
+ => OrHalfTurn(CosFromTable, arg, oddOverHalfTurn: true, out res)
+ || OrHalfTurn(CosFromDoubledAngle, arg, oddOverHalfTurn: true, out res);
+
+ private static (bool, Entity?) TanFromTable(Complex arg)
{
- if (TryPulling(TableTan, arg, out res))
- return true;
+ if (TryPulling(TableTan, arg, out var res))
+ return (true, res);
+ // tan(x) = -tan(pi - x)
if (TryPulling(TableTan, (Real)MathS.DecimalConst.pi - arg, out res))
- {
- res *= -1;
- return true;
- }
- if (TryPulling(TableCos, arg * 2, out res))
- {
- res = MathS.Sqrt((1 - res) / (1 + res));
- return true;
- }
- return false;
+ return (true, res * -1);
+ return (false, null);
}
+ private static (bool, Entity?) TanFromDoubledAngle(Complex arg)
+ {
+ // tan(x) = sqrt((1 - cos(2x)) / (1 + cos(2x)))
+ if (!TryPulling(TableCos, arg * 2, out var res))
+ return (false, null);
+ return (true, MathS.Sqrt((1 - res) / (1 + res)));
+ }
+
+ internal static bool PullTan(Complex arg,
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res)
+ => OrHalfTurn(TanFromTable, arg, oddOverHalfTurn: false, out res)
+ || OrHalfTurn(TanFromDoubledAngle, arg, oddOverHalfTurn: false, out res);
+
private static Entity Sqrt(Entity a) => MathS.Pow(a, f1_2);
private static Entity Cbrt(Entity a) => MathS.Pow(a, f1_3);
[ConstantField] private static readonly Entity f1_2 = Rational.Create(1, 2);
diff --git a/Sources/Tests/UnitTests/Common/TrigonometricTableTest.cs b/Sources/Tests/UnitTests/Common/TrigonometricTableTest.cs
new file mode 100644
index 000000000..ecdd008a4
--- /dev/null
+++ b/Sources/Tests/UnitTests/Common/TrigonometricTableTest.cs
@@ -0,0 +1,188 @@
+//
+// Copyright (c) 2019-2022 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using AngouriMath;
+using AngouriMath.Extensions;
+using Xunit;
+
+namespace AngouriMath.Tests.Common
+{
+ ///
+ /// The table of exact trigonometric values is written as 2pi/n, so it names only
+ /// the angles of the first turn that divide it evenly, and the identities used to read
+ /// it reached some of the rest and not the others. sin(2pi/3) came back
+ /// sqrt(3)/2 while sin(5pi/3), the same value negated, was left written as
+ /// a sine -- so the roots of x^6 = 1 were answered five exactly and one as
+ /// 1/2 + i*sin(5/3*pi), its own conjugate spelled differently.
+ /// https://github.com/asc-community/AngouriMath/issues/743
+ ///
+ public sealed class TrigonometricTableTest
+ {
+ /// The denominators the table has entries for, plus a few it has not.
+ private static readonly int[] Denominators =
+ { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 18, 20, 24 };
+
+ private static readonly string[] Functions = { "sin", "cos", "tan" };
+
+ private static IEnumerable<(string Function, string Angle)> EveryAngleOfTheFirstTurn()
+ {
+ foreach (var n in Denominators)
+ for (var k = 0; k <= 2 * n; k++)
+ foreach (var function in Functions)
+ yield return (function, $"{k}/{n} * pi");
+ }
+
+ private static bool IsUnevaluatedTrig(Entity expr) =>
+ expr.Nodes.Any(node => node is Entity.Sinf or Entity.Cosf or Entity.Tanf
+ or Entity.Cotanf or Entity.Secantf or Entity.Cosecantf);
+
+ private static double? Value(Entity expr)
+ {
+ try
+ {
+ var value = expr.EvalNumerical();
+ if (!value.IsFinite)
+ return null;
+ return value.RealPart.EDecimal.ToDouble();
+ }
+ catch (System.Exception) { return null; }
+ }
+
+ ///
+ /// The property that matters most, and the one a wrong table entry would break: an
+ /// exact value handed back in place of a trigonometric call has to be that call's
+ /// value. Swept over every k/n of the first turn rather than over the angles the
+ /// issue names, because the risk of widening a lookup table is that it starts
+ /// answering angles it does not know.
+ ///
+ [Fact]
+ public void EveryExactValueAgreesWithTheCallItReplaces()
+ {
+ var resolved = 0;
+ foreach (var (function, angle) in EveryAngleOfTheFirstTurn())
+ {
+ var call = $"{function}({angle})".ToEntity();
+ var simplified = call.InnerSimplified;
+ if (IsUnevaluatedTrig(simplified))
+ continue;
+ resolved++;
+ var expected = Value(call);
+ var actual = Value(simplified);
+ if (expected is null)
+ {
+ // tan(pi/2) and its kind: undefined either way is agreement.
+ Assert.True(actual is null,
+ $"{function}({angle}) has no finite value but was given {simplified.Stringize()}");
+ continue;
+ }
+ Assert.True(actual is not null,
+ $"{function}({angle}) is {expected} but was given the non-finite " +
+ $"{simplified.Stringize()}");
+ Assert.Equal(expected.Value, actual!.Value, 12);
+ }
+ // A sweep that resolved nothing would pass every assertion above. Measured over
+ // the 1158 calls swept: 581 of them resolved before the half turn was applied
+ // and 690 after, and the 581 were already sound -- what was missing was missing,
+ // not wrong. Raise this if the table gains entries; it is here to catch losing
+ // them silently.
+ Assert.True(resolved >= 690, $"only {resolved} of the swept angles resolved at all");
+ }
+
+ ///
+ /// The angles the issue names. Each is an exact value wearing an unevaluated form:
+ /// sin(5pi/3) is -sqrt(3)/2 and cos(4pi/5) is -(1 + sqrt(5))/4.
+ ///
+ [Theory]
+ [InlineData("sin(5/3 * pi)", "-sqrt(3) / 2")]
+ [InlineData("cos(4/5 * pi)", "-(1 + sqrt(5)) / 4")]
+ [InlineData("sin(8/5 * pi)", "-sqrt(10 + 2 * sqrt(5)) / 4")]
+ // Their neighbours in the lower half of the circle, which the table reached no
+ // better and which nothing had reported.
+ [InlineData("sin(7/6 * pi)", "-1/2")]
+ [InlineData("sin(11/6 * pi)", "-1/2")]
+ [InlineData("cos(5/4 * pi)", "-sqrt(2) / 2")]
+ [InlineData("cos(7/4 * pi)", "sqrt(2) / 2")]
+ [InlineData("tan(5/3 * pi)", "-sqrt(3)")]
+ [InlineData("cos(6/5 * pi)", "-(1 + sqrt(5)) / 4")]
+ public void AnAngleOfTheLowerHalfIsAnsweredExactly(string call, string expected)
+ {
+ var simplified = call.ToEntity().Simplify();
+ Assert.False(IsUnevaluatedTrig(simplified),
+ $"{call} came back as {simplified.Stringize()}");
+ Assert.Equal(Value(expected.ToEntity())!.Value, Value(simplified)!.Value, 12);
+ }
+
+ ///
+ /// What the issue was found through: the roots of x^n = 1 are handed back in a + bi
+ /// form, and an unevaluated sine in the last of them made the answer inconsistent
+ /// with the conjugate two lines above it.
+ ///
+ [Theory]
+ [InlineData("x ^ 3 - 1 = 0", 3)]
+ [InlineData("x ^ 4 - 1 = 0", 4)]
+ [InlineData("x ^ 5 - 1 = 0", 5)]
+ [InlineData("x ^ 6 - 1 = 0", 6)]
+ [InlineData("x ^ 8 - 1 = 0", 8)]
+ [InlineData("x ^ 12 - 1 = 0", 12)]
+ public void NoRootOfUnityCarriesAnUnevaluatedTrigonometricCall(string equation, int count)
+ {
+ var roots = (Entity.Set.FiniteSet)equation.ToEntity().Solve("x");
+ Assert.Equal(count, roots.Count);
+ foreach (var root in roots)
+ Assert.False(IsUnevaluatedTrig(root),
+ $"{equation} answered {root.Stringize()}, which is not an exact value");
+ }
+
+ ///
+ /// A root and its conjugate must be written the same way. Reading the table before
+ /// building a value out of the doubled angle is what settles this: the doubled-angle
+ /// route gives sqrt((1 + (sqrt(5) - 1)/4) / 2) where the table names the same number
+ /// (sqrt(5) + 1)/4, so before it x^5 = 1 came back written both ways at once.
+ ///
+ [Theory]
+ [InlineData("x ^ 5 - 1 = 0")]
+ [InlineData("x ^ 6 - 1 = 0")]
+ [InlineData("x ^ 12 - 1 = 0")]
+ public void ARootOfUnityIsWrittenAsItsConjugateIs(string equation)
+ {
+ var roots = (Entity.Set.FiniteSet)equation.ToEntity().Solve("x");
+ var byValue = roots.ToDictionary(
+ root => (System.Math.Round(root.EvalNumerical().RealPart.EDecimal.ToDouble(), 9),
+ System.Math.Round(root.EvalNumerical().ImaginaryPart.EDecimal.ToDouble(), 9)),
+ root => root);
+ foreach (var ((re, im), root) in byValue)
+ {
+ if (im == 0 || !byValue.TryGetValue((re, -im), out var conjugate))
+ continue;
+ Assert.Equal(root.Stringize().Replace("-", ""),
+ conjugate.Stringize().Replace("-", ""));
+ }
+ }
+
+ // What must not change: the angles the table always answered.
+ [Theory]
+ [InlineData("sin(0)", "0")]
+ [InlineData("sin(pi)", "0")]
+ [InlineData("cos(pi)", "-1")]
+ [InlineData("sin(pi / 2)", "1")]
+ [InlineData("sin(2/3 * pi)", "sqrt(3) / 2")]
+ [InlineData("cos(1/5 * pi)", "(sqrt(5) + 1) / 4")]
+ [InlineData("cos(2/5 * pi)", "(sqrt(5) - 1) / 4")]
+ [InlineData("sin(4/3 * pi)", "-sqrt(3) / 2")]
+ [InlineData("cos(pi / 3)", "1/2")]
+ [InlineData("tan(pi / 4)", "1")]
+ public void TheAnglesTheTableAlreadyAnsweredAreUnchanged(string call, string expected)
+ {
+ var simplified = call.ToEntity().Simplify();
+ Assert.False(IsUnevaluatedTrig(simplified),
+ $"{call} came back as {simplified.Stringize()}");
+ Assert.Equal(Value(expected.ToEntity())!.Value, Value(simplified)!.Value, 12);
+ }
+ }
+}