Spaces:
Sleeping
Sleeping
| 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) | |
| 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)} | |