rcas 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
# A curve discussion: the questions a course asks about a graph, in the
|
|
5
|
+
# order it asks them. Continental schools give the ritual a name - the
|
|
6
|
+
# German "Kurvendiskussion", the French "etude de fonction", the Italian
|
|
7
|
+
# "studio di funzione"; in English it is curve sketching.
|
|
8
|
+
#
|
|
9
|
+
# discuss(x**3 - 3*x, x) the answers, one row each
|
|
10
|
+
# steps(x**3 - 3*x, x, :discuss) the same questions, worked through
|
|
11
|
+
#
|
|
12
|
+
# Every answer is asked of the function that owns it - real_domain, solve,
|
|
13
|
+
# limit, extrema, inflections, asymptotes - so a discussion cannot
|
|
14
|
+
# disagree with them, and a question rcas cannot answer says so instead of
|
|
15
|
+
# being dropped. The monotonicity and the curvature come from a sign
|
|
16
|
+
# chart: the line is cut at the zeros of f' (of f'') and at the gaps of
|
|
17
|
+
# the domain, and the sign of each piece is read off sample points, which
|
|
18
|
+
# is the table a student draws. Three samples per piece, so a piece whose
|
|
19
|
+
# sign is not constant - a zero the solver missed - is reported as
|
|
20
|
+
# undecided rather than guessed. For a periodic f the chart covers one
|
|
21
|
+
# period and says so.
|
|
22
|
+
#
|
|
23
|
+
# Sources (keys: MANUAL.md, Sources): the questions, the second-derivative
|
|
24
|
+
# test and the sign table are the textbook ones [Spi08, ch. 11].
|
|
25
|
+
module Discussion
|
|
26
|
+
# What a discussion found, one row per question. The expressions stay
|
|
27
|
+
# expressions, so the rows typeset as well as they print.
|
|
28
|
+
class Report
|
|
29
|
+
LABEL = 13
|
|
30
|
+
|
|
31
|
+
SYMMETRY = {
|
|
32
|
+
even: "even: f(-%<x>s) = f(%<x>s), symmetric about the vertical axis",
|
|
33
|
+
odd: "odd: f(-%<x>s) = -f(%<x>s), symmetric about the origin",
|
|
34
|
+
none: "none"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :f, :var, :domain, :symmetry, :period, :zeros, :intercept, :gaps,
|
|
38
|
+
:limits, :asymptotes, :derivatives, :extrema, :monotonicity,
|
|
39
|
+
:inflections, :curvature, :hints
|
|
40
|
+
|
|
41
|
+
def initialize(**parts)
|
|
42
|
+
parts.each { |name, value| instance_variable_set("@#{name}", value) }
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# [label, items, separator, suffix]; an item is a list of strings and
|
|
47
|
+
# expressions, so to_s and to_latex render the same rows.
|
|
48
|
+
def rows
|
|
49
|
+
out = []
|
|
50
|
+
out << ["domain", [[domain || "not determined"]]]
|
|
51
|
+
out << ["symmetry", [[symmetry_text]]]
|
|
52
|
+
out << ["period", [[period]]] if period
|
|
53
|
+
out << ["zeros", items(zeros) { |z| [z] }, nil, repeats(zeros)]
|
|
54
|
+
out << ["y intercept", [["f(0) = ", intercept]]] if intercept
|
|
55
|
+
out << ["gaps", gaps.map { |point, kind, _left, _right| [point, " (#{kind})"] }, nil, repeats(gaps)] unless gaps.empty?
|
|
56
|
+
out << ["at infinity", limits.map { |point, value| limit_item(point, value) }, "; "]
|
|
57
|
+
lines = asymptote_items
|
|
58
|
+
out << ["asymptotes", lines.empty? ? [["none"]] : lines]
|
|
59
|
+
out << ["extrema", items(extrema) { |p, v, kind| ["#{kind} at (", p, ", ", v, ")"] }, "; ", repeats(extrema)]
|
|
60
|
+
out << ["monotonic", items(monotonicity) { |i, kind| ["#{kind} on ", i] }, nil, repeats(monotonicity)]
|
|
61
|
+
out << ["inflections", items(inflections) { |p, v| ["(", p, ", ", v, ")"] }, nil, repeats(inflections)]
|
|
62
|
+
out << ["curvature", items(curvature) { |i, kind| ["#{kind} on ", i] }, nil, repeats(curvature)]
|
|
63
|
+
(hints || []).each { |hint| out << ["note", [[hint]]] }
|
|
64
|
+
out
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def symmetry_text
|
|
68
|
+
template = SYMMETRY[symmetry]
|
|
69
|
+
template.include?("%") ? format(template, x: var) : template
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# "none" and "not determined" are answers too, and different ones.
|
|
73
|
+
def items(values, &block)
|
|
74
|
+
return [["not determined"]] if values.nil?
|
|
75
|
+
return [["none"]] if values.empty?
|
|
76
|
+
values.map(&block)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A periodic function repeats what a row says, one period on.
|
|
80
|
+
def repeats(values)
|
|
81
|
+
period && values && !values.empty? ? ["(+ k*", period, ", k an integer)"] : nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def limit_item(point, value)
|
|
85
|
+
return ["f -> ", value, " as #{var} -> ", point] if value
|
|
86
|
+
["#{period ? 'no limit' : 'not determined'} as #{var} -> ", point]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def asymptote_items
|
|
90
|
+
return [["not determined"]] if asymptotes.values.all?(&:nil?)
|
|
91
|
+
vertical = (asymptotes[:vertical] || []).map { |p| ["#{var} = ", p] }
|
|
92
|
+
lines = vertical + ((asymptotes[:horizontal] || []) + (asymptotes[:oblique] || [])).map { |l| ["y = ", l] }
|
|
93
|
+
lines + asymptotes.select { |_, found| found.nil? }.map { |kind, _| ["#{kind} not determined"] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.render(item) = item.map(&:to_s).join
|
|
97
|
+
|
|
98
|
+
def self.render_latex(item)
|
|
99
|
+
item.map { |part| part.is_a?(String) ? "\\text{#{LaTeX.escape(part)}}" : LaTeX.of(part) }.join
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def to_s
|
|
103
|
+
body = rows.map do |label, list, separator, suffix|
|
|
104
|
+
values = list.map { |item| Report.render(item) }.join(separator || ", ")
|
|
105
|
+
values += " #{Report.render(suffix)}" if suffix
|
|
106
|
+
" #{label.ljust(LABEL)}#{values}"
|
|
107
|
+
end
|
|
108
|
+
(["f(#{var}) = #{f}"] + body).join("\n")
|
|
109
|
+
end
|
|
110
|
+
alias inspect to_s
|
|
111
|
+
|
|
112
|
+
def to_latex(wrap: nil)
|
|
113
|
+
body = rows.map do |label, list, _separator, suffix|
|
|
114
|
+
values = list.map { |item| Report.render_latex(item) }.join(',\; ')
|
|
115
|
+
values += " #{Report.render_latex(suffix)}" if suffix
|
|
116
|
+
"\\text{#{LaTeX.escape(label)}} &: #{values}"
|
|
117
|
+
end
|
|
118
|
+
"\\begin{aligned} f(#{LaTeX.of(var)}) &= #{LaTeX.of(f)} \\\\ #{body.join(' \\\\ ')} \\end{aligned}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
module_function
|
|
123
|
+
|
|
124
|
+
# The whole discussion of f as a function of one indeterminate.
|
|
125
|
+
def discuss(f, var = nil)
|
|
126
|
+
f = Expression.lift(f)
|
|
127
|
+
x = Analysis.variable(f, var)
|
|
128
|
+
domain = domain_of(f, x)
|
|
129
|
+
inside = domain || RealSet.reals
|
|
130
|
+
cycle = period(f, x)
|
|
131
|
+
# zeros are counted in full unless f is periodic, when one period is
|
|
132
|
+
# the chart and the answer: sin(x)/x has zeros at every k*pi, k != 0,
|
|
133
|
+
# and "pi" alone was a truncation (third review, S14)
|
|
134
|
+
@principal = !cycle.nil?
|
|
135
|
+
@cycle = cycle
|
|
136
|
+
first = derivative(f, x)
|
|
137
|
+
second = first && derivative(first, x)
|
|
138
|
+
holes = gaps(f, x)
|
|
139
|
+
cuts = holes.map(&:first)
|
|
140
|
+
Report.new(
|
|
141
|
+
f: f, var: x, domain: domain, symmetry: symmetry(f, x), period: cycle,
|
|
142
|
+
zeros: zeros(f, x, inside), intercept: intercept(f, x, inside),
|
|
143
|
+
gaps: holes, limits: limits_at_infinity(f, x, inside), asymptotes: asymptotes(f, x, inside),
|
|
144
|
+
derivatives: [first, second, second && derivative(second, x)],
|
|
145
|
+
extrema: extrema(f, x, first),
|
|
146
|
+
monotonicity: label(sign_chart(first, x, inside, cuts, cycle), :increasing, :decreasing),
|
|
147
|
+
inflections: inflections(f, x, second),
|
|
148
|
+
curvature: label(sign_chart(second, x, inside, cuts, cycle), :convex, :concave),
|
|
149
|
+
hints: Analysis.root_hints(f, x)
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# nil when rcas cannot differentiate (an unknown function, floor): the
|
|
154
|
+
# whole second half of the discussion then says it is undecided rather
|
|
155
|
+
# than raising in the middle of the report.
|
|
156
|
+
def derivative(e, x)
|
|
157
|
+
tidy(e.diff(x))
|
|
158
|
+
rescue ArgumentError, NotImplementedError, RCAS::Unsupported
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# "Undecided" is an answer here: nil means the row could not be
|
|
163
|
+
# determined and prints as "not determined". These are the ways the
|
|
164
|
+
# library says so. A bare `rescue StandardError` said it for a typo or a
|
|
165
|
+
# genuine bug too, and the tests still passed.
|
|
166
|
+
UNDECIDED = [ArgumentError, DomainError, ZeroDivisionError, NotImplementedError, RCAS::Unsupported, SeriesError].freeze
|
|
167
|
+
|
|
168
|
+
# A derivative reads better cancelled: the second derivative of
|
|
169
|
+
# (x**2 + 1)/x is 2/x**3, not a quotient of quartics.
|
|
170
|
+
def tidy(e)
|
|
171
|
+
Fraction.cancel(e)
|
|
172
|
+
rescue *UNDECIDED
|
|
173
|
+
e.simplify
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ---- the single questions --------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def domain_of(f, x)
|
|
179
|
+
Analysis.real_domain(f, x)
|
|
180
|
+
rescue NotImplementedError, RCAS::Unsupported, ArgumentError, DomainError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reflect(f, x) = f.subs(x => Neg.new(x).simplify).simplify
|
|
185
|
+
|
|
186
|
+
# f(-x) against f(x) and -f(x).
|
|
187
|
+
def symmetry(f, x)
|
|
188
|
+
reflected = reflect(f, x)
|
|
189
|
+
return :even if same?(reflected, f)
|
|
190
|
+
return :odd if same?(reflected, Neg.new(f).simplify)
|
|
191
|
+
:none
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def same?(a, b)
|
|
195
|
+
difference = a - b
|
|
196
|
+
return true if Scalar.zero?(difference.simplify)
|
|
197
|
+
Scalar.zero?(Fraction.cancel(difference))
|
|
198
|
+
rescue *UNDECIDED
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The real solutions of f = 0 that lie in the domain.
|
|
203
|
+
def zeros(f, x, domain)
|
|
204
|
+
points = solutions(f, x) or return nil
|
|
205
|
+
Analysis.sort_points(points.select { |p| in_domain?(domain, p) })
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def in_domain?(domain, point)
|
|
209
|
+
value = Analysis.numeric(point)
|
|
210
|
+
value.nil? || domain.include?(value)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def intercept(f, x, domain)
|
|
214
|
+
return nil unless domain.include?(0)
|
|
215
|
+
value = f.subs(x => Num.new(0)).simplify
|
|
216
|
+
Limits.infinite?(value) ? nil : value
|
|
217
|
+
rescue *UNDECIDED
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Where a denominator vanishes, and whether the graph runs away there:
|
|
222
|
+
# [[point, :pole | :removable | :gap, limit from the left, from the right], ...]
|
|
223
|
+
def gaps(f, x)
|
|
224
|
+
points = Analysis.denominators(f, x).flat_map { |d| solutions(d, x) || [] }.uniq
|
|
225
|
+
Analysis.sort_points(points).map do |point|
|
|
226
|
+
probe = point.is_a?(ImageSet) ? point.at(0) : point # a family stands for its members
|
|
227
|
+
left = one_sided(f, x, probe, :left)
|
|
228
|
+
right = one_sided(f, x, probe, :right)
|
|
229
|
+
kind = if left.nil? || right.nil? then :gap
|
|
230
|
+
elsif Limits.infinite?(left) || Limits.infinite?(right) then :pole
|
|
231
|
+
else :removable
|
|
232
|
+
end
|
|
233
|
+
[point, kind, left, right]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def one_sided(f, x, point, side)
|
|
238
|
+
value = Limits.limit(f, x, point, side)
|
|
239
|
+
value.is_a?(Limit) ? nil : value
|
|
240
|
+
rescue *UNDECIDED
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# [[-oo, value], [oo, value]]; a limit rcas cannot find is nil, and an
|
|
245
|
+
# end the domain does not reach is not asked about at all.
|
|
246
|
+
def limits_at_infinity(f, x, domain)
|
|
247
|
+
ends(domain).map do |point|
|
|
248
|
+
value = begin
|
|
249
|
+
found = Limits.limit(f, x, point)
|
|
250
|
+
found.is_a?(Limit) ? nil : found.simplify
|
|
251
|
+
rescue *UNDECIDED
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
[point, value]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# A line the graph approaches. A line that *is* the graph - the oblique
|
|
259
|
+
# asymptote of 2*x + 1 is 2*x + 1 - is not worth saying. A vertical one
|
|
260
|
+
# is a place rather than a line, and may be a whole family of them
|
|
261
|
+
# ({pi/2 + pi*k | k in ZZ} for the tangent), so that question is not
|
|
262
|
+
# asked of it.
|
|
263
|
+
# Each kind on its own, nil where it is not determined - an undecided
|
|
264
|
+
# limit made every row "none" (fourth review, S11), and a vertical
|
|
265
|
+
# asymptote was lost with a horizontal one rcas could not take. A
|
|
266
|
+
# periodic function that is not constant has no limit at infinity, so
|
|
267
|
+
# neither a horizontal nor an oblique asymptote.
|
|
268
|
+
def asymptotes(f, x, domain)
|
|
269
|
+
ends = ends(domain)
|
|
270
|
+
found = { vertical: undecided_as_nil { Analysis.vertical_asymptotes(f, x) } }
|
|
271
|
+
if @cycle
|
|
272
|
+
found[:horizontal] = found[:oblique] = []
|
|
273
|
+
else
|
|
274
|
+
found[:horizontal] = undecided_as_nil { Analysis.horizontal_asymptotes(f, x, at: ends) }
|
|
275
|
+
found[:oblique] = undecided_as_nil { Analysis.oblique_asymptotes(f, x, at: ends) }
|
|
276
|
+
end
|
|
277
|
+
found.to_h do |kind, lines|
|
|
278
|
+
[kind, kind == :vertical || lines.nil? ? lines : lines.reject { |line| same?(line, f) }]
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def undecided_as_nil
|
|
283
|
+
yield
|
|
284
|
+
rescue *UNDECIDED
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The infinite ends the domain reaches: log(x) has no minus infinity to
|
|
289
|
+
# ask about, and an answer there would be an answer about nothing.
|
|
290
|
+
def ends(domain)
|
|
291
|
+
minus, plus = Neg.new(OO).simplify, OO
|
|
292
|
+
reached = []
|
|
293
|
+
reached << minus if domain.intervals.first&.low == minus
|
|
294
|
+
reached << plus if domain.intervals.last&.high == plus
|
|
295
|
+
reached
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The extrema, but nil rather than [] when the critical points are out of
|
|
299
|
+
# reach: "none" and "I could not tell" are different answers.
|
|
300
|
+
def extrema(f, x, first)
|
|
301
|
+
points = first && solutions(first, x) or return nil
|
|
302
|
+
return nil if points.any? { |p| p.is_a?(ImageSet) } # infinitely many, not charted
|
|
303
|
+
Analysis.extrema(f, x, points: Analysis.sort_points(points))
|
|
304
|
+
rescue *UNDECIDED
|
|
305
|
+
nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def inflections(f, x, second)
|
|
309
|
+
points = second && solutions(second, x) or return nil
|
|
310
|
+
return nil if points.any? { |p| p.is_a?(ImageSet) }
|
|
311
|
+
Analysis.inflections(f, x, points: points).map { |point| [point, f.subs(x => point).simplify] }
|
|
312
|
+
rescue *UNDECIDED
|
|
313
|
+
nil # a curvature rcas cannot decide is "not determined", not "none"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# The real zeros of g, or nil when rcas cannot solve g = 0. One period
|
|
317
|
+
# of them for a periodic f, all of them (families included) otherwise.
|
|
318
|
+
def solutions(g, x)
|
|
319
|
+
g = Expression.lift(g)
|
|
320
|
+
return [] unless g.variables.include?(x.name)
|
|
321
|
+
begin
|
|
322
|
+
# a discussion is about a real function: its points are real ones
|
|
323
|
+
# (solve is complete over CC since the fifth review)
|
|
324
|
+
found = (@cycle && one_period(g, x)) || Solve.solve(g, x, principal: @principal != false, domain: RR)
|
|
325
|
+
return nil unless found.is_a?(Array)
|
|
326
|
+
found.select { |root| root.is_a?(ImageSet) ? !root.nonreal? : real?(root) }
|
|
327
|
+
rescue NotImplementedError, RCAS::Unsupported, ArgumentError, DomainError
|
|
328
|
+
factor_solutions(g, x)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The solutions in [0, T) for the period T of f, counted out of the
|
|
333
|
+
# complete answer: the principal solutions of g are one period of g,
|
|
334
|
+
# which need not be f's - sin(2*x)/sin(x) has period 2*pi and its
|
|
335
|
+
# principal zeros were those of sin(2*x), one period of pi, so 3*pi/2
|
|
336
|
+
# was missing (fourth review). nil when the families cannot be counted.
|
|
337
|
+
def one_period(g, x)
|
|
338
|
+
found = Solve.solve(g, x, domain: RR)
|
|
339
|
+
return nil unless found.is_a?(Array)
|
|
340
|
+
top = Analysis.numeric(@cycle) or return nil
|
|
341
|
+
found.flat_map do |root|
|
|
342
|
+
next [root] unless root.is_a?(ImageSet)
|
|
343
|
+
members = root.between(0.0, top) or return nil
|
|
344
|
+
members.reject { |m| Inequalities.compare(m, @cycle) != -1 }
|
|
345
|
+
end
|
|
346
|
+
rescue *UNDECIDED
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# A product vanishes where one of its factors does, so a product solve
|
|
351
|
+
# will not touch can still be solvable factor by factor: the derivative
|
|
352
|
+
# of exp(-x**2) is -2*x*exp(-x**2), and the exponential is never zero.
|
|
353
|
+
# Every root found this way is checked against g before it is believed.
|
|
354
|
+
def factor_solutions(g, x)
|
|
355
|
+
roots = pieces(g, x).flat_map { |piece| Solve.solve(piece, x, principal: true).select { |root| real?(root) } }
|
|
356
|
+
roots.uniq.select { |root| vanishes?(g, x, root) }
|
|
357
|
+
rescue *UNDECIDED
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
MAX_SPLIT = 3
|
|
362
|
+
|
|
363
|
+
# The parts of g that can vanish: its factors, and, for a sum whose terms
|
|
364
|
+
# share a factor, that factor and what the sum leaves when it is divided
|
|
365
|
+
# out. The second derivative of exp(-x**2) is a sum, not a product, until
|
|
366
|
+
# the exponential is taken out of it.
|
|
367
|
+
def pieces(g, x, depth = 0)
|
|
368
|
+
_coefficient, factors = Simplify.factorize(g, simplify: true)
|
|
369
|
+
factors.flat_map do |base, exponent|
|
|
370
|
+
next [] if exponent.is_a?(Numeric) && exponent.negative?
|
|
371
|
+
next [] unless base.variables.include?(x.name)
|
|
372
|
+
parts = depth < MAX_SPLIT ? split_common(base) : nil
|
|
373
|
+
parts ? parts.flat_map { |part| pieces(part, x, depth + 1) } : [base]
|
|
374
|
+
end.uniq
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# [what every term of the sum has in common, what is left], or nil.
|
|
378
|
+
def split_common(g)
|
|
379
|
+
Simplify.common_factor(g)
|
|
380
|
+
rescue *UNDECIDED
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def vanishes?(g, x, root)
|
|
385
|
+
Decide.zero?(g.subs(x => root).simplify) != false
|
|
386
|
+
rescue *UNDECIDED
|
|
387
|
+
true
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Decided, not measured: 1 + 10**-13*i is not a real zero of
|
|
391
|
+
# (x - 1)**2 + 10**-26, and an |Im| < 1e-12 test said it was (Q1). A
|
|
392
|
+
# point whose realness cannot be told is kept.
|
|
393
|
+
def real?(value)
|
|
394
|
+
Inequalities.real?(value)
|
|
395
|
+
rescue NotImplementedError, RCAS::Unsupported
|
|
396
|
+
true
|
|
397
|
+
rescue *UNDECIDED
|
|
398
|
+
false
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# ---- the sign chart --------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
# The sign of g piece by piece: [[Interval, :positive | :negative], ...],
|
|
404
|
+
# or nil when the zeros, the samples or the signs are out of reach. The
|
|
405
|
+
# line is cut at the zeros of g, at the gaps handed in, and at the ends
|
|
406
|
+
# of the domain; pieces that the domain leaves out are dropped, and two
|
|
407
|
+
# pieces meeting at a point of the domain are merged.
|
|
408
|
+
def sign_chart(g, x, domain, extra = [], cycle = nil)
|
|
409
|
+
return nil if g.nil?
|
|
410
|
+
return [] if constant_zero?(g)
|
|
411
|
+
cuts = chart_cuts(g, x, domain, extra, cycle) or return nil
|
|
412
|
+
pieces = []
|
|
413
|
+
cuts.each_cons(2) do |(low_value, low), (high_value, high)|
|
|
414
|
+
sign = piece_sign(g, x, low_value, high_value, domain)
|
|
415
|
+
next if sign == :outside
|
|
416
|
+
return nil if sign.nil?
|
|
417
|
+
piece = Interval.new(low, high, left_open: true, right_open: true)
|
|
418
|
+
last = pieces.last
|
|
419
|
+
# never across a gap: tan is increasing on (0, pi/2) and on
|
|
420
|
+
# (pi/2, pi), and not on (0, pi) (S13)
|
|
421
|
+
across_gap = extra.any? { |e| !e.is_a?(ImageSet) && Inequalities.compare(e, low)&.zero? }
|
|
422
|
+
if last && last[1] == sign && last[0].high == low && domain.include?(low_value) && !across_gap
|
|
423
|
+
pieces[-1] = [Interval.open(last[0].low, high), sign]
|
|
424
|
+
else
|
|
425
|
+
pieces << [piece, sign]
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
pieces.empty? ? nil : pieces
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# [[value, expression], ...] in order, the ends included.
|
|
432
|
+
def chart_cuts(g, x, domain, extra, cycle)
|
|
433
|
+
points = solutions(g, x) or return nil
|
|
434
|
+
return nil if (points + extra).any? { |p| p.is_a?(ImageSet) } # infinitely many pieces
|
|
435
|
+
cuts = (points + extra).map { |p| [Analysis.numeric(p), p] }
|
|
436
|
+
return nil if cuts.any? { |value, _| value.nil? }
|
|
437
|
+
cuts = window(cuts, cycle) or return nil if cycle
|
|
438
|
+
bounds = domain.intervals.flat_map { |i| [[i.low_value, i.low], [i.high_value, i.high]] }
|
|
439
|
+
cuts += bounds.select { |value, _| value.is_a?(Float) && value.finite? } unless cycle
|
|
440
|
+
cuts = cuts.uniq { |value, _| value }.sort_by(&:first)
|
|
441
|
+
return cuts if cycle
|
|
442
|
+
[[-Float::INFINITY, Neg.new(OO).simplify]] + cuts + [[Float::INFINITY, OO]]
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# One period, from 0: every cut moved into [0, p] by whole periods.
|
|
446
|
+
def window(cuts, cycle)
|
|
447
|
+
length = Analysis.numeric(cycle) or return nil
|
|
448
|
+
moved = cuts.map do |value, point|
|
|
449
|
+
shift = (value / length).floor
|
|
450
|
+
shift.zero? ? [value, point] : [value - shift * length, (point - shift * cycle).simplify]
|
|
451
|
+
end
|
|
452
|
+
([[0.0, Num.new(0)]] + moved + [[length, cycle]]).uniq { |value, _| value.round(9) }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# g is the zero function: a straight graph has no curvature to report,
|
|
456
|
+
# and a constant one no monotonicity.
|
|
457
|
+
def constant_zero?(g)
|
|
458
|
+
Scalar.zero?(g.simplify) # identically zero, cancelled and trig identities included
|
|
459
|
+
rescue *UNDECIDED
|
|
460
|
+
false
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# :positive, :negative, :outside (no sample is in the domain), or nil
|
|
464
|
+
# when the samples disagree or none of them says anything. A sample too
|
|
465
|
+
# close to zero to read - exp(-x**2) far out - is skipped, not believed.
|
|
466
|
+
def piece_sign(g, x, low, high, domain)
|
|
467
|
+
points = sample_points(low, high).select { |s| domain.include?(s) }
|
|
468
|
+
return :outside if points.empty?
|
|
469
|
+
# each sample decided exactly at a rational point, skipped when
|
|
470
|
+
# undecided (it was a Float below 1e-12 that was skipped)
|
|
471
|
+
signs = points.filter_map do |sample|
|
|
472
|
+
value = begin
|
|
473
|
+
g.subs(x => Num.new(Rational(sample).rationalize(Rational(1, 10**12)))).simplify
|
|
474
|
+
rescue ZeroDivisionError
|
|
475
|
+
next nil
|
|
476
|
+
end
|
|
477
|
+
sign = Decide.sign(value)
|
|
478
|
+
%i[positive negative].include?(sign) ? sign : nil
|
|
479
|
+
end.uniq
|
|
480
|
+
signs.size == 1 ? signs.first : nil
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Three points a piece, so that a piece hiding a zero is noticed.
|
|
484
|
+
SAMPLES = 3
|
|
485
|
+
OFFSETS = [0.5, 1.5, 4.0].freeze
|
|
486
|
+
|
|
487
|
+
def sample_points(low, high)
|
|
488
|
+
return OFFSETS.map { |o| o - 1.0 } if low.infinite? && high.infinite?
|
|
489
|
+
return OFFSETS.map { |o| high - o } if low.infinite?
|
|
490
|
+
return OFFSETS.map { |o| low + o } if high.infinite?
|
|
491
|
+
(1..SAMPLES).map { |i| low + (high - low) * i / (SAMPLES + 1) }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def label(chart, positive, negative)
|
|
495
|
+
chart&.map { |piece, sign| [piece, sign == :positive ? positive : negative] }
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# ---- periodicity -----------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
# 2*pi/|a| for sin(a*x + b), pi/|a| for tan.
|
|
501
|
+
PERIODS = { sin: 2, cos: 2, sec: 2, csc: 2, tan: 1, cot: 1 }.freeze
|
|
502
|
+
|
|
503
|
+
# A period of f: the least common multiple of the periods of its
|
|
504
|
+
# trigonometric parts, once a sample of points confirms it (nil
|
|
505
|
+
# otherwise - rcas never guesses one). The smallest period, unless the
|
|
506
|
+
# expression hides a shorter one, as cos(x)**2 = (1 + cos(2*x))/2 does.
|
|
507
|
+
def period(f, x)
|
|
508
|
+
bases = period_bases(f, x) or return nil
|
|
509
|
+
return nil if bases.empty?
|
|
510
|
+
first = Analysis.numeric(bases.first) or return nil
|
|
511
|
+
multiple = Rational(1)
|
|
512
|
+
bases.each do |base|
|
|
513
|
+
value = Analysis.numeric(base) or return nil
|
|
514
|
+
ratio = Rational(value / first).rationalize(Rational(1, 10**6))
|
|
515
|
+
return nil if ratio.denominator > 100
|
|
516
|
+
multiple = Rational(multiple.numerator.lcm(ratio.numerator), multiple.denominator.gcd(ratio.denominator))
|
|
517
|
+
end
|
|
518
|
+
candidate = (bases.first * Num.new(multiple)).simplify
|
|
519
|
+
periodic?(f, x, candidate) ? candidate : nil
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def period_bases(f, x)
|
|
523
|
+
bases = []
|
|
524
|
+
f.each_node do |node|
|
|
525
|
+
next unless node.is_a?(Fn) && PERIODS.key?(node.name)
|
|
526
|
+
argument = node.args.first
|
|
527
|
+
return nil unless argument.variables.include?(x.name)
|
|
528
|
+
slope = linear_slope(argument, x) or return nil
|
|
529
|
+
bases << (Num.new(PERIODS[node.name]) * PI / slope).simplify
|
|
530
|
+
end
|
|
531
|
+
bases.uniq
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# The a of a*x + b, positive, or nil when the argument is not linear.
|
|
535
|
+
def linear_slope(argument, x)
|
|
536
|
+
return nil unless Coefficients.degree(argument, x) == 1
|
|
537
|
+
slope = Coefficients.coeff(argument, x, 1)
|
|
538
|
+
value = Analysis.numeric(slope) or return nil
|
|
539
|
+
value.negative? ? Neg.new(slope).simplify : slope
|
|
540
|
+
rescue DomainError, NotImplementedError, RCAS::Unsupported, ArgumentError
|
|
541
|
+
nil
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# f(x + T) = f(x) as an identity, shown by the normal forms (the
|
|
545
|
+
# addition theorems drop the whole turns); four samples agreeing is not
|
|
546
|
+
# a period - sin(x) + g(x)/10**6 with g vanishing at those samples and
|
|
547
|
+
# their shifts tends to oo (fourth review).
|
|
548
|
+
def periodic?(f, x, candidate)
|
|
549
|
+
shifted = f.subs(x => (x + candidate).simplify)
|
|
550
|
+
difference = (shifted - f).simplify
|
|
551
|
+
return true if difference.is_a?(Num) && difference.value.zero?
|
|
552
|
+
Scalar.identically_zero?(difference) || Scalar.identically_zero?(Trigonometry.expand_trig(difference).simplify)
|
|
553
|
+
rescue StandardError => rescued
|
|
554
|
+
RCAS.guard!(rescued)
|
|
555
|
+
false
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|