calculus-animator / core /animation_engine.py
Rsan0948
Prepare for open source release v1.0.0
de8ccff
Raw
History Blame Contribute Delete
14.7 kB
import numpy as np
from sympy import Symbol, lambdify
from sympy import latex as sym_latex
from config import get_logger
logger = get_logger(__name__)
class AnimationEngine:
def __init__(self):
pass
def _get_symbols(self, expr):
"""Extract free symbols from expression, default to 'x' if none."""
if isinstance(expr, str):
from sympy import sympify
try:
expr = sympify(expr)
except Exception:
return [Symbol("x")]
try:
syms = sorted(list(expr.free_symbols), key=lambda s: s.name)
if not syms:
return [Symbol("x")]
return syms
except Exception:
return [Symbol("x")]
def _safe_sample(self, expr, xs):
try:
if isinstance(expr, str):
from sympy import sympify
expr = sympify(expr)
syms = self._get_symbols(expr)
# Use the first symbol as the primary variable for the 1D plot
f = lambdify(syms[0], expr, modules=["numpy"])
ys = f(xs)
arr = np.array(ys, dtype=float)
if arr.shape == ():
arr = np.full_like(xs, float(arr))
return np.where(np.isfinite(arr), arr, np.nan)
except Exception as e:
logger.error(f"Sampling failed for {expr} (type {type(expr)}): {e}")
return np.full_like(xs, np.nan)
@staticmethod
def _to_num(v, fallback=0.0):
try:
if v is None:
return float(fallback)
return float(v)
except Exception:
return float(fallback)
def _curve_payload(self, expr, xs, label, color, style="solid", width=2.4):
ys = self._safe_sample(expr, xs)
return {
"label": label,
"color": color,
"style": style,
"width": width,
"x": xs.tolist(),
"y": [None if np.isnan(y) else float(y) for y in ys],
"latex": sym_latex(expr),
}
def generate_graph_data(self, expr, x_range=(-10, 10), points=300):
"""Sample a SymPy expression over an x range and return raw x/y arrays.
Args:
expr: A SymPy expression to evaluate.
x_range: Tuple ``(x_min, x_max)`` defining the sampling interval.
points: Number of evenly spaced sample points.
Returns:
On success: ``{"success": True, "x": list, "y": list, "latex": str}``.
On failure: ``{"success": False, "error": str}``.
"""
try:
xs = np.linspace(float(x_range[0]), float(x_range[1]), points)
ys = self._safe_sample(expr, xs)
return {
"success": True,
"x": xs.tolist(),
"y": [None if np.isnan(y) else float(y) for y in ys],
"latex": sym_latex(expr),
}
except Exception as e:
return {"success": False, "error": str(e)}
def generate_graph_payload(self, expr, calc_type=None, params=None, solved_expr=None, x_range=(-10, 10), points=500):
"""Build a rich graph payload for frontend rendering.
Assembles multiple curves, area fills, vertical/horizontal guide lines,
point markers, and legend metadata. Includes type-specific overlays:
shaded area for definite integrals and approach guides for limits.
Args:
expr: Primary SymPy expression (the input function).
calc_type: String or ``CalculusType`` name (e.g. ``"DERIVATIVE"``).
Determines which overlays are added.
params: Dict of operation parameters; used for ``"lower"``/``"upper"``
bounds (definite integral) or ``"point"`` (limit).
solved_expr: Optional SymPy expression for the solved result. When
provided and graphable, a second dashed curve is added.
x_range: Tuple ``(x_min, x_max)`` defining the sampling interval.
points: Number of evenly spaced sample points.
Returns:
On success: ``{"success": True, "calc_type": str, "x_range": list,
"y_range": list, "curves": list, "fills": list, "vlines": list,
"hlines": list, "points": list, "legend": list, "notes": list,
"x": list, "y": list, "latex": str}`` (last three for legacy compat).
On failure: ``{"success": False, "error": str}``.
"""
params = params or {}
calc_type = str(calc_type or "SIMPLIFY").upper()
try:
xs = np.linspace(float(x_range[0]), float(x_range[1]), int(points))
curves = []
fills = []
vlines = []
hlines = []
points_out = []
notes = []
# Primary expression curve.
try:
curves.append(self._curve_payload(
expr, xs, "Input function", "#e94560", "solid", 2.6
))
except Exception as e:
logger.debug(f"Failed to generate input function curve: {e}")
# Secondary curve based on solved result (when graphable and meaningful).
if solved_expr is not None and calc_type not in ("INTEGRAL_DEFINITE", "LIMIT"):
candidate = solved_expr
if hasattr(candidate, "removeO"):
try:
candidate = candidate.removeO()
except Exception:
pass
if str(candidate) != str(expr):
try:
labels = {
"DERIVATIVE": "Derivative f'(x)",
"INTEGRAL_INDEFINITE": "Antiderivative F(x)",
"SERIES": "Series approximation",
"TAYLOR_SERIES": "Taylor approximation",
}
label = labels.get(calc_type, "Solved expression")
curves.append(self._curve_payload(
candidate, xs, label, "#4fc3f7", "dashed", 2.2
))
except Exception as e:
logger.debug(f"Failed to generate solved curve: {e}")
# Definite integral area shading.
if calc_type == "INTEGRAL_DEFINITE":
lo = self._to_num(params.get("lower"), x_range[0])
hi = self._to_num(params.get("upper"), x_range[1])
if lo > hi:
lo, hi = hi, lo
x_fill = np.linspace(lo, hi, 220)
try:
y_fill = self._safe_sample(expr, x_fill)
fills.append({
"label": f"Area [{lo:g}, {hi:g}]",
"color": "rgba(233,69,96,0.22)",
"baseline": 0.0,
"x": x_fill.tolist(),
"y": [None if np.isnan(y) else float(y) for y in y_fill],
})
vlines.append({"x": float(lo), "label": f"x={lo:g}", "color": "#fbbf24"})
vlines.append({"x": float(hi), "label": f"x={hi:g}", "color": "#fbbf24"})
notes.append("Shaded region represents the definite integral area.")
except Exception:
pass
# Limit guides.
if calc_type == "LIMIT":
pt = self._to_num(params.get("point"), 0.0)
try:
vlines.append({
"x": float(pt), "label": f"x={pt}",
"color": "#fbbf24", "style": "dashed"
})
if solved_expr is not None:
lv = float(solved_expr)
if np.isfinite(lv):
hlines.append({
"y": lv, "label": f"limit={lv:.4g}", "color": "#22c55e"
})
points_out.append({
"x": float(pt), "y": float(lv),
"label": "Limit value", "color": "#22c55e"
})
except Exception as e:
logger.debug(f"Failed to generate limit guides: {e}")
notes.append("Dashed line indicates the approach point for the limit.")
# Fallback if nothing is graphable.
if not curves and not fills:
return {"success": False, "error": "No graphable data for this expression."}
# Derive overall y-range from all plottable values.
all_y = []
for c in curves:
all_y.extend([v for v in (c.get("y") or []) if v is not None and np.isfinite(v)])
for f in fills:
all_y.extend([v for v in (f.get("y") or []) if v is not None and np.isfinite(v)])
for h in hlines:
all_y.append(h.get("y"))
for p in points_out:
all_y.append(p.get("y"))
all_y = [float(v) for v in all_y if v is not None and np.isfinite(v)]
if all_y:
arr = np.array(all_y, dtype=float)
p2 = float(np.percentile(arr, 2))
p98 = float(np.percentile(arr, 98))
span = max(1e-6, p98 - p2)
y_min = p2 - span * 0.2
y_max = p98 + span * 0.2
else:
y_min, y_max = -10.0, 10.0
payload = {
"success": True,
"calc_type": calc_type,
"x_range": [float(x_range[0]), float(x_range[1])],
"y_range": [float(y_min), float(y_max)],
"curves": curves,
"fills": fills,
"vlines": vlines,
"hlines": hlines,
"points": points_out,
"legend": [c.get("label") for c in curves] + [f.get("label") for f in fills],
"notes": notes,
}
# Legacy compatibility fields used in mini animation graph.
if curves:
payload["x"] = curves[0]["x"]
payload["y"] = curves[0]["y"]
payload["latex"] = curves[0].get("latex", "")
return payload
except Exception as e:
return {"success": False, "error": str(e)}
def generate_area_frames(self, expr, lo, hi, frames=40):
"""Generate animation frames progressively filling the area under a curve.
Args:
expr: SymPy expression to integrate visually.
lo: Left bound of the integration interval (numeric).
hi: Right bound of the integration interval (numeric).
frames: Number of animation frames to produce.
Returns:
A list of frame dicts, each with keys ``"frame"``, ``"x"``, ``"y"``,
and ``"fill_to"`` (the rightmost x reached at that frame).
Returns an empty list on error.
"""
try:
out = []
for i in range(frames + 1):
cur = float(lo) + (float(hi) - float(lo)) * (i / frames)
xs = np.linspace(float(lo), cur, max(int(100 * i / frames), 2))
ys = self._safe_sample(expr, xs)
ys = np.where(np.isfinite(ys), ys, 0)
out.append({"frame": i, "x": xs.tolist(), "y": ys.tolist(), "fill_to": cur})
return out
except Exception:
return []
def generate_limit_frames(self, expr, point, frames=40):
"""Generate animation frames showing left/right approach to a limit point.
Each frame narrows the gap between two sample points approaching ``point``
from both sides, animating convergence.
Args:
expr: SymPy expression to evaluate near ``point``.
point: The x value being approached (numeric or coercible to float).
frames: Number of animation frames to produce.
Returns:
A list of frame dicts with keys ``"frame"``, ``"left_x"``,
``"left_y"``, ``"right_x"``, ``"right_y"``, and ``"approaching"``.
Returns an empty list on error.
"""
try:
syms = self._get_symbols(expr)
f = lambdify(syms[0], expr, modules=["numpy"])
point = float(point)
out = []
for i in range(frames + 1):
t = (i + 1) / (frames + 1)
gap = 2.0 * (1 - t) + 0.0001
lx = point - gap
rx = point + gap
try:
ly = float(f(lx))
ry = float(f(rx))
except Exception:
ly = ry = None
out.append({
"frame": i,
"left_x": lx, "left_y": ly if ly is not None and np.isfinite(ly) else None,
"right_x": rx, "right_y": ry if ry is not None and np.isfinite(ry) else None,
"approaching": point,
})
return out
except Exception as e:
logger.error(f"Limit frames generation failed: {e}")
return []
def generate_tangent(self, expr, deriv_expr, x_pt):
"""Compute the tangent line to a curve at a given x coordinate.
Args:
expr: SymPy expression for the original function f(x).
deriv_expr: SymPy expression for the derivative f'(x).
x_pt: The x coordinate at which to draw the tangent (numeric).
Returns:
On success: ``{"success": True, "point": {"x": float, "y": float},
"slope": float, "tangent_x": list, "tangent_y": list}``.
On failure: ``{"success": False, "error": str}``.
"""
try:
syms = self._get_symbols(expr)
f = lambdify(syms[0], expr, modules=["numpy"])
fp = lambdify(syms[0], deriv_expr, modules=["numpy"])
x_pt = float(x_pt)
y_pt = float(f(x_pt))
slope = float(fp(x_pt))
txs = np.linspace(x_pt - 3, x_pt + 3, 60)
tys = y_pt + slope * (txs - x_pt)
return {
"success": True,
"point": {"x": x_pt, "y": y_pt},
"slope": slope,
"tangent_x": txs.tolist(),
"tangent_y": tys.tolist(),
}
except Exception as e:
logger.error(f"Tangent generation failed: {e}")
return {"success": False, "error": str(e)}