executable-engineering 0.1.44__py3-none-any.whl → 0.1.47__py3-none-any.whl

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.
@@ -0,0 +1,678 @@
1
+ """
2
+ differentiation_module.py
3
+ Analytical models, finite difference stencils, and interactive Plotly visualizers
4
+ for numerical differentiation in ExecutableEngineering.
5
+ """
6
+
7
+ import math
8
+
9
+ try:
10
+ import numpy as np
11
+ _HAVE_NUMPY = True
12
+ except ImportError:
13
+ _HAVE_NUMPY = False
14
+
15
+ class _NumpyFallback:
16
+ pi = math.pi
17
+
18
+ @staticmethod
19
+ def isscalar(val):
20
+ return not hasattr(val, '__iter__')
21
+
22
+ class ndarray(list):
23
+ def __getitem__(self, key):
24
+ if isinstance(key, tuple):
25
+ row_slice, col_slice = key
26
+ sub_rows = super().__getitem__(row_slice)
27
+ if isinstance(sub_rows, list):
28
+ return _NumpyFallback.array([_NumpyFallback.array(r)[col_slice] for r in sub_rows])
29
+ return sub_rows[col_slice]
30
+ res = super().__getitem__(key)
31
+ if isinstance(res, list):
32
+ return _NumpyFallback.array(res)
33
+ return res
34
+
35
+ def __add__(self, other):
36
+ if hasattr(other, '__iter__'):
37
+ return _NumpyFallback.array([x + y for x, y in zip(self, other)])
38
+ return _NumpyFallback.array([x + other for x in self])
39
+
40
+ def __radd__(self, other):
41
+ return self.__add__(other)
42
+
43
+ def __sub__(self, other):
44
+ if hasattr(other, '__iter__'):
45
+ return _NumpyFallback.array([x - y for x, y in zip(self, other)])
46
+ return _NumpyFallback.array([x - other for x in self])
47
+
48
+ def __rsub__(self, other):
49
+ if hasattr(other, '__iter__'):
50
+ return _NumpyFallback.array([y - x for x, y in zip(self, other)])
51
+ return _NumpyFallback.array([other - x for x in self])
52
+
53
+ def __mul__(self, other):
54
+ if hasattr(other, '__iter__'):
55
+ return _NumpyFallback.array([x * y for x, y in zip(self, other)])
56
+ return _NumpyFallback.array([x * other for x in self])
57
+
58
+ def __rmul__(self, other):
59
+ return self.__mul__(other)
60
+
61
+ def __truediv__(self, other):
62
+ if hasattr(other, '__iter__'):
63
+ return _NumpyFallback.array([x / y for x, y in zip(self, other)])
64
+ return _NumpyFallback.array([x / other for x in self])
65
+
66
+ def __rtruediv__(self, other):
67
+ if hasattr(other, '__iter__'):
68
+ return _NumpyFallback.array([y / x for x, y in zip(self, other)])
69
+ return _NumpyFallback.array([other / x for x in self])
70
+
71
+ def __pow__(self, other):
72
+ return _NumpyFallback.array([x ** other for x in self])
73
+
74
+ def __neg__(self):
75
+ return _NumpyFallback.array([-x for x in self])
76
+
77
+ def __gt__(self, other):
78
+ if hasattr(other, '__iter__'):
79
+ return _NumpyFallback.array([x > y for x, y in zip(self, other)])
80
+ return _NumpyFallback.array([x > other for x in self])
81
+
82
+ def flatten(self):
83
+ flat = []
84
+ for item in self:
85
+ if hasattr(item, '__iter__'):
86
+ flat.extend(_NumpyFallback.array(item).flatten())
87
+ else:
88
+ flat.append(item)
89
+ return _NumpyFallback.array(flat)
90
+
91
+ @classmethod
92
+ def array(cls, data, dtype=float):
93
+ if isinstance(data, cls.ndarray):
94
+ return data
95
+ return cls.ndarray(data)
96
+
97
+ @classmethod
98
+ def asarray(cls, data, dtype=float):
99
+ if hasattr(data, '__iter__'):
100
+ return cls.array([float(x) for x in data], dtype=dtype)
101
+ return float(data)
102
+
103
+ @classmethod
104
+ def linspace(cls, start, stop, num=50):
105
+ step = (stop - start) / float(num - 1) if num > 1 else 0.0
106
+ return cls.array([start + i * step for i in range(num)])
107
+
108
+ @classmethod
109
+ def log(cls, x):
110
+ if hasattr(x, '__iter__'):
111
+ return cls.array([math.log(v) for v in x])
112
+ return math.log(x)
113
+
114
+ @classmethod
115
+ def abs(cls, x):
116
+ if hasattr(x, '__iter__'):
117
+ return cls.array([math.fabs(v) for v in x])
118
+ return math.fabs(x)
119
+
120
+ @classmethod
121
+ def where(cls, cond, a, b):
122
+ if hasattr(cond, '__iter__'):
123
+ return cls.array([av if cv else bv for cv, av, bv in zip(cond, a, b)])
124
+ return a if cond else b
125
+
126
+ @classmethod
127
+ def meshgrid(cls, x, y):
128
+ X = cls.array([list(x) for _ in y])
129
+ Y = cls.array([[yj for _ in x] for yj in y])
130
+ return X, Y
131
+
132
+ @classmethod
133
+ def gradient(cls, Z, *varargs):
134
+ dy = varargs[0] if len(varargs) > 0 else 1.0
135
+ dx = varargs[1] if len(varargs) > 1 else 1.0
136
+ rows = len(Z)
137
+ cols = len(Z[0])
138
+ dZdx = []
139
+ dZdy = []
140
+ for r in range(rows):
141
+ row_dx = []
142
+ for c in range(cols):
143
+ if c == 0:
144
+ row_dx.append((Z[r][1] - Z[r][0]) / dx)
145
+ elif c == cols - 1:
146
+ row_dx.append((Z[r][-1] - Z[r][-2]) / dx)
147
+ else:
148
+ row_dx.append((Z[r][c + 1] - Z[r][c - 1]) / (2.0 * dx))
149
+ dZdx.append(row_dx)
150
+ for r in range(rows):
151
+ row_dy = []
152
+ for c in range(cols):
153
+ if r == 0:
154
+ row_dy.append((Z[1][c] - Z[0][c]) / dy)
155
+ elif r == rows - 1:
156
+ row_dy.append((Z[-1][c] - Z[-2][c]) / dy)
157
+ else:
158
+ row_dy.append((Z[r + 1][c] - Z[r - 1][c]) / (2.0 * dy))
159
+ dZdy.append(row_dy)
160
+ return cls.array(dZdy), cls.array(dZdx)
161
+
162
+ np = _NumpyFallback()
163
+
164
+
165
+ try:
166
+ import plotly.graph_objects as go
167
+ from plotly.subplots import make_subplots
168
+ _HAVE_PLOTLY = True
169
+ except ImportError:
170
+ _HAVE_PLOTLY = False
171
+
172
+ class _FigureFallback:
173
+ def __init__(self, data=None, layout=None):
174
+ self.data = list(data) if data else []
175
+ self.layout = layout or {}
176
+
177
+ def add_trace(self, trace, row=None, col=None):
178
+ self.data.append(trace)
179
+ return self
180
+
181
+ def update_layout(self, **kwargs):
182
+ self.layout.update(kwargs)
183
+ return self
184
+
185
+ def update_xaxes(self, **kwargs):
186
+ return self
187
+
188
+ def update_yaxes(self, **kwargs):
189
+ return self
190
+
191
+ def add_vline(self, **kwargs):
192
+ return self
193
+
194
+ def show(self):
195
+ pass
196
+
197
+ class _GoFallback:
198
+ Figure = _FigureFallback
199
+ Scatter = staticmethod(lambda **kwargs: kwargs)
200
+ Bar = staticmethod(lambda **kwargs: kwargs)
201
+ Surface = staticmethod(lambda **kwargs: kwargs)
202
+ Cone = staticmethod(lambda **kwargs: kwargs)
203
+ Heatmap = staticmethod(lambda **kwargs: kwargs)
204
+
205
+ go = _GoFallback()
206
+ make_subplots = staticmethod(lambda **kwargs: _FigureFallback())
207
+
208
+
209
+ # --- Analytical Benchmark Models ---
210
+
211
+ def rocket_velocity(t):
212
+ """
213
+ Analytical rocket velocity benchmark:
214
+ v(t) = u * ln(m0 / (m0 - q * t)) - g * t
215
+ where:
216
+ u = 2000.0 m/s (exhaust velocity)
217
+ m0 = 150000.0 kg (initial rocket mass)
218
+ q = 2500.0 kg/s (fuel consumption rate)
219
+ g = 9.81 m/s^2 (gravitational acceleration)
220
+ """
221
+ u = 2000.0
222
+ m0 = 150000.0
223
+ q = 2500.0
224
+ g = 9.81
225
+ t_arr = np.asarray(t, dtype=float)
226
+ res = u * np.log(m0 / (m0 - q * t_arr)) - g * t_arr
227
+ if np.isscalar(t):
228
+ return float(res)
229
+ return res
230
+
231
+
232
+ def rocket_acceleration(t):
233
+ """
234
+ Exact analytical rocket acceleration (time derivative of rocket_velocity):
235
+ a(t) = (u * q) / (m0 - q * t) - g
236
+ """
237
+ u = 2000.0
238
+ m0 = 150000.0
239
+ q = 2500.0
240
+ g = 9.81
241
+ t_arr = np.asarray(t, dtype=float)
242
+ res = (u * q) / (m0 - q * t_arr) - g
243
+ if np.isscalar(t):
244
+ return float(res)
245
+ return res
246
+
247
+
248
+ # --- Finite Difference Stencils ---
249
+
250
+ def forward_difference(f, x, h):
251
+ """
252
+ First-order forward finite difference stencil:
253
+ f'(x) ≈ (f(x + h) - f(x)) / h
254
+ """
255
+ if callable(f):
256
+ return (f(x + h) - f(x)) / h
257
+ raise TypeError("f must be a callable function f(x)")
258
+
259
+
260
+ def backward_difference(f, x, h):
261
+ """
262
+ First-order backward finite difference stencil:
263
+ f'(x) ≈ (f(x) - f(x - h)) / h
264
+ """
265
+ if callable(f):
266
+ return (f(x) - f(x - h)) / h
267
+ raise TypeError("f must be a callable function f(x)")
268
+
269
+
270
+ def central_difference(f, x, h):
271
+ """
272
+ Second-order central finite difference stencil:
273
+ f'(x) ≈ (f(x + h) - f(x - h)) / (2 * h)
274
+ """
275
+ if callable(f):
276
+ return (f(x + h) - f(x - h)) / (2.0 * h)
277
+ raise TypeError("f must be a callable function f(x)")
278
+
279
+
280
+ def second_derivative(f, x, h):
281
+ """
282
+ Second-order central difference for the second derivative:
283
+ f''(x) ≈ (f(x + h) - 2 * f(x) + f(x - h)) / h^2
284
+ """
285
+ if callable(f):
286
+ return (f(x + h) - 2.0 * f(x) + f(x - h)) / (h ** 2)
287
+ raise TypeError("f must be a callable function f(x)")
288
+
289
+
290
+ # Stencil aliases for flexibility
291
+ forward_diff = forward_difference
292
+ backward_diff = backward_difference
293
+ central_diff = central_difference
294
+ second_central_diff = second_derivative
295
+
296
+
297
+ # --- Interactive Plotly Visualizers ---
298
+
299
+ def plot_secant_tangent_limit(f, x0=2.0, h_values=(1.0, 0.5, 0.2, 0.05), x_range=(0.5, 4.0), title="Secant to Tangent Convergence"):
300
+ """
301
+ Interactive Plotly figure illustrating the geometric limit of secant lines
302
+ approaching the true tangent line as step size h -> 0.
303
+ """
304
+ x = np.linspace(x_range[0], x_range[1], 400)
305
+ y = f(x)
306
+ y0 = f(x0)
307
+
308
+ # High-accuracy numerical slope for true tangent reference
309
+ eps = 1e-6
310
+ true_slope = (f(x0 + eps) - f(x0 - eps)) / (2.0 * eps)
311
+ tangent_line = y0 + true_slope * (x - x0)
312
+
313
+ fig = go.Figure()
314
+ fig.add_trace(go.Scatter(
315
+ x=x, y=y, mode='lines', name='f(x)',
316
+ line=dict(color='#111827', width=3)
317
+ ))
318
+ fig.add_trace(go.Scatter(
319
+ x=x, y=tangent_line, mode='lines',
320
+ name=f"True Tangent (slope = {true_slope:.4f})",
321
+ line=dict(color='#DC2626', width=2.5, dash='dash')
322
+ ))
323
+
324
+ colors = ['#2563EB', '#D97706', '#059669', '#7C3AED', '#DB2777']
325
+ for idx, h in enumerate(h_values):
326
+ x1 = x0 + h
327
+ y1 = f(x1)
328
+ slope = (y1 - y0) / h
329
+ secant_line = y0 + slope * (x - x0)
330
+ c = colors[idx % len(colors)]
331
+ fig.add_trace(go.Scatter(
332
+ x=x, y=secant_line, mode='lines',
333
+ name=f"Secant h={h} (slope = {slope:.4f})",
334
+ line=dict(color=c, width=1.8, dash='dot')
335
+ ))
336
+ fig.add_trace(go.Scatter(
337
+ x=[x1], y=[y1], mode='markers',
338
+ name=f"f(x0 + {h})",
339
+ marker=dict(color=c, size=7, symbol='diamond'),
340
+ showlegend=False
341
+ ))
342
+
343
+ fig.add_trace(go.Scatter(
344
+ x=[x0], y=[y0], mode='markers',
345
+ name=f"Anchor Point (x0, f(x0))",
346
+ marker=dict(color='#111827', size=11, symbol='circle')
347
+ ))
348
+
349
+ fig.update_layout(
350
+ title=title,
351
+ xaxis_title="x",
352
+ yaxis_title="f(x)",
353
+ template="plotly_white",
354
+ hovermode="x unified",
355
+ legend=dict(x=0.02, y=0.98, bgcolor="rgba(255, 255, 255, 0.8)")
356
+ )
357
+ return fig
358
+
359
+
360
+ def plot_derivative_comparison(t, a_true, approximations, title="Numerical vs Analytical Derivative"):
361
+ """
362
+ Compares numerical derivatives against analytical truth using Plotly subplots.
363
+ t: 1D array of evaluation coordinates (e.g. time)
364
+ a_true: 1D array of exact analytical derivative values
365
+ approximations: dict of {'Scheme Label': numerical_derivative_array}
366
+ """
367
+ fig = make_subplots(
368
+ rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08,
369
+ subplot_titles=("Derivative Comparison", "Absolute Error |Approx - True|"),
370
+ row_heights=[0.65, 0.35]
371
+ )
372
+
373
+ fig.add_trace(
374
+ go.Scatter(
375
+ x=t, y=a_true, mode='lines', name='Analytical True',
376
+ line=dict(color='#111827', width=2.5)
377
+ ),
378
+ row=1, col=1
379
+ )
380
+
381
+ palette = ['#2563EB', '#059669', '#D97706', '#7C3AED', '#DC2626', '#0891B2']
382
+ t_list = list(t)
383
+ a_true_list = [float(val) for val in a_true]
384
+
385
+ for idx, (label, a_num) in enumerate(approximations.items()):
386
+ c = palette[idx % len(palette)]
387
+ a_num_list = [float(val) for val in a_num]
388
+ fig.add_trace(
389
+ go.Scatter(
390
+ x=t, y=a_num, mode='lines+markers', name=label,
391
+ line=dict(color=c, width=1.5), marker=dict(size=4)
392
+ ),
393
+ row=1, col=1
394
+ )
395
+ err_clean = [max(abs(an - at), 1e-16) for an, at in zip(a_num_list, a_true_list)]
396
+ fig.add_trace(
397
+ go.Scatter(
398
+ x=t, y=err_clean, mode='lines', name=f"{label} Error",
399
+ line=dict(color=c, width=1.5)
400
+ ),
401
+ row=2, col=1
402
+ )
403
+
404
+ fig.update_layout(
405
+ title=title,
406
+ template="plotly_white",
407
+ hovermode="x unified",
408
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
409
+ )
410
+ fig.update_yaxes(title_text="Derivative f'(t)", row=1, col=1)
411
+ fig.update_yaxes(title_text="Absolute Error", type="log", row=2, col=1)
412
+ fig.update_xaxes(title_text="t", row=2, col=1)
413
+ return fig
414
+
415
+
416
+ def plot_error_scaling(h_values, errors_dict, expected_orders=None, title="Finite Difference Error vs Step Size h"):
417
+ """
418
+ Log-log Plotly convergence plot showing error decay against step size h.
419
+ h_values: 1D array-like of step sizes
420
+ errors_dict: dict of {'Scheme Name': errors_array}
421
+ expected_orders: optional dict of {'Scheme Name': int_order}
422
+ """
423
+ h_arr = np.asarray(h_values, dtype=float)
424
+ fig = go.Figure()
425
+ palette = ['#2563EB', '#059669', '#D97706', '#7C3AED', '#DC2626']
426
+
427
+ for idx, (name, errs) in enumerate(errors_dict.items()):
428
+ c = palette[idx % len(palette)]
429
+ err_arr = np.asarray(errs, dtype=float)
430
+ fig.add_trace(go.Scatter(
431
+ x=h_arr, y=err_arr, mode='lines+markers', name=name,
432
+ line=dict(color=c, width=2), marker=dict(size=7, symbol='circle')
433
+ ))
434
+
435
+ if expected_orders:
436
+ for name, order in expected_orders.items():
437
+ if name in errors_dict:
438
+ y0 = errors_dict[name][0]
439
+ h0 = h_arr[0]
440
+ ref_line = (h_arr ** order) * (y0 / (h0 ** order))
441
+ fig.add_trace(go.Scatter(
442
+ x=h_arr, y=ref_line, mode='lines',
443
+ name=f"Theoretical O(h^{order})",
444
+ line=dict(dash='dash', color='#6B7280', width=1.3)
445
+ ))
446
+
447
+ fig.update_layout(
448
+ title=title,
449
+ xaxis_title="Step Size h",
450
+ yaxis_title="Absolute Error",
451
+ xaxis_type="log",
452
+ yaxis_type="log",
453
+ template="plotly_white",
454
+ legend=dict(x=0.02, y=0.98)
455
+ )
456
+ return fig
457
+
458
+
459
+ def plot_surface_gradient_3d(func, x_range=(-2, 2), y_range=(-2, 2), n_grid=40, arrow_stride=4, title="3D Surface and Numerical Gradient Vector Field", subplots=False):
460
+ """
461
+ Interactive 3D Plotly visualization of a 2D scalar field z = f(x, y)
462
+ with 3D gradient vector cones, optionally expanded to subplots for
463
+ gradient components in x and y.
464
+ """
465
+ x = np.linspace(x_range[0], x_range[1], n_grid)
466
+ y = np.linspace(y_range[0], y_range[1], n_grid)
467
+ X, Y = np.meshgrid(x, y)
468
+ Z = func(X, Y)
469
+
470
+ dx = x[1] - x[0]
471
+ dy = y[1] - y[0]
472
+ dZdx = np.gradient(Z, dx, axis=1)
473
+ dZdy = np.gradient(Z, dy, axis=0)
474
+
475
+ # Downsampled cone vectors
476
+ Xs = X[::arrow_stride, ::arrow_stride].flatten()
477
+ Ys = Y[::arrow_stride, ::arrow_stride].flatten()
478
+ Zs = Z[::arrow_stride, ::arrow_stride].flatten()
479
+ Us = dZdx[::arrow_stride, ::arrow_stride].flatten()
480
+ Vs = dZdy[::arrow_stride, ::arrow_stride].flatten()
481
+ Ws = np.asarray([0.0 for _ in Us])
482
+
483
+ if subplots:
484
+ fig = make_subplots(
485
+ rows=1, cols=3,
486
+ specs=[[{"type": "scene"}, {"type": "scene"}, {"type": "scene"}]],
487
+ subplot_titles=(
488
+ "Surface f(x, y) with ∇f Cones",
489
+ "Gradient Component: ∂f/∂x",
490
+ "Gradient Component: ∂f/∂y"
491
+ ),
492
+ horizontal_spacing=0.03
493
+ )
494
+ # Panel 1: Surface with gradient cones
495
+ fig.add_trace(go.Surface(
496
+ x=X, y=Y, z=Z, colorscale='Viridis', opacity=0.85,
497
+ name='f(x, y)', showscale=False
498
+ ), row=1, col=1)
499
+ fig.add_trace(go.Cone(
500
+ x=Xs, y=Ys, z=Zs, u=Us, v=Vs, w=Ws,
501
+ sizemode='scaled', sizeref=0.35,
502
+ colorscale='Blues', showscale=False,
503
+ name='∇f'
504
+ ), row=1, col=1)
505
+
506
+ # Panel 2: x component ∂f/∂x
507
+ fig.add_trace(go.Surface(
508
+ x=X, y=Y, z=dZdx, colorscale='RdBu', showscale=False,
509
+ name='∂f/∂x'
510
+ ), row=1, col=2)
511
+
512
+ # Panel 3: y component ∂f/∂y
513
+ fig.add_trace(go.Surface(
514
+ x=X, y=Y, z=dZdy, colorscale='RdBu', showscale=False,
515
+ name='∂f/∂y'
516
+ ), row=1, col=3)
517
+
518
+ fig.update_layout(
519
+ title=title,
520
+ height=550,
521
+ template="plotly_white",
522
+ margin=dict(l=10, r=10, b=10, t=50)
523
+ )
524
+ return fig
525
+
526
+ fig = go.Figure()
527
+ fig.add_trace(go.Surface(
528
+ x=X, y=Y, z=Z, colorscale='Viridis', opacity=0.85,
529
+ name='f(x, y)', showscale=False
530
+ ))
531
+
532
+ fig.add_trace(go.Cone(
533
+ x=Xs, y=Ys, z=Zs, u=Us, v=Vs, w=Ws,
534
+ sizemode='scaled', sizeref=0.35,
535
+ colorscale='Reds', showscale=False,
536
+ name='Gradient ∇f'
537
+ ))
538
+
539
+ fig.update_layout(
540
+ title=title,
541
+ scene=dict(
542
+ xaxis_title="x",
543
+ yaxis_title="y",
544
+ zaxis_title="f(x, y)"
545
+ ),
546
+ template="plotly_white",
547
+ margin=dict(l=10, r=10, b=10, t=40)
548
+ )
549
+ return fig
550
+
551
+
552
+ def plot_noise_amplification(t, clean_signal, noisy_signal, d_clean, d_noisy, title="Noise Amplification in Numerical Differentiation"):
553
+ """
554
+ Two-panel Plotly display contrasting clean vs noisy raw signals and their derivatives.
555
+ """
556
+ fig = make_subplots(
557
+ rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.09,
558
+ subplot_titles=("Original vs Noisy Signal", "Analytical vs Finite Difference Derivative")
559
+ )
560
+
561
+ fig.add_trace(
562
+ go.Scatter(
563
+ x=t, y=clean_signal, mode='lines', name='Clean Signal',
564
+ line=dict(color='#2563EB', width=2)
565
+ ),
566
+ row=1, col=1
567
+ )
568
+ fig.add_trace(
569
+ go.Scatter(
570
+ x=t, y=noisy_signal, mode='lines', name='Noisy Signal',
571
+ line=dict(color='#9CA3AF', width=1, dash='dot')
572
+ ),
573
+ row=1, col=1
574
+ )
575
+
576
+ fig.add_trace(
577
+ go.Scatter(
578
+ x=t, y=d_clean, mode='lines', name='Clean Derivative',
579
+ line=dict(color='#2563EB', width=2)
580
+ ),
581
+ row=2, col=1
582
+ )
583
+ fig.add_trace(
584
+ go.Scatter(
585
+ x=t, y=d_noisy, mode='lines', name='Noisy Derivative',
586
+ line=dict(color='#DC2626', width=1)
587
+ ),
588
+ row=2, col=1
589
+ )
590
+
591
+ fig.update_layout(
592
+ title=title,
593
+ template="plotly_white",
594
+ hovermode="x unified",
595
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
596
+ )
597
+ fig.update_yaxes(title_text="Signal f(t)", row=1, col=1)
598
+ fig.update_yaxes(title_text="Derivative f'(t)", row=2, col=1)
599
+ fig.update_xaxes(title_text="Time t", row=2, col=1)
600
+ return fig
601
+
602
+
603
+ def plot_frequency_noise_spectrum(frequencies, fft_signal, fft_derivative, title="Frequency Spectrum of Signal vs Derivative Noise"):
604
+ """
605
+ Plotly frequency spectrum safely filtering positive frequencies to eliminate zero-division warnings.
606
+ """
607
+ freqs = list(frequencies)
608
+ pos_indices = [i for i, f in enumerate(freqs) if f > 0]
609
+ f_pos = [freqs[i] for i in pos_indices]
610
+ sig_mag = [math.fabs(float(fft_signal[i])) for i in pos_indices]
611
+ deriv_mag = [math.fabs(float(fft_derivative[i])) for i in pos_indices]
612
+
613
+ fig = go.Figure()
614
+ fig.add_trace(go.Scatter(
615
+ x=f_pos, y=sig_mag, mode='lines', name='Signal Magnitude |X(f)|',
616
+ line=dict(color='#2563EB', width=2)
617
+ ))
618
+ fig.add_trace(go.Scatter(
619
+ x=f_pos, y=deriv_mag, mode='lines', name='Derivative Magnitude |(2πif)X(f)|',
620
+ line=dict(color='#DC2626', width=2)
621
+ ))
622
+
623
+ fig.update_layout(
624
+ title=title,
625
+ xaxis_title="Frequency f (Hz)",
626
+ yaxis_title="Magnitude",
627
+ xaxis_type="log",
628
+ yaxis_type="log",
629
+ template="plotly_white",
630
+ legend=dict(x=0.02, y=0.98)
631
+ )
632
+ return fig
633
+
634
+
635
+ def plot_uneven_spacing_derivative(x, y, dy_num, dy_true=None, title="Derivative on Unevenly Spaced Grid"):
636
+ """
637
+ Plotly comparison showing derivative evaluation on an irregularly sampled grid.
638
+ """
639
+ fig = make_subplots(
640
+ rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08,
641
+ subplot_titles=("Uneven Grid Samples f(x)", "Numerical Derivative f'(x)")
642
+ )
643
+ fig.add_trace(
644
+ go.Scatter(
645
+ x=x, y=y, mode='lines+markers', name='f(x) Samples',
646
+ line=dict(color='#2563EB', width=1.5),
647
+ marker=dict(size=6, symbol='circle')
648
+ ),
649
+ row=1, col=1
650
+ )
651
+
652
+ if dy_true is not None:
653
+ fig.add_trace(
654
+ go.Scatter(
655
+ x=x, y=dy_true, mode='lines', name="True f'(x)",
656
+ line=dict(color='#111827', width=2)
657
+ ),
658
+ row=2, col=1
659
+ )
660
+ fig.add_trace(
661
+ go.Scatter(
662
+ x=x, y=dy_num, mode='lines+markers', name="Uneven Stencil f'(x)",
663
+ line=dict(color='#DC2626', width=1.5),
664
+ marker=dict(size=6, symbol='cross')
665
+ ),
666
+ row=2, col=1
667
+ )
668
+
669
+ fig.update_layout(
670
+ title=title,
671
+ template="plotly_white",
672
+ hovermode="x unified",
673
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
674
+ )
675
+ fig.update_yaxes(title_text="f(x)", row=1, col=1)
676
+ fig.update_yaxes(title_text="f'(x)", row=2, col=1)
677
+ fig.update_xaxes(title_text="x", row=2, col=1)
678
+ return fig