diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index 44b8d5fa4..11bad1196 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -202,6 +202,7 @@ private Entity InnerSimplifyWithCheck(bool isExact) /// y ^ 8 + 8 * x * y ^ 7 + 28 * x ^ 2 * y ^ 6 + 56 * x ^ 3 * y ^ 5 + 70 * x ^ 4 * y ^ 4 + 56 * x ^ 5 * y ^ 3 + 28 * x ^ 6 * y ^ 2 + 8 * x ^ 7 * y + x ^ 8 /// /// + public Entity Expand(int level = 2) { static Entity Expand_(Entity e, int level) => @@ -214,7 +215,89 @@ static Entity Expand_(Entity e, int level) => expChildren.AddRange(exp); else return this; // if one is too complicated, return the current one - return Expand_(TreeAnalyzer.MultiHangBinary(expChildren, (a, b) => new Sumf(a, b)), level).InnerSimplified; + return CollectLikeTerms( + Expand_(TreeAnalyzer.MultiHangBinary(expChildren, (a, b) => new Sumf(a, b)), level).InnerSimplified); + } + + /// + /// Adds up the terms of an expanded sum that differ only by a numeric factor, so + /// that expanding actually finishes: (x+1)^2 * (x+1)^2 multiplied out gives + /// sixteen terms, of which only five are distinct. + /// + /// + /// Deliberately not , which is far too expensive to run + /// inside . Each term is reduced to a coefficient and a + /// product of powers, and terms whose products agree are added together -- + /// enough to collect like terms and nothing more. + /// + private static Entity CollectLikeTerms(Entity expanded) + { + if (expanded is not Sumf and not Minusf) + return expanded; + + // A term carrying a domain condition must not be folded into another: the + // conditions are what say where the answer holds, and adding coefficients + // together loses them. (4a - 2)/(2x) + (1 - 2a)/x is 0 only where x is not 0, + // and collecting it to a plain 0 would drop exactly that. + if (expanded.Nodes.Any(node => node is Providedf)) + return expanded; + + var coefficients = new Dictionary(); + var monomials = new Dictionary(); + var order = new List(); + + foreach (var term in Sumf.LinearChildren(expanded)) + { + Entity coefficient = 1; + var exponents = new Dictionary(); + var bases = new Dictionary(); + + foreach (var factor in Mulf.LinearChildren(term)) + { + // PowerRules folds (x^2)^2 into x^4, without which the two would be + // counted as different monomials. + var reduced = factor.Replace(Functions.Patterns.PowerRules).InnerSimplified; + if (reduced is Number) + { + coefficient = (coefficient * reduced).InnerSimplified; + continue; + } + var (@base, exponent) = reduced is Powf(var b, var e) ? (b, e) : (reduced, (Entity)1); + var key = @base.Stringize(); + bases[key] = @base; + exponents[key] = exponents.TryGetValue(key, out var already) + ? (already + exponent).InnerSimplified + : exponent; + } + + var monomialKey = string.Join(" ", (IEnumerable)exponents.Keys.OrderBy(k => k, System.StringComparer.Ordinal) + .Select(k => k + "^" + exponents[k].Stringize())); + + if (coefficients.TryGetValue(monomialKey, out var running)) + coefficients[monomialKey] = (running + coefficient).InnerSimplified; + else + { + order.Add(monomialKey); + coefficients[monomialKey] = coefficient; + Entity monomial = 1; + foreach (var key in exponents.Keys.OrderBy(k => k, System.StringComparer.Ordinal)) + monomial = (monomial * bases[key].Pow(exponents[key])).InnerSimplified; + monomials[monomialKey] = monomial; + } + } + + Entity result = 0; + foreach (var key in order) + { + // A term whose coefficients cancelled is still written out rather than + // dropped. Where the monomial is defined everywhere it simplifies to 0 and + // costs nothing; where it is not -- x^-1, say -- InnerSimplified turns + // 0 * x^-1 into `0 provided not x = 0`, and dropping the term would have + // thrown that condition away. (4a - 2)/(2x) + (1 - 2a)/x is the case. + var term = (coefficients[key] * monomials[key]).InnerSimplified; + result = result == Integer.Create(0) ? term : result + term; + } + return result.InnerSimplified; } /// diff --git a/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs b/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs index 99e5b7aa1..c1f960495 100644 --- a/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs +++ b/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs @@ -18,7 +18,7 @@ public sealed class IntegrationTest [InlineData("2x", "C + x ^ 2")] [InlineData("x2", "x ^ 3 / 3 + C")] [InlineData("x2 + x", "x ^ 3 / 3 + x ^ 2 / 2 + C")] - [InlineData("x2 - x", "x ^ 3 / 3 - x ^ 2 / 2 + C")] + [InlineData("x2 - x", "C + x ^ 3 / 3 - x ^ 2 / 2")] [InlineData("a / x", "a * ln(abs(x)) + C")] [InlineData("x cos(x)", "cos(x) + sin(x) * x + C")] [InlineData("sin(x)cos(x)", "sin(x) ^ 2 / 2 + C")] @@ -252,7 +252,9 @@ public void TestLnAbsSquared() // (ln|x| - 1)^2 + 1 = ln^2|x| - 2ln|x| + 2, so this is the antiderivative // written above. Term collection used to stop short, leaving the repeated // ln(abs(x)) uncombined and the 2 * x outside the bracket. - Assert.Equal("C + ((ln(abs(x)) - 1) ^ 2 + 1) * x", result.Stringize()); + // (ln|x| - 1)^2 + 1 multiplied out is ln^2|x| - 2ln|x| + 2, which is what + // Expand now returns, having collected the like terms it used to leave apart. + Assert.Equal("C + (2 + ln(abs(x)) ^ 2 + (-2) * ln(abs(x))) * x", result.Stringize()); // Verify the result by differentiation var derivative = result.Differentiate("x"); // TODO: Make this simplify to expr with Simplify() diff --git a/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs b/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs index 5b9c82b4b..7f503413d 100644 --- a/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs +++ b/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs @@ -24,7 +24,9 @@ [Fact] public void Factorize1() => public void Factorial() { var expr = MathS.Factorial(x + 3) / MathS.Factorial(x + 1); - Assert.Equal(MathS.Pow(x, 2) + x * 3 + (2 * x + 6), expr.Expand()); + // Expand adds up like terms now, so 3x and 2x arrive as 5x rather than + // side by side. + Assert.Equal(MathS.Pow(x, 2) + 5 * x + 6, expr.Expand()); expr = MathS.Factorial(x + -3) / MathS.Factorial(x + -1); Assert.Equal(1 / (x + -2) / (x + -1), expr.Expand()); } diff --git a/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs b/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs new file mode 100644 index 000000000..71511bcf4 --- /dev/null +++ b/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs @@ -0,0 +1,72 @@ +// +// 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 AngouriMath; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Common +{ + /// + /// Expand adds up the terms it produces that differ only by a numeric factor. + /// + public sealed class ExpandCollectsTermsTest + { + // https://github.com/asc-community/AngouriMath/issues/164 + // Reported as "cannot be expanded": multiplying it out gave sixteen terms, of + // which only five are distinct. + [Fact] + public void Issue164_RepeatedFactorExpands() => + Assert.Equal("1 + 4 * x + 6 * x ^ 2 + 4 * x ^ 3 + x ^ 4", + "(x+1)^2*(x+2-1)^2".ToEntity().Expand().Stringize()); + + // The maintainer asked for this one on the issue as well. + [Fact] + public void Issue164_CubeOfAProductExpands() => + Assert.Equal("8 + (-36) * x + 66 * x ^ 2 + (-63) * x ^ 3 + 33 * x ^ 4 + (-9) * x ^ 5 + x ^ 6", + "((x-1)*(x-2))^3".ToEntity().Expand().Stringize()); + + [Theory] + [InlineData("(x + 1) ^ 2", "1 + 2 * x + x ^ 2")] + [InlineData("(x + 1) ^ 3", "1 + 3 * x + 3 * x ^ 2 + x ^ 3")] + [InlineData("x * (x + 1)", "x ^ 2 + x")] + public void OrdinaryProductsCollect(string input, string expected) => + Assert.Equal(expected, input.ToEntity().Expand().Stringize()); + + // Nothing may be collected across different variables. + [Fact] + public void UnlikeTermsAreLeftApart() => + Assert.Equal("a * c + a * d + b * c + b * d", "(a+b)*(c+d)".ToEntity().Expand().Stringize()); + + // Whatever comes out has to be the same expression. + [Theory] + [InlineData("(x+1)^2*(x+2-1)^2")] + [InlineData("((x-1)*(x-2))^3")] + [InlineData("(x + 1) ^ 3")] + [InlineData("(x + y) ^ 2")] + [InlineData("(x + 1) ^ 2 * (x + 2)")] + public void ExpansionPreservesValue(string input) + { + var before = input.ToEntity(); + var after = before.Expand(); + foreach (var point in new[] { 0.37, 1.41, -0.8 }) + { + var a = before.Substitute("x", point).Substitute("y", point + 0.5).EvalNumerical(); + var b = after.Substitute("x", point).Substitute("y", point + 0.5).EvalNumerical(); + Assert.Equal(a.RealPart.EDecimal.ToDouble(), b.RealPart.EDecimal.ToDouble(), 9); + } + } + + // Terms whose coefficients cancel are still written out, because the monomial may + // carry a domain condition that dropping the term would throw away: this is zero + // only where x is not. + [Fact] + public void CancellingTermsKeepTheirDomainCondition() => + Assert.Equal("0 provided not x = 0".ToEntity(), + "(4a - 2) / (2x) + (1 - 2a) / x".ToEntity().Simplify()); + } +} diff --git a/Sources/Tests/UnitTests/Core/PolyParser.cs b/Sources/Tests/UnitTests/Core/PolyParser.cs index fb10292f1..bc5d2273a 100644 --- a/Sources/Tests/UnitTests/Core/PolyParser.cs +++ b/Sources/Tests/UnitTests/Core/PolyParser.cs @@ -54,7 +54,8 @@ public void TestLinear3() { if (MathS.Utils.TryGetPolyLinear("a x - b + 3x + x", "x", out var a, out var b)) { - AssertEntityEqual("a + 3 + 1", a); + // 3 and 1 are added together now that Expand collects like terms. + AssertEntityEqual("a + 4", a); AssertEntityEqual("-b", b); } else