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,966 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
# Curve sketching and several variables: the calculus a course actually
|
|
5
|
+
# asks for, built from diff, solve and limit.
|
|
6
|
+
#
|
|
7
|
+
# extrema(x**3 - 3*x, x) # => [[-1, 2, :maximum], [1, -2, :minimum]]
|
|
8
|
+
# inflections(x**3 - 3*x, x) # => [0]
|
|
9
|
+
# asymptotes((x**2 + 1)/x, x) # vertical, horizontal and oblique
|
|
10
|
+
# tangent(x**2, x, 1) # => -1 + 2*x
|
|
11
|
+
# gradient(x**2*y, [x, y]) # => (2*x*y, x**2)
|
|
12
|
+
# hessian(x**2*y, [x, y]) # the second derivatives as a matrix
|
|
13
|
+
# lagrange(x + y, [x**2 + y**2 - 1], [x, y])
|
|
14
|
+
#
|
|
15
|
+
# Sources (keys: MANUAL.md, Sources): the second-derivative test, the
|
|
16
|
+
# asymptote limits and Lagrange multipliers are the textbook ones
|
|
17
|
+
# [Spi08, ch. 11, 17]; the Hessian and Jacobian follow [Rud76, ch. 9].
|
|
18
|
+
module Analysis
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def variable(f, var)
|
|
22
|
+
return Expression.lift(var) if var
|
|
23
|
+
names = Expression.lift(f).variables
|
|
24
|
+
raise ArgumentError, "which variable? give one, e.g. extrema(f, x)" unless names.size == 1
|
|
25
|
+
Var.new(names.first)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def numeric(value) = RCAS.real_float(value)
|
|
29
|
+
|
|
30
|
+
# ---- one variable ----------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# Where the derivative vanishes, sorted where they can be compared.
|
|
33
|
+
#
|
|
34
|
+
# An equation solve cannot do is a refusal, not "no critical points":
|
|
35
|
+
# cos(x) + x**2/4 has its maximum at 0 all the same (third review,
|
|
36
|
+
# S11). Points where f itself has no value are not points of its graph.
|
|
37
|
+
def critical_points(f, var = nil)
|
|
38
|
+
f = Expression.lift(f)
|
|
39
|
+
x = variable(f, var)
|
|
40
|
+
found = Solve.solve(f.diff(x), x, principal: true)
|
|
41
|
+
raise RCAS::Unsupported, "critical points of #{f}: the derivative vanishes on a whole interval" unless found.is_a?(Array)
|
|
42
|
+
sort_points(found.select { |p| real_point?(p) && defined_at?(f, x, p) })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# f has a real value at the point (a family counts as defined).
|
|
46
|
+
def defined_at?(f, x, point)
|
|
47
|
+
return true if point.is_a?(ImageSet)
|
|
48
|
+
value = f.subs(x => point).simplify
|
|
49
|
+
return false unless Integrate.defined_value?(value)
|
|
50
|
+
real = Inequalities.real?(value)
|
|
51
|
+
real != false
|
|
52
|
+
rescue ZeroDivisionError
|
|
53
|
+
false
|
|
54
|
+
rescue NotImplementedError, RCAS::Unsupported
|
|
55
|
+
true # realness undecided: the point is kept, and says so nowhere else
|
|
56
|
+
rescue StandardError => rescued
|
|
57
|
+
RCAS.guard!(rescued)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sort_points(points)
|
|
62
|
+
points.sort_by { |p| [numeric(p) ? 0 : 1, numeric(p) || 0.0, p.to_s] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A curve discussion is about a real function, so a complex root of the
|
|
66
|
+
# derivative is not a critical point of its graph: critical_points of
|
|
67
|
+
# x**3 + x reported the two roots of 3*x**2 + 1. A point rcas cannot
|
|
68
|
+
# evaluate stays, since not knowing is not the same as knowing it is
|
|
69
|
+
# complex - but one whose imaginary unit is written into it goes.
|
|
70
|
+
# Decided exactly (Inequalities.real?): 1 + 10**-13*i is not real,
|
|
71
|
+
# however small its imaginary part (third review, Q1); an unevaluated
|
|
72
|
+
# log(-1) is not either.
|
|
73
|
+
def real_point?(point)
|
|
74
|
+
return true if point.is_a?(ImageSet)
|
|
75
|
+
Inequalities.real?(point)
|
|
76
|
+
rescue NotImplementedError, RCAS::Unsupported
|
|
77
|
+
true # not knowing is not knowing it is complex
|
|
78
|
+
rescue StandardError => rescued
|
|
79
|
+
RCAS.guard!(rescued)
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# [[x, f(x), :minimum | :maximum | :saddle], ...] by the second derivative,
|
|
84
|
+
# falling back to the sign of the first derivative on either side.
|
|
85
|
+
# `points` supplies the critical points, for a caller that knows them
|
|
86
|
+
# (or can find more of them) already.
|
|
87
|
+
#
|
|
88
|
+
# The kind is decided by the first derivative of f that does not vanish
|
|
89
|
+
# at the point, which is exact: an even order is an extremum, an odd
|
|
90
|
+
# one a saddle. The old test read f'' against 1e-12 and then sampled f'
|
|
91
|
+
# at +-1e-4, which stepped over a zero of f' at 10**-5 and called a
|
|
92
|
+
# saddle a minimum (third review, S10).
|
|
93
|
+
def extrema(f, var = nil, points: nil)
|
|
94
|
+
f = Expression.lift(f)
|
|
95
|
+
x = variable(f, var)
|
|
96
|
+
first = f.diff(x)
|
|
97
|
+
candidates = points || critical_points(f, x)
|
|
98
|
+
candidates.filter_map do |point|
|
|
99
|
+
next nil unless defined_at?(f, x, point)
|
|
100
|
+
value = (f.subs(x => point)).simplify
|
|
101
|
+
kind = extremum_kind(first, x, point, candidates)
|
|
102
|
+
kind ? [point, value, kind] : nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extremum_kind(first, x, point, others)
|
|
107
|
+
found = vanishing(first, x, point)
|
|
108
|
+
if found
|
|
109
|
+
order, sign = found
|
|
110
|
+
return :saddle if order.even? # f' vanishes to an even order: f has an odd one
|
|
111
|
+
return sign == :positive ? :minimum : :maximum
|
|
112
|
+
end
|
|
113
|
+
sign_change(first, x, point, step: safe_step(point, others))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# [k, sign] for the smallest k >= 1 with g^(k)(point) != 0 decided, or
|
|
117
|
+
# nil when that cannot be told within the budget.
|
|
118
|
+
def vanishing(g, x, point, limit = nil)
|
|
119
|
+
limit ||= vanishing_limit(g, x)
|
|
120
|
+
limit.times do |k|
|
|
121
|
+
g = g.diff(x)
|
|
122
|
+
value = g.subs(x => point).simplify
|
|
123
|
+
if value.variables.empty?
|
|
124
|
+
sign = Decide.sign(value)
|
|
125
|
+
return nil if sign.nil?
|
|
126
|
+
next if sign == :zero
|
|
127
|
+
return [k + 1, sign]
|
|
128
|
+
end
|
|
129
|
+
next if Scalar.zero?(value)
|
|
130
|
+
# 6*a at 0 for a*x**3 + x**4: whether it vanishes depends on a
|
|
131
|
+
raise RCAS::Unsupported, "whether #{value} is zero decides the shape at #{point}; assume something about #{value.variables.join(', ')}"
|
|
132
|
+
end
|
|
133
|
+
nil
|
|
134
|
+
rescue ArgumentError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The shape of a critical point from the sign of g on both sides. The
|
|
139
|
+
# step must stay inside the point's own neighbourhood: with a zero of g
|
|
140
|
+
# at 10**-5 next door, the default 10**-4 samples the far side of it and
|
|
141
|
+
# reads the wrong sign (22 Sept 2026, from the second review).
|
|
142
|
+
# The signs are decided exactly at rational points (Decide), so an
|
|
143
|
+
# underflow is not read as a zero (T4a).
|
|
144
|
+
def sign_change(g, x, point, step: nil)
|
|
145
|
+
centre = numeric(point) or return nil
|
|
146
|
+
step ||= 1e-4 * [1.0, centre.abs].max
|
|
147
|
+
return nil unless step&.positive?
|
|
148
|
+
exact = Expression.lift(point)
|
|
149
|
+
delta = Num.new(Rational(step).rationalize(Rational(step) / 1000))
|
|
150
|
+
left = Decide.sign(g.subs(x => (exact - delta).simplify).simplify)
|
|
151
|
+
right = Decide.sign(g.subs(x => (exact + delta).simplify).simplify)
|
|
152
|
+
return nil unless %i[positive negative].include?(left) && %i[positive negative].include?(right)
|
|
153
|
+
return :minimum if left == :negative && right == :positive
|
|
154
|
+
return :maximum if left == :positive && right == :negative
|
|
155
|
+
:saddle
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Where the curvature changes sign; `points` supplies the zeros of the
|
|
159
|
+
# second derivative, as for extrema.
|
|
160
|
+
#
|
|
161
|
+
# "Cannot solve f'' = 0" is a refusal, never "no inflections": x*sin(x)
|
|
162
|
+
# has f''(0) = 2 and f''(pi) = -2, so there is one in between (T4b).
|
|
163
|
+
# A point outside the domain of f is no point of the graph (S14).
|
|
164
|
+
def inflections(f, var = nil, points: nil)
|
|
165
|
+
f = Expression.lift(f)
|
|
166
|
+
x = variable(f, var)
|
|
167
|
+
second = f.diff(x, 2)
|
|
168
|
+
candidates = points || Solve.solve(second, x, principal: true)
|
|
169
|
+
raise RCAS::Unsupported, "inflections of #{f}: f'' vanishes on a whole interval" unless candidates.is_a?(Array)
|
|
170
|
+
real = candidates.select { |p| real_point?(p) && defined_at?(f, x, p) }
|
|
171
|
+
sort_points(real.select { |point| inflection_at?(second, x, point, real) })
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# How far a derivative of g has to be taken before it stops vanishing,
|
|
175
|
+
# for anything that is not a polynomial; a polynomial has its degree as
|
|
176
|
+
# the bound, so x**17 is decided however small its coefficient (T4a).
|
|
177
|
+
MAX_VANISHING = 12
|
|
178
|
+
|
|
179
|
+
def vanishing_limit(g, x)
|
|
180
|
+
coefficients = Solve.polynomial_coefficients(g.expand, x)
|
|
181
|
+
coefficients ? [coefficients.size, 1].max : MAX_VANISHING
|
|
182
|
+
rescue StandardError => rescued
|
|
183
|
+
RCAS.guard!(rescued)
|
|
184
|
+
MAX_VANISHING
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# f'' changes sign at one of its zeros exactly when it vanishes there to
|
|
188
|
+
# an *odd* order [Spi08, ch. 11], and that order is the first derivative of f'' that
|
|
189
|
+
# does not vanish - which `Scalar.zero?` decides exactly for a rational
|
|
190
|
+
# point of a polynomial. This replaces the old test, which asked whether
|
|
191
|
+
# f''' was numerically above 10**-12 and fell back to a sign chart: with
|
|
192
|
+
# f'' = x**2*(x - a) and a = 10**-5 the chart stepped over a and
|
|
193
|
+
# reported an inflection at 0, and with f'' = x**3*(x - a) the exactly
|
|
194
|
+
# non-zero f'''(a) = a**3 = 10**-15 was read as zero (22 Sept 2026, the
|
|
195
|
+
# second review). The chart is now the fallback, on a step that stays
|
|
196
|
+
# inside the point's own neighbourhood; when even that cannot decide,
|
|
197
|
+
# the answer is "undecided" and not "no inflection".
|
|
198
|
+
def inflection_at?(second, x, point, others)
|
|
199
|
+
found = begin
|
|
200
|
+
vanishing(second, x, point)
|
|
201
|
+
rescue NotImplementedError, RCAS::Unsupported
|
|
202
|
+
# a parameter decides the order, but maybe not its parity: f'' =
|
|
203
|
+
# 6*a*x + 20*x**3 vanishes to order 1 for a != 0 and 3 for a = 0,
|
|
204
|
+
# an inflection either way (the review's S22 had "6*a decides")
|
|
205
|
+
parities = possible_orders(second, x, point)&.map(&:odd?)&.uniq
|
|
206
|
+
return parities.first if parities&.size == 1
|
|
207
|
+
raise
|
|
208
|
+
end
|
|
209
|
+
return found.first.odd? if found
|
|
210
|
+
change = sign_change(second, x, point, step: safe_step(point, others))
|
|
211
|
+
return change != :saddle if change
|
|
212
|
+
raise RCAS::Unsupported, "inflections: whether the curvature changes at #{point} is not decided here"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The orders g could vanish to at the point, over the parameters: each
|
|
216
|
+
# derivative whose value there depends on a parameter is where it
|
|
217
|
+
# stops if that value is not 0, and the search goes on for the case
|
|
218
|
+
# that it is; a constant that is not 0 ends it. nil when undecided.
|
|
219
|
+
def possible_orders(g, x, point)
|
|
220
|
+
orders = []
|
|
221
|
+
vanishing_limit(g, x).times do |k|
|
|
222
|
+
g = g.diff(x)
|
|
223
|
+
value = g.subs(x => point).simplify
|
|
224
|
+
if value.variables.empty?
|
|
225
|
+
sign = Decide.sign(value)
|
|
226
|
+
return nil if sign.nil?
|
|
227
|
+
next if sign == :zero
|
|
228
|
+
return orders << k + 1
|
|
229
|
+
end
|
|
230
|
+
orders << k + 1 unless Scalar.zero?(value)
|
|
231
|
+
end
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The smallest k >= 1 with g^(k)(point) != 0, or nil.
|
|
236
|
+
def vanishing_order(g, x, point, limit = nil) = vanishing(g, x, point, limit)&.first
|
|
237
|
+
|
|
238
|
+
# Half the way to the nearest other candidate, at most the default step:
|
|
239
|
+
# no sample may cross a neighbouring zero.
|
|
240
|
+
def safe_step(point, others)
|
|
241
|
+
centre = numeric(point) or return nil
|
|
242
|
+
step = 1e-4 * [1.0, centre.abs].max
|
|
243
|
+
gaps = others.filter_map { |q| (v = numeric(q)) && (v - centre).abs }.reject { |d| d < 1e-15 }
|
|
244
|
+
gaps.empty? ? step : [step, gaps.min / 2].min
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# { vertical: [...], horizontal: [...], oblique: [...] }; the horizontal
|
|
248
|
+
# and oblique lines are the limits at minus and plus infinity. `at` names
|
|
249
|
+
# the ends to look at, for a function that only reaches one of them.
|
|
250
|
+
def asymptotes(f, var = nil, at: nil)
|
|
251
|
+
f = Expression.lift(f)
|
|
252
|
+
x = variable(f, var)
|
|
253
|
+
ends = at || infinities
|
|
254
|
+
{ vertical: vertical_asymptotes(f, x), horizontal: horizontal_asymptotes(f, x, at: ends), oblique: oblique_asymptotes(f, x, at: ends) }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def infinities = [OO, Neg.new(OO).simplify]
|
|
258
|
+
|
|
259
|
+
# The candidates are the zeros of the denominators and the edges of the
|
|
260
|
+
# logarithms' domains - log(x) runs to -oo at 0, which a search of the
|
|
261
|
+
# denominators alone never saw (S12). A denominator whose zeros solve
|
|
262
|
+
# cannot name is a refusal: 1/(exp(x) - x - 2) has two poles (S11).
|
|
263
|
+
def vertical_asymptotes(f, x)
|
|
264
|
+
edges = []
|
|
265
|
+
f.each_node { |n| edges << n.args.first if n.is_a?(Fn) && n.name == :log && n.args.first.variables.include?(x.name) }
|
|
266
|
+
poles = (denominators(f, x) + edges).uniq.flat_map do |g|
|
|
267
|
+
found = Solve.solve(g, x, domain: RR)
|
|
268
|
+
raise RCAS::Unsupported, "vertical asymptotes of #{f}: #{g} = 0 vanishes on a whole interval" unless found.is_a?(Array)
|
|
269
|
+
found
|
|
270
|
+
end
|
|
271
|
+
sort_points(poles.uniq.select { |p| real_point?(p) && runs_away?(f, x, p) })
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# A whole family of asymptotes is reported as the family: tan has one
|
|
275
|
+
# at every odd multiple of pi/2, and a member of the set stands for all
|
|
276
|
+
# of them when the limit is taken.
|
|
277
|
+
# Either side infinite is an asymptote, both finite is none, and a limit
|
|
278
|
+
# rcas cannot take is neither: a refusal (fourth review, S11). A side
|
|
279
|
+
# where f has no real values (left of 0 for 1/log(x)) does not count.
|
|
280
|
+
def runs_away?(f, x, point)
|
|
281
|
+
probe = point.is_a?(ImageSet) ? point.at(0) : point
|
|
282
|
+
sides = %i[right left].map { |side| Limits.limit(f, x, probe, side) }
|
|
283
|
+
return true if sides.any? { |v| Limits.infinite?(v) }
|
|
284
|
+
sides = sides.zip([1, -1]).reject { |v, side| v.is_a?(Limit) && !real_beside?(f, x, probe, side) }.map(&:first)
|
|
285
|
+
return false if sides.none? { |v| v.is_a?(Limit) }
|
|
286
|
+
raise RCAS::Unsupported, "vertical asymptotes of #{f}: the limit at #{probe} is not decided here"
|
|
287
|
+
rescue ZeroDivisionError
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# f has real values out towards an infinity: false only when that is
|
|
292
|
+
# decided at +-1000.
|
|
293
|
+
def real_toward?(f, x, point)
|
|
294
|
+
value = f.subs(x => Num.new(point == OO ? 1000 : -1000)).simplify
|
|
295
|
+
Inequalities.real?(value) != false
|
|
296
|
+
rescue ZeroDivisionError, NotImplementedError, RCAS::Unsupported
|
|
297
|
+
true
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# f has real values just to one side of the point: false only when that
|
|
301
|
+
# is decided at a point 1/1000 away.
|
|
302
|
+
def real_beside?(f, x, point, side)
|
|
303
|
+
value = f.subs(x => (Expression.lift(point) + Num.new(Rational(side, 1000))).simplify).simplify
|
|
304
|
+
Inequalities.real?(value) != false
|
|
305
|
+
rescue ZeroDivisionError, NotImplementedError, RCAS::Unsupported
|
|
306
|
+
true
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The denominators f divides by: the one of its normal form, the ones it
|
|
310
|
+
# is written with, which the normal form may have cancelled away
|
|
311
|
+
# ((x**2 - 1)/(x - 1) is still undefined at 1), and the one tan hides -
|
|
312
|
+
# tan(u) is sin(u)/cos(u), so it has a pole wherever cos(u) vanishes,
|
|
313
|
+
# and without that a tangent had no asymptotes and no gaps at all.
|
|
314
|
+
def denominators(f, x)
|
|
315
|
+
found = [RationalFunction.denom(f)]
|
|
316
|
+
f.each_node do |node|
|
|
317
|
+
case node
|
|
318
|
+
when Div then found << node.right
|
|
319
|
+
when Fn then found << Fn.new(:cos, [node.args.first]) if node.name == :tan
|
|
320
|
+
when Pow
|
|
321
|
+
exponent = node.exponent
|
|
322
|
+
found << node.base if exponent.is_a?(Num) && exponent.value.is_a?(Numeric) &&
|
|
323
|
+
!exponent.value.is_a?(Complex) && exponent.value.negative?
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
found.select { |d| d.variables.include?(x.name) }.uniq
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def horizontal_asymptotes(f, x, at: nil)
|
|
330
|
+
(at || infinities).filter_map do |point|
|
|
331
|
+
value = Limits.limit(f, x, point)
|
|
332
|
+
next nil if value.is_a?(Limit) && !real_toward?(f, x, point) # 1/log(x) towards -oo
|
|
333
|
+
raise RCAS::Unsupported, "horizontal asymptotes of #{f}: the limit at #{point} is not decided here" if value.is_a?(Limit)
|
|
334
|
+
next nil if Limits.infinite?(value) || value == UNDEFINED
|
|
335
|
+
value.simplify
|
|
336
|
+
end.uniq
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# y = m*x + c with m the limit of f/x and c the limit of f - m*x.
|
|
340
|
+
def oblique_asymptotes(f, x, at: nil)
|
|
341
|
+
(at || infinities).filter_map do |point|
|
|
342
|
+
# only a function that runs away has an oblique asymptote there
|
|
343
|
+
value = Limits.limit(f, x, point)
|
|
344
|
+
next nil if value.is_a?(Limit) && !real_toward?(f, x, point)
|
|
345
|
+
raise RCAS::Unsupported, "oblique asymptotes of #{f}: the limit at #{point} is not decided here" if value.is_a?(Limit)
|
|
346
|
+
next nil unless Limits.infinite?(value)
|
|
347
|
+
slope = Limits.limit((f / x).cancel, x, point)
|
|
348
|
+
raise RCAS::Unsupported, "oblique asymptotes of #{f}: the limit of f/x at #{point} is not decided here" if slope.is_a?(Limit)
|
|
349
|
+
next nil if Limits.infinite?(slope) || slope == UNDEFINED || Scalar.zero?(slope)
|
|
350
|
+
offset = Limits.limit((f - slope * x).cancel, x, point)
|
|
351
|
+
raise RCAS::Unsupported, "oblique asymptotes of #{f}: the limit of f - #{slope}*x at #{point} is not decided here" if offset.is_a?(Limit)
|
|
352
|
+
next nil if Limits.infinite?(offset) || offset == UNDEFINED
|
|
353
|
+
(slope * x + offset).simplify
|
|
354
|
+
end.uniq
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The tangent and the normal to the graph at a point.
|
|
358
|
+
# A vertical tangent (sqrt(x) at 0) is no line y = m*x + c: refused
|
|
359
|
+
# with a message rather than a ZeroDivisionError (S17).
|
|
360
|
+
def tangent(f, var = nil, at = nil)
|
|
361
|
+
f = Expression.lift(f)
|
|
362
|
+
x = variable(f, var)
|
|
363
|
+
a = Expression.lift(at)
|
|
364
|
+
slope = begin
|
|
365
|
+
f.diff(x).subs(x => a).simplify
|
|
366
|
+
rescue ZeroDivisionError
|
|
367
|
+
raise ArgumentError, "tangent: #{f} has no finite slope at #{x} = #{a}; the tangent there is vertical or does not exist"
|
|
368
|
+
end
|
|
369
|
+
raise ArgumentError, "tangent: #{f} has no finite slope at #{x} = #{a}" if Limits.infinite?(slope) || slope == UNDEFINED
|
|
370
|
+
differentiable!(f, x, a, "tangent")
|
|
371
|
+
value = f.subs(x => a).simplify
|
|
372
|
+
(value + slope * (x - a)).simplify
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# A kink of abs, sign, floor, ceil or round at the point: the formula
|
|
376
|
+
# for f' there says sign(0) = 0, and |x| got the tangent y = 0 at its
|
|
377
|
+
# corner (fourth review). The one-sided limits of f' decide - they have
|
|
378
|
+
# to exist and agree - and what they cannot decide is refused.
|
|
379
|
+
def differentiable!(f, x, a, name)
|
|
380
|
+
kinks = f.each_node.select { |n| n.is_a?(Fn) && Limits::KINKED.include?(n.name) && n.args.first.variables.include?(x.name) }
|
|
381
|
+
at_kink = kinks.any? do |n|
|
|
382
|
+
u = n.args.first.subs(x => a).simplify
|
|
383
|
+
case n.name
|
|
384
|
+
when :abs, :sign then Decide.zero?(u) != false
|
|
385
|
+
else Decide.zero?((u - Fn.new(:round, [u])).simplify) != false || n.name == :round
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
return unless at_kink
|
|
389
|
+
derivative = f.diff(x)
|
|
390
|
+
left, right = %i[left right].map { |side| Limits.limit(derivative, x, a, side) }
|
|
391
|
+
return if [left, right].none? { |v| v.is_a?(Limit) || Limits.infinite?(v) || v == UNDEFINED } && Scalar.zero?((left - right).simplify)
|
|
392
|
+
raise ArgumentError, "#{name}: #{f} has no tangent at #{x} = #{a}: the slopes from the left (#{left}) and the right (#{right}) differ there"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def normal(f, var = nil, at = nil)
|
|
396
|
+
f = Expression.lift(f)
|
|
397
|
+
x = variable(f, var)
|
|
398
|
+
a = Expression.lift(at)
|
|
399
|
+
slope = f.diff(x).subs(x => a).simplify
|
|
400
|
+
differentiable!(f, x, a, "normal")
|
|
401
|
+
raise ArgumentError, "normal: the tangent is horizontal at #{a}" if Scalar.zero?(slope)
|
|
402
|
+
(f.subs(x => a) - (x - a) / slope).simplify
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Where a real expression is defined: denominators non-zero, even roots
|
|
406
|
+
# and logarithms of positive arguments. => a RealSet.
|
|
407
|
+
def real_domain(f, var = nil)
|
|
408
|
+
f = Expression.lift(f)
|
|
409
|
+
x = variable(f, var)
|
|
410
|
+
parameters, conditions = domain_conditions(f, x).partition { |c| parameter_condition?(c, x) }
|
|
411
|
+
parameters.each do |c|
|
|
412
|
+
decided = decide_without_x(c)
|
|
413
|
+
raise RCAS::Unsupported, "real_domain: whether #{c} holds depends on #{c.lhs.variables.join(', ')}; assume a sign for it" if decided.nil?
|
|
414
|
+
return RealSet.empty unless decided
|
|
415
|
+
end
|
|
416
|
+
set = conditions.empty? ? RealSet.reals : conditions.map { |c| solved_condition(c, x) }.reduce(:&)
|
|
417
|
+
real = real_locus(f, x)
|
|
418
|
+
real ? set & real : set
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# A condition about parameters alone. It cannot be solved for x, so it
|
|
422
|
+
# is decided instead: with `assume(a < 0)` the logarithm in log(a) + x
|
|
423
|
+
# has no real value anywhere, and skipping the condition claimed the
|
|
424
|
+
# whole line (22 Sept 2026, from the second review).
|
|
425
|
+
def parameter_condition?(c, x) = !c.lhs.variables.empty? && !c.lhs.variables.include?(x.name)
|
|
426
|
+
|
|
427
|
+
# true, false, or nil when the assumptions do not settle it.
|
|
428
|
+
def decide_without_x(c)
|
|
429
|
+
return nil unless c.rhs.is_a?(Num) && c.rhs.zero?
|
|
430
|
+
sign = RCAS.sign_of(c.lhs)
|
|
431
|
+
case c.op
|
|
432
|
+
when :> then POSITIVE.include?(sign) ? true : (NONPOSITIVE.include?(sign) ? false : nil)
|
|
433
|
+
when :>= then NONNEGATIVE.include?(sign) ? true : (sign == :negative ? false : nil)
|
|
434
|
+
when :!= then sign == :zero ? false : (SIGNED.include?(sign) ? true : nil)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
POSITIVE = %i[positive].freeze
|
|
439
|
+
NONPOSITIVE = %i[negative nonpositive zero].freeze
|
|
440
|
+
NONNEGATIVE = %i[positive nonnegative zero].freeze
|
|
441
|
+
SIGNED = %i[positive negative].freeze
|
|
442
|
+
|
|
443
|
+
# Where an expression carrying i is real at all: its imaginary part has
|
|
444
|
+
# to vanish, so real_domain(I*x, x) is {0} and not the whole line, and
|
|
445
|
+
# x + i is real nowhere (22 Sept 2026, from the second review; the first
|
|
446
|
+
# review had asked for this and the answer then was that it was a gap).
|
|
447
|
+
# Only an explicit complex number puts the question, so nothing without
|
|
448
|
+
# one pays for it. => a RealSet, or nil when there is no condition.
|
|
449
|
+
#
|
|
450
|
+
# "Explicit" was too narrow: (-1)**sqrt(2) is exp(i*pi*sqrt(2)), complex
|
|
451
|
+
# without an i in sight, and real_domain((-1)**sqrt(2) + x) said the whole
|
|
452
|
+
# line (third review, T5a). A constant part shown not to be real puts the
|
|
453
|
+
# question too.
|
|
454
|
+
# A point is in the real domain when every subexpression is real and
|
|
455
|
+
# defined there - Mathematica's rule for FunctionDomain, and the fifth
|
|
456
|
+
# review's decision - so a subexpression that is a non-real constant
|
|
457
|
+
# leaves nothing: i*sqrt(x) and x*log(-2) are real nowhere, however
|
|
458
|
+
# their values fall. "Where is the value real" is another question, asked
|
|
459
|
+
# as solve(im(f) == 0, x). The expression is simplified first, so that
|
|
460
|
+
# (1 + i)*(1 - i)*x, which is 2*x, keeps its line.
|
|
461
|
+
def real_locus(f, x)
|
|
462
|
+
scattered = scattered_set(f, x)
|
|
463
|
+
g = f.simplify
|
|
464
|
+
nonreal = g.each_node.any? do |n|
|
|
465
|
+
(n.is_a?(Num) && n.value.is_a?(Complex) && !n.value.imaginary.zero?) ||
|
|
466
|
+
((n.is_a?(Pow) || n.is_a?(Fn)) && n.variables.empty? && nonreal_constant?(n))
|
|
467
|
+
end
|
|
468
|
+
nonreal ? RealSet.empty : scattered
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def nonreal_constant?(n)
|
|
472
|
+
Inequalities.real?(n) == false
|
|
473
|
+
rescue NotImplementedError, RCAS::Unsupported
|
|
474
|
+
false
|
|
475
|
+
rescue StandardError => rescued
|
|
476
|
+
RCAS.guard!(rescued)
|
|
477
|
+
false
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# b**u with u moving with x and b possibly negative is real on a
|
|
481
|
+
# scattered set, and gamma(u) off one: (-2)**x at the integers, x**x on
|
|
482
|
+
# (0, oo) and at the negative integers, gamma(x) off 0, -1, -2, ... No
|
|
483
|
+
# finite union of intervals is that (S15), so the answer is a
|
|
484
|
+
# ScatteredSet, for a linear argument or exponent; nil when f has no
|
|
485
|
+
# such part, and a refusal for a shape that is not named here or for
|
|
486
|
+
# more than one of them.
|
|
487
|
+
def scattered_set(f, x)
|
|
488
|
+
found = []
|
|
489
|
+
f.each_node do |n|
|
|
490
|
+
if n.is_a?(Fn) && %i[gamma factorial].include?(n.name) && n.args.first.variables.include?(x.name)
|
|
491
|
+
found << poles_of_gamma(n, x)
|
|
492
|
+
next
|
|
493
|
+
end
|
|
494
|
+
next unless n.is_a?(Pow) && n.exponent.variables.include?(x.name)
|
|
495
|
+
base = n.base
|
|
496
|
+
sign = base.variables.empty? ? Decide.sign(base) : RCAS.assume(x.name => RR) { RCAS.sign_of(base) }
|
|
497
|
+
next if sign == :positive
|
|
498
|
+
found << scattered_power(n, x)
|
|
499
|
+
end
|
|
500
|
+
found = found.uniq
|
|
501
|
+
return nil if found.empty?
|
|
502
|
+
raise RCAS::Unsupported, "real_domain: #{f} is real on the meeting of several scattered sets, which is not written here" if found.size > 1
|
|
503
|
+
found.first
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# gamma(a*x + b) has a pole where a*x + b is 0, -1, -2, ...; factorial(u)
|
|
507
|
+
# where u is -1, -2, ...
|
|
508
|
+
def poles_of_gamma(n, x)
|
|
509
|
+
coefficients = Solve.polynomial_coefficients(n.args.first, x)
|
|
510
|
+
unless coefficients&.size == 2 && coefficients.none? { |c| c.variables.any? }
|
|
511
|
+
raise RCAS::Unsupported, "real_domain: #{n} has a pole at every point where its argument is a non-positive integer"
|
|
512
|
+
end
|
|
513
|
+
b, a = coefficients
|
|
514
|
+
k = Var.new(:k)
|
|
515
|
+
top = n.name == :gamma ? Neg.new(k) : Neg.new(k) - 1
|
|
516
|
+
ScatteredSet.new(RealSet.reals, minus: [ImageSet.new(((top - b) / a).simplify, [k], NN)])
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# c**(a*x + b) for a negative constant c is real where the exponent is a
|
|
520
|
+
# whole number, since it is |c|**u*exp(i*pi*u); x**x (principal powers
|
|
521
|
+
# all through) is real for x > 0 and at the negative integers.
|
|
522
|
+
def scattered_power(n, x)
|
|
523
|
+
k = Var.new(:k)
|
|
524
|
+
if n.base.variables.empty? && Decide.sign(n.base) == :negative
|
|
525
|
+
coefficients = Solve.polynomial_coefficients(n.exponent, x)
|
|
526
|
+
if coefficients&.size == 2 && coefficients.none? { |c| c.variables.any? }
|
|
527
|
+
b, a = coefficients
|
|
528
|
+
return ScatteredSet.new(RealSet.empty, plus: [ImageSet.new(((k - b) / a).simplify, [k], ZZ)])
|
|
529
|
+
end
|
|
530
|
+
elsif n.base == x && n.exponent == x
|
|
531
|
+
return ScatteredSet.new(RealSet.new([Interval.open(Num.new(0), OO)]), plus: [ImageSet.new((Neg.new(k) - 1).simplify, [k], NN)])
|
|
532
|
+
end
|
|
533
|
+
raise RCAS::Unsupported, "real_domain: #{n} is real only on a scattered set of points where its base is negative"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# A condition rcas cannot solve is not an empty one: dropping it would
|
|
537
|
+
# claim the function is defined where nobody has looked. The message
|
|
538
|
+
# says which condition it was, in rcas's own words rather than as a
|
|
539
|
+
# backtrace out of Inequalities.
|
|
540
|
+
def off_zeros(d, x)
|
|
541
|
+
zeros = Solve.solve(d, x, domain: RR)
|
|
542
|
+
return nil unless zeros.is_a?(Array) && zeros.all? { |z| z.is_a?(Expression) && z.variables.empty? }
|
|
543
|
+
real = zeros.select { |z| Inequalities.real?(z) }.map { |z| Inequalities.real_part(z) }
|
|
544
|
+
real.empty? ? RealSet.reals : RealSet.reals - RealSet.new(real.map { |z| Interval.point(z) })
|
|
545
|
+
rescue NotImplementedError, RCAS::Unsupported, ArgumentError
|
|
546
|
+
nil
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def solved_condition(condition, x)
|
|
550
|
+
Inequalities.solve(condition, x)
|
|
551
|
+
rescue NotImplementedError, RCAS::Unsupported => e
|
|
552
|
+
# d != 0 is the line less the zeros of d, which solve names for more
|
|
553
|
+
# than polynomials (log(x) != 0 is every x but 1)
|
|
554
|
+
if condition.op == :!= && (off = off_zeros(condition.lhs - condition.rhs, x))
|
|
555
|
+
return off
|
|
556
|
+
end
|
|
557
|
+
# cause: nil, or irb prints this backtrace and the one underneath it
|
|
558
|
+
raise RCAS::Unsupported, "real_domain: where #{condition} holds is not decided here (#{e.message})", cause: nil
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# The inverse functions whose real argument has to stay in [-1, 1].
|
|
562
|
+
# Past that they do have a value, off the real line (Functions
|
|
563
|
+
# .real_branch), which is exactly why the condition has to be named
|
|
564
|
+
# here: real_domain(asin(x), x) answered with the whole line before
|
|
565
|
+
# (20 Sept 2026, after the tenth pass of the review).
|
|
566
|
+
BOUNDED_INVERSES = %i[asin acos].freeze
|
|
567
|
+
|
|
568
|
+
# A condition is worth recording when its argument moves with x, and
|
|
569
|
+
# also when it moves with nothing at all: log(-1) + x is real nowhere,
|
|
570
|
+
# and skipping the constant condition claimed the whole line (22 Sept
|
|
571
|
+
# 2026, from a review). An argument in a *parameter* is left alone,
|
|
572
|
+
# because the answer would then be a case split on the parameter rather
|
|
573
|
+
# than a domain.
|
|
574
|
+
def condition_argument?(u, x) = true
|
|
575
|
+
|
|
576
|
+
# The conditions behind that domain, so that a caller can name them:
|
|
577
|
+
# one per denominator, even root and logarithm, and two for each
|
|
578
|
+
# asin or acos.
|
|
579
|
+
def domain_conditions(f, x)
|
|
580
|
+
conditions = denominators(f, x).map { |d| Inequality.new(d, :!=, 0) }
|
|
581
|
+
f.each_node do |node|
|
|
582
|
+
# every fractional power is the principal one, real only for a base
|
|
583
|
+
# that is not negative - x**(1/3) too; surd(x, 3) is the real root
|
|
584
|
+
# (the fifth review's decision on odd roots)
|
|
585
|
+
if node.is_a?(Pow) && condition_argument?(node.base, x) && fractional_exponent?(node.exponent)
|
|
586
|
+
conditions << Inequality.new(node.base, :>=, 0)
|
|
587
|
+
elsif node.is_a?(Fn) && node.name == :log && condition_argument?(node.args.first, x)
|
|
588
|
+
conditions << Inequality.new(node.args.first, :>, 0)
|
|
589
|
+
elsif node.is_a?(Fn) && BOUNDED_INVERSES.include?(node.name) && condition_argument?(node.args.first, x)
|
|
590
|
+
conditions << Inequality.new(node.args.first, :>=, Num.new(-1))
|
|
591
|
+
conditions << Inequality.new(node.args.first, :<=, Num.new(1))
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
conditions
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Whether a constant real exponent is not an integer, so that the power
|
|
598
|
+
# asks for a base that is not negative. A literal Rational was the only
|
|
599
|
+
# case once, and x**0.5 and x**pi had the whole line as their domain (a
|
|
600
|
+
# review, 23 Sept 2026). A Float is judged by its value, any other
|
|
601
|
+
# constant by `Infer.excluded?` (pi is not an integer: it is
|
|
602
|
+
# transcendental). An exponent in x (x**x) is `scattered_domain`'s, one
|
|
603
|
+
# in a parameter is left alone like every parameter condition, and a
|
|
604
|
+
# constant whose integrality rcas cannot decide is refused rather than
|
|
605
|
+
# dropped.
|
|
606
|
+
def fractional_exponent?(e)
|
|
607
|
+
return false unless e.variables.empty?
|
|
608
|
+
e = e.simplify
|
|
609
|
+
if e.is_a?(Num)
|
|
610
|
+
v = e.value
|
|
611
|
+
return false unless v.is_a?(Numeric) && v.real?
|
|
612
|
+
return v.is_a?(Float) ? v.finite? && v != v.round : !(v.is_a?(Integer) || (v.is_a?(Rational) && v.denominator == 1))
|
|
613
|
+
end
|
|
614
|
+
return false if (domain = Infer.domain(e)) && domain <= ZZ
|
|
615
|
+
return false unless Decide.sign(ComplexParts.im(e)) == :zero # a complex exponent is not this rule's
|
|
616
|
+
return true if Infer.excluded?(e, ZZ)
|
|
617
|
+
raise RCAS::Unsupported, "real_domain: cannot decide whether the exponent #{e} is an integer, which decides whether a negative base is allowed"
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# ---- several variables ---------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
# A word for a reader who wrote x**(1/3) and meant the real cube root:
|
|
623
|
+
# ** is the principal root, which has no real value for a negative base
|
|
624
|
+
# (the fifth review's decision), and surd is the real one. One hint per
|
|
625
|
+
# such power whose base moves with x.
|
|
626
|
+
def root_hints(f, x)
|
|
627
|
+
Expression.lift(f).each_node.select do |n|
|
|
628
|
+
n.is_a?(Pow) && n.exponent.is_a?(Num) && n.exponent.value.is_a?(Rational) &&
|
|
629
|
+
n.exponent.value.denominator.odd? && n.exponent.value.denominator > 1 && n.base.variables.include?(x.name)
|
|
630
|
+
end.uniq.map do |n|
|
|
631
|
+
q = n.exponent.value.denominator
|
|
632
|
+
name = q == 3 ? "cube" : "#{q}th"
|
|
633
|
+
"#{n} has no real value where #{n.base} < 0; surd(#{n.base}, #{q}) is the real #{name} root"
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
COORDINATES = %i[x y z].freeze
|
|
638
|
+
|
|
639
|
+
# The coordinates, named rather than guessed (the fifth review's
|
|
640
|
+
# decision, as Mathematica and MuPAD ask for the list): the ones given,
|
|
641
|
+
# or the free names when they are among x, y and z, or - for a field in
|
|
642
|
+
# names of its own - when there are as many of them as components
|
|
643
|
+
# (line_integral's rule).
|
|
644
|
+
# Anything else is refused with the name that is in the way: in
|
|
645
|
+
# x**2 + a*y, a is a parameter, and the gradient had a third component.
|
|
646
|
+
def variables_of(f, vars, components: nil, name: "gradient")
|
|
647
|
+
return Array(vars).map { |v| Expression.lift(v) } if vars && !Array(vars).empty?
|
|
648
|
+
names = Array(f).flat_map { |g| Expression.lift(g).variables }.uniq.sort
|
|
649
|
+
raise ArgumentError, "name the variables, e.g. #{name}(f, [x, y])" if names.empty?
|
|
650
|
+
# the count rule is for a field in names of its own (u, v); a field
|
|
651
|
+
# that uses x or y and one more name has a parameter in it
|
|
652
|
+
counted = names.size == components && (names & COORDINATES).empty?
|
|
653
|
+
return names.map { |n| Var.new(n) } if (names - COORDINATES).empty? || counted
|
|
654
|
+
extra = names - COORDINATES
|
|
655
|
+
coordinates = names & COORDINATES
|
|
656
|
+
suggestion = coordinates.empty? ? names : coordinates
|
|
657
|
+
raise ArgumentError, "#{name}: #{extra.join(', ')} #{extra.size == 1 ? 'is' : 'are'} not a coordinate; " \
|
|
658
|
+
"pass the coordinates, e.g. #{name}(f, [#{suggestion.join(', ')}])"
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def gradient(f, vars = nil)
|
|
662
|
+
f = Expression.lift(f)
|
|
663
|
+
xs = variables_of(f, vars)
|
|
664
|
+
VectorSpace.new(RR, xs.size).unchecked(xs.map { |x| f.diff(x) })
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def hessian(f, vars = nil)
|
|
668
|
+
f = Expression.lift(f)
|
|
669
|
+
xs = variables_of(f, vars, name: "hessian")
|
|
670
|
+
rows = xs.map { |a| xs.map { |b| f.diff(a).diff(b) } }
|
|
671
|
+
MatrixSpace.new(RR, xs.size, xs.size).unchecked(rows)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def jacobian(fs, vars = nil)
|
|
675
|
+
fs = Array(fs).map { |f| Expression.lift(f) }
|
|
676
|
+
xs = variables_of(fs, vars, components: fs.size, name: "jacobian")
|
|
677
|
+
MatrixSpace.new(RR, fs.size, xs.size).unchecked(fs.map { |f| xs.map { |x| f.diff(x) } })
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def divergence(field, vars = nil)
|
|
681
|
+
fs = Array(field).map { |f| Expression.lift(f) }
|
|
682
|
+
xs = variables_of(fs, vars, components: fs.size, name: "divergence")
|
|
683
|
+
raise ArgumentError, "divergence: #{fs.size} components for #{xs.size} variables" unless fs.size == xs.size
|
|
684
|
+
fs.each_with_index.map { |f, i| f.diff(xs[i]) }.reduce(:+).simplify
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def laplacian(f, vars = nil)
|
|
688
|
+
f = Expression.lift(f)
|
|
689
|
+
xs = variables_of(f, vars, name: "laplacian")
|
|
690
|
+
xs.map { |x| f.diff(x, 2) }.reduce(:+).simplify
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def curl(field, vars = nil)
|
|
694
|
+
fs = Array(field).map { |f| Expression.lift(f) }
|
|
695
|
+
xs = variables_of(fs, vars, components: fs.size, name: "curl")
|
|
696
|
+
raise ArgumentError, "curl: three components and three variables are needed" unless fs.size == 3 && xs.size == 3
|
|
697
|
+
components = [
|
|
698
|
+
fs[2].diff(xs[1]) - fs[1].diff(xs[2]),
|
|
699
|
+
fs[0].diff(xs[2]) - fs[2].diff(xs[0]),
|
|
700
|
+
fs[1].diff(xs[0]) - fs[0].diff(xs[1])
|
|
701
|
+
]
|
|
702
|
+
VectorSpace.new(RR, 3).unchecked(components.map(&:simplify))
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# ---- lengths, areas, volumes ---------------------------------------------
|
|
706
|
+
|
|
707
|
+
# The length of a curve: integral(sqrt(1 + f'**2)) for a graph and
|
|
708
|
+
# integral(sqrt(x'**2 + y'**2)) for [x(t), y(t)], in space too. The integral is
|
|
709
|
+
# rarely elementary and then stays an integral(...) node, which evalf
|
|
710
|
+
# or nintegrate finishes.
|
|
711
|
+
def arclength(f, var, from, to)
|
|
712
|
+
var = Expression.lift(var)
|
|
713
|
+
integrand =
|
|
714
|
+
if f.is_a?(Array)
|
|
715
|
+
raise ArgumentError, "arclength: a parametric curve needs two or three components" unless [2, 3].include?(f.size)
|
|
716
|
+
f.map { |c| Expression.lift(c).diff(var)**2 }.reduce(:+)
|
|
717
|
+
else
|
|
718
|
+
Num.new(1) + Expression.lift(f).diff(var)**2
|
|
719
|
+
end
|
|
720
|
+
Integrate.definite(root(integrand), var, from, to)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# The volume of the solid the graph of f sweeps out: pi*integral(f**2)
|
|
724
|
+
# about the x-axis, and 2*pi*integral(x*f) (cylindrical shells) about
|
|
725
|
+
# the y-axis. Radius and height are distances, so the shell integrand
|
|
726
|
+
# takes both of them in absolute value - the cylinder of radius and
|
|
727
|
+
# height one came out as -pi over x: -1..0 before (22 Sept 2026, from a
|
|
728
|
+
# review).
|
|
729
|
+
def revolution_volume(f, var, from, to, axis: :x)
|
|
730
|
+
f = Expression.lift(f)
|
|
731
|
+
var = Expression.lift(var)
|
|
732
|
+
integrand =
|
|
733
|
+
if axis == :y
|
|
734
|
+
one_side!(var, from, to, "revolution_volume")
|
|
735
|
+
2 * PI * distance(var, var, from, to) * distance(f, var, from, to)
|
|
736
|
+
else
|
|
737
|
+
PI * f**2
|
|
738
|
+
end
|
|
739
|
+
Integrate.definite(integrand.simplify, var, from, to)
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# The area of the surface of revolution: 2*pi*integral(f*sqrt(1 + f'**2))
|
|
743
|
+
# about the x-axis, 2*pi*integral(x*sqrt(1 + f'**2)) about the y-axis.
|
|
744
|
+
# The radius is the distance to the axis, abs(f) or abs(x), and not the
|
|
745
|
+
# signed value: the cylinder of radius one had area -2*pi for f = -1.
|
|
746
|
+
def revolution_surface(f, var, from, to, axis: :x)
|
|
747
|
+
f = Expression.lift(f)
|
|
748
|
+
var = Expression.lift(var)
|
|
749
|
+
one_side!(var, from, to, "revolution_surface") if axis == :y
|
|
750
|
+
radius = axis == :y ? distance(var, var, from, to) : distance(f, var, from, to)
|
|
751
|
+
line = root(Num.new(1) + f.diff(var)**2)
|
|
752
|
+
Integrate.definite((2 * PI * radius * line).simplify, var, from, to)
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# |u| on the interval, written without the abs only where the sign of u
|
|
756
|
+
# there has been *proved*. Sampling five points said x - 1/10 was
|
|
757
|
+
# positive on 0..1 and dropped the abs, which understated the surface of
|
|
758
|
+
# revolution by 2.4% (22 Sept 2026, from the second review); an abs the
|
|
759
|
+
# integrator can see through is a far smaller price.
|
|
760
|
+
def distance(u, var, from, to)
|
|
761
|
+
u = Expression.lift(u)
|
|
762
|
+
case sign_on_interval(u, var, from, to)
|
|
763
|
+
when :positive then u
|
|
764
|
+
when :negative then Neg.new(u).simplify
|
|
765
|
+
else Fn.new(:abs, [u])
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
# An interval [lo, hi] of Rationals that contains every value of u on
|
|
770
|
+
# the box {name => [lo, hi]} - interval arithmetic, so a proof: x**2 +
|
|
771
|
+
# y**2 lies in [2, 8] on [1, 2]x[1, 2] and never vanishes there. nil for
|
|
772
|
+
# anything it does not enclose (a function, a denominator that may be 0).
|
|
773
|
+
def enclosure(u, box)
|
|
774
|
+
case u
|
|
775
|
+
when Num
|
|
776
|
+
v = u.value
|
|
777
|
+
v.is_a?(Integer) || v.is_a?(Rational) ? [v.to_r, v.to_r] : nil
|
|
778
|
+
when Var then box[u.name]
|
|
779
|
+
when Const then u.name == :pi ? [Rational(314_159_265, 100_000_000), Rational(314_159_266, 100_000_000)] : nil
|
|
780
|
+
when Neg
|
|
781
|
+
a = enclosure(u.arg, box) or return nil
|
|
782
|
+
[-a[1], -a[0]]
|
|
783
|
+
when Add, Sub
|
|
784
|
+
a = enclosure(u.left, box) or return nil
|
|
785
|
+
b = enclosure(u.right, box) or return nil
|
|
786
|
+
u.is_a?(Add) ? [a[0] + b[0], a[1] + b[1]] : [a[0] - b[1], a[1] - b[0]]
|
|
787
|
+
when Mul
|
|
788
|
+
a = enclosure(u.left, box) or return nil
|
|
789
|
+
b = enclosure(u.right, box) or return nil
|
|
790
|
+
products = a.product(b).map { |p, q| p * q }
|
|
791
|
+
[products.min, products.max]
|
|
792
|
+
when Div
|
|
793
|
+
a = enclosure(u.left, box) or return nil
|
|
794
|
+
b = enclosure(u.right, box) or return nil
|
|
795
|
+
return nil if b[0] <= 0 && b[1] >= 0
|
|
796
|
+
enclosure_product(a, [1 / b[1], 1 / b[0]])
|
|
797
|
+
when Pow
|
|
798
|
+
n = u.exponent
|
|
799
|
+
return nil unless n.is_a?(Num) && n.value.is_a?(Integer) && n.value.abs <= 64
|
|
800
|
+
a = enclosure(u.base, box) or return nil
|
|
801
|
+
n = n.value
|
|
802
|
+
if n.negative?
|
|
803
|
+
return nil if a[0] <= 0 && a[1] >= 0
|
|
804
|
+
a = [1 / a[1], 1 / a[0]]
|
|
805
|
+
n = -n
|
|
806
|
+
end
|
|
807
|
+
low, high = a.map { |e| e**n }.minmax
|
|
808
|
+
low = 0r if n.even? && a[0] <= 0 && a[1] >= 0
|
|
809
|
+
[low, high]
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def enclosure_product(a, b)
|
|
814
|
+
products = a.product(b).map { |p, q| p * q }
|
|
815
|
+
[products.min, products.max]
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# The box of a list of ranges, innermost first, each bound enclosed
|
|
819
|
+
# over the ranges outside it; nil when a bound is not enclosed.
|
|
820
|
+
def box_of(ranges)
|
|
821
|
+
box = {}
|
|
822
|
+
ranges.reverse_each do |var, from, to|
|
|
823
|
+
lo = enclosure(Expression.lift(from).simplify, box) or return nil
|
|
824
|
+
hi = enclosure(Expression.lift(to).simplify, box) or return nil
|
|
825
|
+
box[Expression.lift(var).name] = [[lo[0], hi[0]].min, [lo[1], hi[1]].max]
|
|
826
|
+
end
|
|
827
|
+
box
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# :positive, :negative, :nonnegative, :nonpositive or nil for u on the
|
|
831
|
+
# box, by its enclosure.
|
|
832
|
+
def sign_on_box(u, ranges)
|
|
833
|
+
box = box_of(ranges) or return nil
|
|
834
|
+
lo, hi = enclosure(Expression.lift(u).simplify, box)
|
|
835
|
+
return nil if lo.nil?
|
|
836
|
+
return :positive if lo.positive?
|
|
837
|
+
return :negative if hi.negative?
|
|
838
|
+
return :nonnegative if lo.zero?
|
|
839
|
+
return :nonpositive if hi.zero?
|
|
840
|
+
nil
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# The sign of u on the interval, or nil. A continuous u keeps one sign
|
|
844
|
+
# on an interval in which it has no zero, so the zeros are what decides
|
|
845
|
+
# it - *all* of them: Solve names them completely (a family is counted
|
|
846
|
+
# out member by member; cos(16*pi*x) has sixteen zeros in 1..2, of which
|
|
847
|
+
# the principal ones showed none), a zero strictly inside means there is
|
|
848
|
+
# no single sign, and a zero rcas cannot place means it does not know.
|
|
849
|
+
# "No zero" is only half the argument: u has to be continuous there, so
|
|
850
|
+
# a pole, a place where u stops being real, or a function with jumps
|
|
851
|
+
# inside the interval also leaves the question open (1/((x - 1/100)*
|
|
852
|
+
# (x - 1/50)) has no zero on 0..1 and is negative between its poles;
|
|
853
|
+
# third review, T1). The interior samples are decided exactly and are a
|
|
854
|
+
# veto, never the proof.
|
|
855
|
+
def sign_on_interval(u, var, from, to)
|
|
856
|
+
u = Expression.lift(u)
|
|
857
|
+
var = Expression.lift(var)
|
|
858
|
+
return constant_sign(u) unless u.variables.include?(var.name)
|
|
859
|
+
return nil unless u.variables == [var.name]
|
|
860
|
+
lo, hi = [Expression.lift(from), Expression.lift(to)].sort { |p, q| Inequalities.compare(p, q) || 0 }
|
|
861
|
+
return nil unless Inequalities.compare(lo, hi) == -1 && numeric(lo) && numeric(hi)
|
|
862
|
+
return nil if u.each_node.any? { |n| n.is_a?(Piecewise) || (n.is_a?(Fn) && JUMPS.include?(n.name)) }
|
|
863
|
+
breaks = [u] + denominators(u, var) + domain_conditions(u, var).map { |c| (c.lhs - c.rhs).simplify }
|
|
864
|
+
return nil if breaks.any? { |g| zero_inside?(g, var, lo, hi) != false }
|
|
865
|
+
signs_on(u, var, lo, hi)
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# Functions that jump: no zero does not mean one sign across them.
|
|
869
|
+
JUMPS = %i[floor ceil round sign].freeze
|
|
870
|
+
|
|
871
|
+
# true when g has a zero strictly between lo and hi, false when it is
|
|
872
|
+
# shown to have none, nil when that cannot be told.
|
|
873
|
+
def zero_inside?(g, var, lo, hi)
|
|
874
|
+
return false unless g.variables.include?(var.name)
|
|
875
|
+
roots = begin
|
|
876
|
+
Solve.solve(g, var, domain: RR)
|
|
877
|
+
rescue StandardError, NotImplementedError, RCAS::Unsupported => rescued
|
|
878
|
+
RCAS.guard!(rescued, refused: true)
|
|
879
|
+
return nil
|
|
880
|
+
end
|
|
881
|
+
return nil unless roots.is_a?(Array) # an identity or a set: no single sign to read
|
|
882
|
+
found = false
|
|
883
|
+
roots.each do |r|
|
|
884
|
+
members =
|
|
885
|
+
if r.is_a?(ImageSet)
|
|
886
|
+
r.between(numeric(lo) - 1, numeric(hi) + 1) or return nil
|
|
887
|
+
else
|
|
888
|
+
[r]
|
|
889
|
+
end
|
|
890
|
+
members.each do |m|
|
|
891
|
+
inside = strictly_between?(m, lo, hi)
|
|
892
|
+
return nil if inside.nil?
|
|
893
|
+
found ||= inside
|
|
894
|
+
end
|
|
895
|
+
end
|
|
896
|
+
found
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
# Is the real point m strictly inside (lo, hi)? false for a point off
|
|
900
|
+
# the real line, nil when that or its position cannot be decided.
|
|
901
|
+
def strictly_between?(m, lo, hi)
|
|
902
|
+
real = begin
|
|
903
|
+
Inequalities.real?(m)
|
|
904
|
+
rescue NotImplementedError, RCAS::Unsupported
|
|
905
|
+
return nil
|
|
906
|
+
end
|
|
907
|
+
return false unless real
|
|
908
|
+
m = Inequalities.real_part(m)
|
|
909
|
+
below = Inequalities.compare(lo, m)
|
|
910
|
+
above = Inequalities.compare(m, hi)
|
|
911
|
+
return nil if below.nil? || above.nil?
|
|
912
|
+
below.negative? && above.negative?
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Kept for callers that ask about one root.
|
|
916
|
+
def splits?(root, lo, hi) = strictly_between?(root, Expression.lift(lo), Expression.lift(hi)) != false
|
|
917
|
+
|
|
918
|
+
def constant_sign(u)
|
|
919
|
+
sign = Decide.sign(u.simplify)
|
|
920
|
+
%i[positive negative].include?(sign) ? sign : nil
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# The interior read at several points, each sign decided; they must
|
|
924
|
+
# agree, and the middle one carries the answer.
|
|
925
|
+
INTERIOR = [1, 2, 3, 4, 5, 6, 7].map { |i| Rational(i, 8) }.freeze
|
|
926
|
+
|
|
927
|
+
def signs_on(u, var, lo, hi)
|
|
928
|
+
signs = INTERIOR.map do |t|
|
|
929
|
+
point = (lo + (hi - lo) * Num.new(t)).simplify
|
|
930
|
+
sign = Decide.sign(u.subs(var => point).simplify)
|
|
931
|
+
return nil unless %i[positive negative].include?(sign)
|
|
932
|
+
sign
|
|
933
|
+
end
|
|
934
|
+
signs.uniq.size == 1 ? signs.first : nil
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# Shells about the y-axis stand on one side of it; a range that crosses
|
|
938
|
+
# the axis would have the two halves sweeping the same shells, which
|
|
939
|
+
# abs(x) would then count twice. Splitting the range is the reader's
|
|
940
|
+
# call, so rcas says so instead of answering - and says too that the two
|
|
941
|
+
# halves are a union and not a sum wherever they overlap.
|
|
942
|
+
def one_side!(var, from, to, who)
|
|
943
|
+
a = numeric(from)
|
|
944
|
+
b = numeric(to)
|
|
945
|
+
return if a.nil? || b.nil? || a * b >= 0
|
|
946
|
+
raise ArgumentError, "#{who}: the range #{var} = #{from}..#{to} crosses the axis of revolution; " \
|
|
947
|
+
"take the two sides separately - where their shells overlap the two answers are not to be added"
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def root(u) = Pow.new(u.simplify, Num.new(Rational(1, 2))).simplify
|
|
951
|
+
|
|
952
|
+
# Stationary points of f under the constraints g = 0, by the multiplier
|
|
953
|
+
# rule: grad f = sum(lambda_i grad g_i) together with the constraints.
|
|
954
|
+
def lagrange(f, constraints, vars = nil)
|
|
955
|
+
f = Expression.lift(f)
|
|
956
|
+
gs = Array(constraints).map { |g| Solve.to_zero(g) }
|
|
957
|
+
xs = variables_of([f] + gs, vars, name: "lagrange")
|
|
958
|
+
multipliers = gs.each_index.map { |i| Var.new(gs.size == 1 ? :lambda : :"lambda#{i + 1}") }
|
|
959
|
+
equations = xs.map do |x|
|
|
960
|
+
gs.each_with_index.reduce(f.diff(x)) { |acc, (g, i)| acc - multipliers[i] * g.diff(x) }.simplify
|
|
961
|
+
end
|
|
962
|
+
solutions = Solve.solve(equations + gs, xs + multipliers, principal: true)
|
|
963
|
+
solutions.map { |s| Assignment[xs.to_h { |x| [x, s[x]] }] }
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
end
|