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); + } + } +}