componavt commited on
Commit
2e5a95e
·
1 Parent(s): 723b602
README.md CHANGED
@@ -10,32 +10,88 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- # Differ Hug: Interactive ODE Research Platform
14
 
15
- A computational framework for exploring and visualizing ordinary differential equations (ODEs) in real-time.
16
 
17
- ## Research Capabilities
18
 
19
- - **Phase Portrait Visualization**: Generate and analyze phase space trajectories for autonomous and non-autonomous systems
20
- - **Attractor Analysis**: Investigate chaotic attractors, limit cycles, and stable/unstable manifolds
21
- - **Vector Field Rendering**: Visualize flow fields and direction fields with customizable resolution
22
- - **Parameter Space Exploration**: Systematically vary system parameters to observe bifurcations and qualitative changes in dynamics
23
- - **Numerical Integration**: Multiple solver methods (RK4, LSODA) with adaptive step size control
24
 
25
- ## Technical Stack
 
 
 
 
 
 
26
 
27
- - **Gradio**: Interactive web interface for parameter adjustment and real-time visualization
28
- - **NumPy/SciPy**: High-performance numerical computation and ODE solvers
29
- - **Matplotlib**: Publication-quality phase portraits and time series plots
30
- - **Hugging Face Spaces**: Cloud-based deployment and reproducible research environment
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  ## Usage
33
 
34
- Access the interactive interface to:
35
- - Select from predefined ODE systems (Lorenz, Van der Pol, Lotka-Volterra, etc.)
36
- - Adjust initial conditions and system parameters through interactive controls
37
- - Generate phase portraits, time series, and 3D trajectory visualizations
38
- - Export results for further analysis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  ## Citation
41
 
@@ -44,7 +100,8 @@ If you use this tool in your research, please cite:
44
  ```bibtex
45
  @software{differ_hug,
46
  author = {Krizhanovsky, Andrey},
47
- title = {Differ Hug: Interactive ODE Research Platform},
48
  url = {https://huggingface.co/spaces/componavt/differ-hug},
49
  year = {2026}
50
- }
 
 
10
  pinned: false
11
  ---
12
 
13
+ # Differ Hug: ODE Research Platform
14
 
15
+ An interactive platform for exploring and analyzing ordinary differential equations (ODEs), focused on gene regulatory systems and numerical diagnostics.
16
 
17
+ ## Features
18
 
19
+ ### ode System
20
+ - **Gene Regulatory ODE System**: Cooperative sigmoidal activation model
21
+ - **Numerical Integration**: High-precision DOP853 solver with RK45 fallback
22
+ - **Initial Conditions**: Circular/sector-based ensembles with controllable center and radius
 
23
 
24
+ ### Diagnostics & Metrics
25
+ - **FTLE**: Finite-Time Lyapunov Exponent with R² reliability metric
26
+ - **Hurst Exponent**: Rescaled range (R/S) analysis for time series
27
+ - **Curvature Statistics**: Mean, median, std, p10, p90 of curvature radius
28
+ - **Path Length**: Total arclength of trajectories
29
+ - **Local Z-score**: Nearest-neighbor analysis of curvature metrics
30
+ - **Anomaly Score**: Combined robust z-scores for outlying trajectories
31
 
32
+ ### Visualization
33
+ - **Phase Portrait**: Trajectories in phase space with annotations
34
+ - **Time Series**: x(t) and y(t) evolution over time
35
+ - **Shadowing Analysis**: Perturbation growth visualization
36
+ - **Metrics Table**: Full diagnostic output with highlighting
37
+
38
+ ## Requirements
39
+
40
+ ```
41
+ gradio>=4.44.1
42
+ numpy>=1.24.0
43
+ scipy>=1.10.0
44
+ matplotlib>=3.7.0
45
+ pandas (optional for extended functionality)
46
+ ```
47
 
48
  ## Usage
49
 
50
+ Run the application locally:
51
+ ```bash
52
+ python app.py
53
+ ```
54
+
55
+ The app will be available at `http://localhost:7860`
56
+
57
+ ## ODE System
58
+
59
+ The gene regulatory system:
60
+
61
+ $$
62
+ \begin{cases}
63
+ \displaystyle \frac{dx}{dt} = \frac{K\,x^{1/\alpha}}{b^{1/\alpha} + x^{1/\alpha}} - \gamma_1\,x,\\[10pt]
64
+ \displaystyle \frac{dy}{dt} = \frac{K\,y^{1/\alpha}}{b^{1/\alpha} + y^{1/\alpha}} - \gamma_2\,y.
65
+ \end{cases}
66
+ $$
67
+
68
+ ### Parameters
69
+ - **alpha**: Controls sigmoid steepness (1/alpha = Hill coefficient)
70
+ - **K**: Maximum activation rate
71
+ - **b**: Half-activation constant
72
+ - **gamma1, gamma2**: Decay rates
73
+
74
+ ## Project Structure
75
+
76
+ ```
77
+ differ-hug/
78
+ ├── app.py # Gradio UI entry point
79
+ ├── differ_hug/ # Core computation package
80
+ │ ├── __init__.py
81
+ │ ├── compute.py # Numerical computation functions
82
+ │ ├── plotting.py # Visualization functions
83
+ │ ├── params.py # Parameter serialization
84
+ │ └── docs.py # Documentation helpers
85
+ └── differential_equations_streamlit_src/ # Original Streamlit code (archived)
86
+ ```
87
+
88
+ ## Migration Notes
89
+
90
+ This is a Gradio port of the original Streamlit implementation. Key changes:
91
+ - Replaced `streamlit` with `gradio`
92
+ - Separated computation logic from UI
93
+ - Preserved all numerical diagnostics
94
+ - Made torch-based neural network solvers optional
95
 
96
  ## Citation
97
 
 
100
  ```bibtex
101
  @software{differ_hug,
102
  author = {Krizhanovsky, Andrey},
103
+ title = {Differ Hug: ODE Research Platform},
104
  url = {https://huggingface.co/spaces/componavt/differ-hug},
105
  year = {2026}
106
+ }
107
+ ```
app.py CHANGED
@@ -1,13 +1,484 @@
1
- import gradio as gr
 
 
 
 
 
 
2
 
3
- def greet(name, intensity):
4
- return "Hello, " + name + "!" * int(intensity)
 
 
 
 
 
5
 
6
- demo = gr.Interface(
7
- fn=greet,
8
- inputs=["text", gr.Slider(value=1, minimum=1, maximum=10, step=1)],
9
- outputs="text",
 
 
 
 
 
10
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  if __name__ == "__main__":
13
- demo.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True)
 
1
+ """
2
+ Gradio UI for the ODE Research Platform.
3
+
4
+ This module implements a complete Gradio interface for the gene regulatory
5
+ ODE system, including parameter controls, simulation execution, and
6
+ visualization of results.
7
+ """
8
 
9
+ import gradio as gr
10
+ import numpy as np
11
+ import pandas as pd
12
+ import matplotlib.pyplot as plt
13
+ import io
14
+ import sys
15
+ from typing import Tuple, Dict, Any
16
 
17
+ from differ_hug.compute import (
18
+ build_angles,
19
+ build_initial_conditions,
20
+ gene_regulatory_rhs,
21
+ solve_trajectory_set,
22
+ compute_local_zscore,
23
+ compute_anomaly_score,
24
+ compute_shadowing_diagnostics,
25
+ SciPySolver,
26
  )
27
+ from differ_hug.plotting import (
28
+ make_phase_portrait_figure,
29
+ make_time_series_figure,
30
+ make_shadowing_figure,
31
+ make_metrics_table_figure,
32
+ )
33
+ from differ_hug.params import parameters_to_text, text_to_parameters
34
+ from differ_hug.docs import documentation_markdown, system_latex, get_system_description
35
+
36
+
37
+ # Default parameter values
38
+ DEFAULTS = {
39
+ "t_number": 100,
40
+ "t_train_end": 1.0,
41
+ "t_full_end": 3.0,
42
+ "alpha": 0.001,
43
+ "K": 1.0,
44
+ "b": 1.0,
45
+ "gamma1": 1.0,
46
+ "gamma2": 1.0,
47
+ "initial_radius": 0.01,
48
+ "num_points": 12,
49
+ "circle_start": 0,
50
+ "circle_end": 360,
51
+ "center_x": 1.0,
52
+ "center_y": 1.0,
53
+ "solver_type": "SciPy DOP853",
54
+ }
55
+
56
+ DEFAULT_PARAMS_TEXT = parameters_to_text(DEFAULTS)
57
+
58
+
59
+ def run_simulation(
60
+ t_number: int,
61
+ t_train_end: float,
62
+ t_full_end: float,
63
+ alpha: float,
64
+ K: float,
65
+ b: float,
66
+ gamma1: float,
67
+ gamma2: float,
68
+ initial_radius: float,
69
+ num_points: int,
70
+ circle_start: int,
71
+ circle_end: int,
72
+ center_x: float,
73
+ center_y: float,
74
+ solver_type: str,
75
+ show_connections: bool,
76
+ connection_stride: int,
77
+ selected_indices: list,
78
+ ) -> Tuple[Any, Any, Any, Any, str, str, str, str]:
79
+ """
80
+ Run the ODE simulation and generate all outputs.
81
+ """
82
+ diagnostics = []
83
+ diagnostics.append("Starting simulation...")
84
+
85
+ try:
86
+ diagnostics.append("Step 1: Building angles and initial conditions...")
87
+ angles = build_angles(num_points, circle_start, circle_end)
88
+ initial_conditions = build_initial_conditions(center_x, center_y, initial_radius, angles)
89
+ diagnostics.append(f" Generated {len(initial_conditions)} initial conditions")
90
+
91
+ diagnostics.append("Step 2: Creating ODE RHS function...")
92
+ rhs_func = gene_regulatory_rhs(alpha, K, b, gamma1, gamma2)
93
+
94
+ diagnostics.append("Step 3: Setting up solver...")
95
+ if solver_type == "SciPy DOP853":
96
+ solver_method = "DOP853"
97
+ else:
98
+ solver_method = "RK45"
99
+
100
+ t_full = np.linspace(0, t_full_end, t_number)
101
+ t_train = np.linspace(0, t_train_end, max(50, t_number // 2))
102
+
103
+ diagnostics.append("Step 4: Solving trajectories...")
104
+ solutions, metrics = solve_trajectory_set(
105
+ rhs_func, initial_conditions, t_full, t_train, solver_method
106
+ )
107
+ diagnostics.append(f" Successfully solved {len(solutions)} trajectories")
108
+
109
+ if not solutions:
110
+ diagnostics.append(" ERROR: No trajectories solved successfully")
111
+ return (
112
+ None, None, None, None,
113
+ "\n".join(diagnostics),
114
+ DEFAULT_PARAMS_TEXT,
115
+ system_latex(),
116
+ "ERROR: No trajectories solved successfully",
117
+ ""
118
+ )
119
+
120
+ diagnostics.append("Step 5: Computing local z-score...")
121
+ local_z = compute_local_zscore(metrics)
122
+ for i, m in enumerate(metrics):
123
+ m["curv_radius_local_zscore"] = float(local_z[i]) if local_z[i] is not None else np.nan
124
+
125
+ diagnostics.append("Step 6: Computing anomaly scores...")
126
+ df_metrics = pd.DataFrame(metrics)
127
+ df_metrics["anomaly_score"] = compute_anomaly_score(df_metrics)
128
+
129
+ diagnostics.append("Step 7: Sorting by anomaly score...")
130
+ df_metrics = df_metrics.sort_values(by="anomaly_score", ascending=False, na_position="last")
131
+
132
+ diagnostics.append("Step 8: Creating figures...")
133
+
134
+ phase_fig = make_phase_portrait_figure(
135
+ solutions, selected_indices, solver_type,
136
+ t_train_end, t_full_end, t_number,
137
+ show_connections, connection_stride
138
+ )
139
+
140
+ time_fig = make_time_series_figure(
141
+ solutions, selected_indices, solver_type,
142
+ t_train_end, t_full_end, t_number
143
+ )
144
+
145
+ shadowing_fig = None
146
+ if show_connections:
147
+ shadowing_fig = make_shadowing_figure(
148
+ solutions, selected_indices, solver_type,
149
+ t_train_end, t_full_end, t_number
150
+ )
151
+
152
+ diagnostics.append("Step 9: Creating metrics table...")
153
+
154
+ df_to_display = df_metrics.copy()
155
+ column_rename_map = {
156
+ 'curv_radius_mean': 'curv_rad_mn',
157
+ 'curv_radius_median': 'curv_rad_med',
158
+ 'curv_radius_std': 'curv_rad_std',
159
+ 'curv_radius_local_zscore': 'curv_rad_lcl_z',
160
+ 'curv_count_finite': 'curv_ct_fin'
161
+ }
162
+ df_to_display = df_to_display.rename(columns=column_rename_map)
163
+ metrics_html = df_to_display.style.format("{:.3f}")._repr_html_()
164
+
165
+ diagnostics.append("Step 10: Preparing outputs...")
166
+ params_text = parameters_to_text(locals())
167
+
168
+ status = f"Simulation completed. Solved {len(solutions)} trajectories."
169
+ diagnostics.append(status)
170
+
171
+ diagnostics_text = "\n".join(diagnostics)
172
+
173
+ return (
174
+ phase_fig,
175
+ time_fig,
176
+ shadowing_fig,
177
+ metrics_html,
178
+ diagnostics_text,
179
+ params_text,
180
+ system_latex(),
181
+ status,
182
+ ""
183
+ )
184
+
185
+ except Exception as e:
186
+ error_msg = f"ERROR: {str(e)}"
187
+ diagnostics.append(error_msg)
188
+ return (
189
+ None, None, None, None,
190
+ "\n".join(diagnostics),
191
+ DEFAULT_PARAMS_TEXT,
192
+ system_latex(),
193
+ error_msg,
194
+ ""
195
+ )
196
+
197
+
198
+ def apply_text_to_controls(
199
+ params_text: str,
200
+ t_number: int,
201
+ t_train_end: float,
202
+ t_full_end: float,
203
+ alpha: float,
204
+ K: float,
205
+ b: float,
206
+ gamma1: float,
207
+ gamma2: float,
208
+ initial_radius: float,
209
+ num_points: int,
210
+ circle_start: int,
211
+ circle_end: int,
212
+ center_x: float,
213
+ center_y: float,
214
+ solver_type: str,
215
+ ) -> dict:
216
+ """
217
+ Parse text parameters and update control values.
218
+ """
219
+ parsed = text_to_parameters(params_text)
220
+ if not parsed:
221
+ return {
222
+ t_number: t_number, t_train_end: t_train_end, t_full_end: t_full_end,
223
+ alpha: alpha, K: K, b: b, gamma1: gamma1, gamma2: gamma2,
224
+ initial_radius: initial_radius, num_points: num_points,
225
+ circle_start: circle_start, circle_end: circle_end,
226
+ center_x: center_x, center_y: center_y, solver_type: solver_type,
227
+ params_text: DEFAULT_PARAMS_TEXT
228
+ }
229
+
230
+ updates = {}
231
+
232
+ int_keys = {"t_number", "num_points", "circle_start", "circle_end"}
233
+ float_keys = {
234
+ "t_train_end", "t_full_end", "alpha", "K", "b",
235
+ "gamma1", "gamma2", "initial_radius", "center_x", "center_y"
236
+ }
237
+
238
+ for key, val in parsed.items():
239
+ if key in int_keys:
240
+ try:
241
+ updates[key] = int(val)
242
+ except Exception:
243
+ pass
244
+ elif key in float_keys:
245
+ try:
246
+ updates[key] = float(val)
247
+ except Exception:
248
+ pass
249
+ elif key == "solver_type":
250
+ updates["solver_type"] = val
251
+ elif key == "circle_start_end":
252
+ cs, ce = val
253
+ updates["circle_start"] = cs
254
+ updates["circle_end"] = ce
255
+
256
+ if "circle_start" in parsed and "circle_end" in parsed:
257
+ cs = int(parsed["circle_start"])
258
+ ce = int(parsed["circle_end"])
259
+ updates["circle_start"] = cs
260
+ updates["circle_end"] = ce
261
+
262
+ if "num_points" in parsed:
263
+ updates["selected_indices"] = list(range(min(5, int(parsed["num_points"]))))
264
+
265
+ updates["params_text"] = parameters_to_text({**parsed, **updates})
266
+
267
+ return updates
268
+
269
+
270
+ def read_controls_to_text(
271
+ t_number: int,
272
+ t_train_end: float,
273
+ t_full_end: float,
274
+ alpha: float,
275
+ K: float,
276
+ b: float,
277
+ gamma1: float,
278
+ gamma2: float,
279
+ initial_radius: float,
280
+ num_points: int,
281
+ circle_start: int,
282
+ circle_end: int,
283
+ center_x: float,
284
+ center_y: float,
285
+ solver_type: str,
286
+ ) -> str:
287
+ """
288
+ Read current control values and convert to text format.
289
+ """
290
+ params = {
291
+ "t_number": int(t_number),
292
+ "t_train_end": float(t_train_end),
293
+ "t_full_end": float(t_full_end),
294
+ "alpha": float(alpha),
295
+ "K": float(K),
296
+ "b": float(b),
297
+ "gamma1": float(gamma1),
298
+ "gamma2": float(gamma2),
299
+ "initial_radius": float(initial_radius),
300
+ "num_points": int(num_points),
301
+ "circle_start": int(circle_start),
302
+ "circle_end": int(circle_end),
303
+ "center_x": float(center_x),
304
+ "center_y": float(center_y),
305
+ "solver_type": solver_type,
306
+ }
307
+ return parameters_to_text(params)
308
+
309
+
310
+ def update_selected_indices(num_points: int, solutions_len: int) -> list:
311
+ """Update selected indices based on num_points."""
312
+ n = min(num_points, 50)
313
+ return list(range(min(5, n)))
314
+
315
+
316
+ with gr.Blocks(title="Differ Hug: ODE Research Platform") as demo:
317
+ gr.Markdown("# Differ Hug: ODE Research Platform")
318
+ gr.Markdown("Interactive Gene Regulatory ODE System")
319
+
320
+ with gr.Row():
321
+ with gr.Column(scale=1):
322
+ gr.Markdown("### Parameters")
323
+
324
+ t_number = gr.Slider(
325
+ label="t_number (time points)", minimum=10, maximum=1000, step=10,
326
+ value=DEFAULTS["t_number"]
327
+ )
328
+ t_train_end = gr.Slider(
329
+ label="t_train_end (training end)", minimum=0.1, maximum=5.0, step=0.1,
330
+ value=DEFAULTS["t_train_end"]
331
+ )
332
+ t_full_end = gr.Slider(
333
+ label="t_full_end (full integration end)", minimum=0.1, maximum=10.0, step=0.1,
334
+ value=DEFAULTS["t_full_end"]
335
+ )
336
+
337
+ gr.Markdown("### System Parameters")
338
+ alpha = gr.Number(label="alpha (1/alpha exponent)", value=DEFAULTS["alpha"])
339
+ K = gr.Slider(label="K", minimum=0.1, maximum=5.0, step=0.1, value=DEFAULTS["K"])
340
+ b = gr.Number(label="b", value=DEFAULTS["b"])
341
+ gamma1 = gr.Number(label="gamma1", value=DEFAULTS["gamma1"])
342
+ gamma2 = gr.Number(label="gamma2", value=DEFAULTS["gamma2"])
343
+
344
+ gr.Markdown("### Initial Conditions")
345
+ initial_radius = gr.Number(label="Initial radius (R)", value=DEFAULTS["initial_radius"])
346
+ num_points = gr.Slider(label="Number of trajectories", minimum=3, maximum=50, step=1,
347
+ value=DEFAULTS["num_points"])
348
+ circle_start = gr.Slider(label="Circle start (degrees)", minimum=0, maximum=360, step=1,
349
+ value=DEFAULTS["circle_start"])
350
+ circle_end = gr.Slider(label="Circle end (degrees)", minimum=0, maximum=360, step=1,
351
+ value=DEFAULTS["circle_end"])
352
+ center_x = gr.Number(label="Center X", value=DEFAULTS["center_x"])
353
+ center_y = gr.Number(label="Center Y", value=DEFAULTS["center_y"])
354
+
355
+ gr.Markdown("### Solver Options")
356
+ solver_type = gr.Radio(
357
+ choices=["SciPy DOP853", "SciPy RK45"],
358
+ label="Solver Type",
359
+ value=DEFAULTS["solver_type"]
360
+ )
361
+ show_connections = gr.Checkbox(
362
+ label="Show connections (DOP853 ↔ NN)",
363
+ value=False
364
+ )
365
+ connection_stride = gr.Slider(
366
+ label="Connection stride", minimum=1, maximum=20, step=1,
367
+ value=5
368
+ )
369
+
370
+ selected_indices = gr.CheckboxGroup(
371
+ label="Select trajectories to display (sorted by anomaly score)",
372
+ choices=[],
373
+ value=[],
374
+ interactive=True
375
+ )
376
+
377
+ run_button = gr.Button("Run Simulation", variant="primary")
378
+
379
+ gr.Markdown("### Plain Text Parameters")
380
+ params_text = gr.Textbox(
381
+ label="Parameters (edit and apply)",
382
+ value=DEFAULT_PARAMS_TEXT,
383
+ lines=3,
384
+ max_lines=10
385
+ )
386
+ with gr.Row():
387
+ apply_text_btn = gr.Button("Apply text → controls")
388
+ read_text_btn = gr.Button("Read controls → text")
389
+
390
+ status_text = gr.Textbox(label="Status", lines=2)
391
+
392
+ with gr.Column(scale=2):
393
+ gr.Markdown("### Outputs")
394
+
395
+ with gr.Tabs():
396
+ with gr.TabItem("Phase Portrait"):
397
+ phase_fig = gr.Plot(label="Phase Portrait")
398
+ with gr.TabItem("Time Series"):
399
+ time_fig = gr.Plot(label="Time Series")
400
+ with gr.TabItem("Shadowing"):
401
+ shadowing_fig = gr.Plot(label="Shadowing Analysis")
402
+ with gr.TabItem("Metrics"):
403
+ metrics_df = gr.HTML(label="Metrics Table")
404
+ with gr.TabItem("Documentation"):
405
+ documentation = gr.Markdown(documentation_markdown())
406
+ with gr.TabItem("System Equation"):
407
+ latex_eq = gr.Markdown(system_latex())
408
+
409
+ diagnostics_text = gr.Textbox(label="Diagnostics", lines=10)
410
+
411
+ gr.Markdown("---")
412
+ gr.Markdown("**Notes:**")
413
+ gr.Markdown("- Anomaly score combines FTLE, path length, max curvature and R² reliability")
414
+ gr.Markdown("- Top 5 anomalous trajectories are selected by default")
415
+
416
+ run_button.click(
417
+ fn=run_simulation,
418
+ inputs=[
419
+ t_number, t_train_end, t_full_end, alpha, K, b, gamma1, gamma2,
420
+ initial_radius, num_points, circle_start, circle_end,
421
+ center_x, center_y, solver_type, show_connections, connection_stride,
422
+ selected_indices
423
+ ],
424
+ outputs=[
425
+ phase_fig, time_fig, shadowing_fig, metrics_df,
426
+ diagnostics_text, params_text, latex_eq, status_text, selected_indices
427
+ ]
428
+ )
429
+
430
+ apply_text_btn.click(
431
+ fn=apply_text_to_controls,
432
+ inputs=[
433
+ params_text, t_number, t_train_end, t_full_end, alpha, K, b,
434
+ gamma1, gamma2, initial_radius, num_points, circle_start, circle_end,
435
+ center_x, center_y, solver_type
436
+ ],
437
+ outputs=[
438
+ t_number, t_train_end, t_full_end, alpha, K, b,
439
+ gamma1, gamma2, initial_radius, num_points,
440
+ circle_start, circle_end, center_x, center_y, solver_type,
441
+ params_text, selected_indices
442
+ ]
443
+ )
444
+
445
+ read_text_btn.click(
446
+ fn=read_controls_to_text,
447
+ inputs=[
448
+ t_number, t_train_end, t_full_end, alpha, K, b, gamma1, gamma2,
449
+ initial_radius, num_points, circle_start, circle_end,
450
+ center_x, center_y, solver_type
451
+ ],
452
+ outputs=params_text
453
+ )
454
+
455
+ num_points.change(
456
+ fn=update_selected_indices,
457
+ inputs=[num_points, gr.Number(value=0, visible=False)],
458
+ outputs=selected_indices
459
+ )
460
+
461
+ demo.load(
462
+ fn=lambda: (gr.update(value=DEFAULTS["t_number"]),
463
+ gr.update(value=DEFAULTS["t_train_end"]),
464
+ gr.update(value=DEFAULTS["t_full_end"]),
465
+ gr.update(value=DEFAULTS["alpha"]),
466
+ gr.update(value=DEFAULTS["K"]),
467
+ gr.update(value=DEFAULTS["b"]),
468
+ gr.update(value=DEFAULTS["gamma1"]),
469
+ gr.update(value=DEFAULTS["gamma2"]),
470
+ gr.update(value=DEFAULTS["initial_radius"]),
471
+ gr.update(value=DEFAULTS["num_points"]),
472
+ gr.update(value=DEFAULTS["circle_start"]),
473
+ gr.update(value=DEFAULTS["circle_end"]),
474
+ gr.update(value=DEFAULTS["center_x"]),
475
+ gr.update(value=DEFAULTS["center_y"])),
476
+ outputs=[
477
+ t_number, t_train_end, t_full_end, alpha, K, b,
478
+ gamma1, gamma2, initial_radius, num_points,
479
+ circle_start, circle_end, center_x, center_y
480
+ ]
481
+ )
482
 
483
  if __name__ == "__main__":
484
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True)
differ_hug/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ differ_hug - Gradio-based ODE Research Platform
3
+
4
+ This package provides a clean separation between numerical computation
5
+ and the Gradio UI for the gene regulatory ODE system.
6
+ """
differ_hug/compute.py ADDED
@@ -0,0 +1,581 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core computation functions for the ODE research platform.
3
+
4
+ This module contains pure computational functions that preserve the
5
+ numerical logic from the original Streamlit implementation while
6
+ being reusable and testable.
7
+ """
8
+
9
+ import numpy as np
10
+ from scipy.integrate import solve_ivp
11
+ from typing import Tuple, List, Dict, Any, Optional
12
+ import pandas as pd
13
+
14
+
15
+ def build_angles(num_points: int, circle_start: float, circle_end: float) -> np.ndarray:
16
+ """
17
+ Generate angles for initial conditions on a circle or sector.
18
+
19
+ Args:
20
+ num_points: Number of points to generate
21
+ circle_start: Starting angle in degrees (0-360)
22
+ circle_end: Ending angle in degrees (0-360)
23
+
24
+ Returns:
25
+ Array of angles in radians
26
+ """
27
+ cs_val = circle_start % 360
28
+ ce_val = circle_end % 360
29
+ span = (ce_val - cs_val) % 360
30
+
31
+ if np.isclose(span, 0.0):
32
+ angles = np.linspace(0, 2 * np.pi, num_points, endpoint=False)
33
+ else:
34
+ if ce_val >= cs_val:
35
+ degs = np.linspace(cs_val, ce_val, num_points, endpoint=False)
36
+ else:
37
+ span2 = (ce_val + 360) - cs_val
38
+ degs = (cs_val + np.linspace(0, span2, num_points, endpoint=False)) % 360
39
+ angles = np.deg2rad(degs)
40
+
41
+ return angles
42
+
43
+
44
+ def build_initial_conditions(
45
+ center_x: float,
46
+ center_y: float,
47
+ radius: float,
48
+ angles: np.ndarray
49
+ ) -> List[Tuple[float, float]]:
50
+ """
51
+ Build initial conditions from center, radius, and angles.
52
+
53
+ Args:
54
+ center_x: X coordinate of circle center
55
+ center_y: Y coordinate of circle center
56
+ radius: Radius of the circle
57
+ angles: Array of angles in radians
58
+
59
+ Returns:
60
+ List of (x, y) initial condition tuples
61
+ """
62
+ initial_conditions = []
63
+ for angle in angles:
64
+ x0 = center_x + radius * np.cos(angle)
65
+ y0 = center_y + radius * np.sin(angle)
66
+ initial_conditions.append((x0, y0))
67
+ return initial_conditions
68
+
69
+
70
+ def gene_regulatory_rhs(
71
+ alpha_val: float,
72
+ K_val: float,
73
+ b_val: float,
74
+ g1_val: float,
75
+ g2_val: float
76
+ ):
77
+ """
78
+ Create the right-hand side function for the gene regulatory ODE system.
79
+
80
+ The system is:
81
+ dx/dt = (K * x^(1/alpha))/(b^(1/alpha) + x^(1/alpha)) - gamma1 * x
82
+ dy/dt = (K * y^(1/alpha))/(b^(1/alpha) + y^(1/alpha)) - gamma2 * y
83
+
84
+ Args:
85
+ alpha_val: Alpha parameter (controls exponent)
86
+ K_val: K parameter
87
+ b_val: b parameter
88
+ g1_val: gamma1 parameter
89
+ g2_val: gamma2 parameter
90
+
91
+ Returns:
92
+ RHS function suitable for scipy.integrate.solve_ivp
93
+ """
94
+ def rhs(t, state):
95
+ x, y = state
96
+ n = 1.0 / alpha_val
97
+ if n > 1000:
98
+ frac_x = K_val if x > b_val else 0.0
99
+ frac_y = K_val if y > b_val else 0.0
100
+ else:
101
+ x_pos, y_pos = max(x, 0.0), max(y, 0.0)
102
+ try:
103
+ pow_b = np.power(b_val, n)
104
+ pow_x = np.power(x_pos, n)
105
+ pow_y = np.power(y_pos, n)
106
+ frac_x = (K_val * pow_x) / (pow_b + pow_x) if np.isfinite(pow_x) else (K_val if x > b_val else 0.0)
107
+ frac_y = (K_val * pow_y) / (pow_b + pow_y) if np.isfinite(pow_y) else (K_val if y > b_val else 0.0)
108
+ except Exception:
109
+ frac_x, frac_y = (K_val if x > b_val else 0.0), (K_val if y > b_val else 0.0)
110
+ return [frac_x - g1_val * x, frac_y - g2_val * y]
111
+
112
+ return rhs
113
+
114
+
115
+ class SciPySolver:
116
+ """
117
+ Solver wrapper using scipy.integrate.solve_ivp.
118
+ """
119
+ DEFAULT_METHOD = 'DOP853'
120
+ DEFAULT_RTOL = 1e-9
121
+ DEFAULT_ATOL = 1e-9
122
+
123
+ def __init__(
124
+ self,
125
+ method: str = DEFAULT_METHOD,
126
+ rtol: float = DEFAULT_RTOL,
127
+ atol: float = DEFAULT_ATOL
128
+ ):
129
+ self.method = method
130
+ self.rtol = rtol
131
+ self.atol = atol
132
+
133
+ def solve(
134
+ self,
135
+ rhs,
136
+ x0: Tuple[float, float],
137
+ t_eval: np.ndarray
138
+ ) -> Tuple[bool, Optional[np.ndarray], Optional[np.ndarray]]:
139
+ """
140
+ Solve ODE using scipy.integrate.solve_ivp.
141
+
142
+ Args:
143
+ rhs: Right-hand side function of the ODE system
144
+ x0: Initial conditions (x0, y0)
145
+ t_eval: Time points to evaluate
146
+
147
+ Returns:
148
+ Tuple of (success, x_solution, y_solution)
149
+ """
150
+ try:
151
+ sol = solve_ivp(
152
+ rhs,
153
+ (t_eval[0], t_eval[-1]),
154
+ x0,
155
+ method=self.method,
156
+ rtol=self.rtol,
157
+ atol=self.atol,
158
+ t_eval=t_eval
159
+ )
160
+ if sol.success:
161
+ return True, sol.y[0], sol.y[1]
162
+ else:
163
+ return False, None, None
164
+ except Exception:
165
+ return False, None, None
166
+
167
+
168
+ def solve_trajectory_set(
169
+ rhs_func,
170
+ initial_conditions: List[Tuple[float, float]],
171
+ t_full: np.ndarray,
172
+ t_train: np.ndarray,
173
+ solver_method: str = 'DOP853'
174
+ ) -> Tuple[List[Dict], List[Dict]]:
175
+ """
176
+ Solve ODE for a set of initial conditions and compute metrics.
177
+
178
+ Args:
179
+ rhs_func: RHS function for the ODE system
180
+ initial_conditions: List of (x0, y0) tuples
181
+ t_full: Time array for full integration
182
+ t_train: Time array for training interval
183
+ solver_method: Solver method to use ('DOP853' or 'RK45')
184
+
185
+ Returns:
186
+ Tuple of (solutions, metrics) where each is a list of dicts
187
+ """
188
+ solutions = []
189
+ metrics = []
190
+
191
+ solver = SciPySolver(method=solver_method)
192
+
193
+ for idx, (x0, y0) in enumerate(initial_conditions):
194
+ success, x_full, y_full = solver.solve(rhs_func, (x0, y0), t_full)
195
+
196
+ if not success or x_full is None or y_full is None:
197
+ solver_fallback = SciPySolver(method='RK45')
198
+ success, x_full, y_full = solver_fallback.solve(rhs_func, (x0, y0), t_full)
199
+ if not success or x_full is None or y_full is None:
200
+ continue
201
+
202
+ train_success, x_train, y_train = solver.solve(rhs_func, (x0, y0), t_train)
203
+ if not train_success:
204
+ continue
205
+
206
+ solutions.append({
207
+ "idx": idx,
208
+ "x": x_full,
209
+ "y": y_full,
210
+ "t_full": t_full,
211
+ "x_train": x_train,
212
+ "y_train": y_train,
213
+ "t_train": t_train,
214
+ })
215
+
216
+ amp = float(np.max(np.sqrt(x_full * x_full + y_full * y_full)) -
217
+ np.min(np.sqrt(x_full * x_full + y_full * y_full)))
218
+
219
+ ftle, final_d, ftle_r2 = compute_ftle_metrics(rhs_func, x0, y0, t_full[-1], t_full, x_full, y_full)
220
+
221
+ hx = hurst_rs(x_full)
222
+ hy = hurst_rs(y_full)
223
+ hurst_val = np.nanmean([hx, hy])
224
+
225
+ cr_stats = curvature_radius_stats(x_full, y_full, t_full)
226
+ curv_mean = cr_stats["mean"]
227
+ curv_median = cr_stats["median"]
228
+ curv_std = cr_stats["std"]
229
+
230
+ path_len = compute_path_length(x_full, y_full)
231
+
232
+ kappa_arr = cr_stats.get("kappa_array")
233
+ kappa_vals = np.array(kappa_arr) if kappa_arr is not None else np.array([])
234
+ kappa_vals = kappa_vals[np.isfinite(kappa_vals)] if kappa_vals.size > 0 else np.array([])
235
+
236
+ max_kappa = float(np.nanmax(kappa_vals)) if kappa_vals.size > 0 else np.nan
237
+ frac_high_curv = float(np.sum(kappa_vals > 0.1) / len(t_full)) if kappa_vals.size > 0 else np.nan
238
+
239
+ metrics.append({
240
+ "idx": idx,
241
+ "ftle": ftle,
242
+ "ftle_r2": ftle_r2,
243
+ "amp": amp,
244
+ "final_dist": final_d,
245
+ "hurst": hurst_val,
246
+ "curv_radius_mean": curv_mean,
247
+ "curv_radius_median": curv_median,
248
+ "curv_radius_std": curv_std,
249
+ "curv_p10": cr_stats["p10"],
250
+ "curv_p90": cr_stats["p90"],
251
+ "curv_count_finite": cr_stats["count_finite"],
252
+ "initial_x": float(x0),
253
+ "initial_y": float(y0),
254
+ "path_len": path_len,
255
+ "max_kappa": max_kappa,
256
+ "frac_high_curv": frac_high_curv,
257
+ })
258
+
259
+ # Compute local z-score after all metrics are collected
260
+ local_z = compute_local_zscore(metrics)
261
+ for i, m in enumerate(metrics):
262
+ m["curv_radius_local_zscore"] = float(local_z[i]) if local_z[i] is not None else np.nan
263
+
264
+ # Compute anomaly score
265
+ df_temp = pd.DataFrame(metrics)
266
+ df_temp["anomaly_score"] = compute_anomaly_score(df_temp)
267
+ for i, m in enumerate(metrics):
268
+ m["anomaly_score"] = float(df_temp.iloc[i]["anomaly_score"])
269
+
270
+ return solutions, metrics
271
+
272
+
273
+ def compute_ftle_metrics(
274
+ rhs,
275
+ x0: float,
276
+ y0: float,
277
+ te: float,
278
+ t_eval: np.ndarray,
279
+ x: np.ndarray,
280
+ y: np.ndarray
281
+ ) -> Tuple[float, float, float]:
282
+ """
283
+ Computes FTLE (Finite-Time Lyapunov Exponent) and related metrics.
284
+
285
+ Args:
286
+ rhs: Right-hand side function of the ODE system
287
+ x0, y0: Initial conditions
288
+ te: End time
289
+ t_eval: Time points array
290
+ x, y: Solution arrays from the main trajectory
291
+
292
+ Returns:
293
+ tuple: (ftle, final_d, ftle_r2) or (np.nan, np.nan, np.nan) if computation fails
294
+ """
295
+ eps = 1e-6 * (1.0 + abs(x0) + abs(y0))
296
+ xp0, yp0 = x0 + eps, y0 + 0.5 * eps
297
+ try:
298
+ sol_p = solve_ivp(rhs, (0, te), (xp0, yp0), method='DOP853', t_eval=t_eval)
299
+ if sol_p.success:
300
+ xp, yp = sol_p.y
301
+ dist = np.sqrt((x - xp) ** 2 + (y - yp) ** 2)
302
+ dist = np.where(dist <= 0, 1e-12, dist)
303
+ final_d = float(dist[-1])
304
+ s_idx, e_idx = int(0.25 * len(t_eval)), int(0.75 * len(t_eval))
305
+ if e_idx > s_idx + 1:
306
+ d_slice = dist[s_idx:e_idx]
307
+ t_slice = t_eval[s_idx:e_idx]
308
+ d_slice = np.clip(d_slice, 1e-12, None)
309
+ ln_d = np.log(d_slice)
310
+ slope, intercept = np.polyfit(t_slice, ln_d, 1)
311
+ ftle = float(slope)
312
+ resid = ln_d - (slope * t_slice + intercept)
313
+ ss_res = np.sum(resid ** 2)
314
+ ss_tot = np.sum((ln_d - np.mean(ln_d)) ** 2)
315
+ ftle_r2 = 1 - ss_res / ss_tot if ss_tot > 0 else np.nan
316
+ return ftle, final_d, ftle_r2
317
+ return np.nan, np.nan, np.nan
318
+ except Exception:
319
+ return np.nan, np.nan, np.nan
320
+
321
+
322
+ def hurst_rs(ts: np.ndarray) -> float:
323
+ """
324
+ Compute the Hurst exponent using the Rescaled Range (R/S) method.
325
+
326
+ Args:
327
+ ts: Time series data
328
+
329
+ Returns:
330
+ float: Hurst exponent or np.nan if computation fails
331
+ """
332
+ x = np.array(ts, dtype=float)
333
+ N = len(x)
334
+ if N < 20:
335
+ return np.nan
336
+ x = x - np.mean(x)
337
+ Y = np.cumsum(x)
338
+ R = np.zeros(N)
339
+ S = np.zeros(N)
340
+ for n in range(10, N // 2 + 1):
341
+ seg = x[:n]
342
+ Yseg = Y[:n]
343
+ Rn = np.max(Yseg) - np.min(Yseg)
344
+ Sn = np.std(seg, ddof=0)
345
+ if Sn > 0:
346
+ R[n - 1] = Rn
347
+ S[n - 1] = Sn
348
+ valid = (S > 0) & (R > 0)
349
+ if np.sum(valid) < 3:
350
+ return np.nan
351
+ rs = R[valid] / S[valid]
352
+ ns = np.arange(1, N + 1)[valid]
353
+ try:
354
+ H = np.polyfit(np.log(ns), np.log(rs), 1)[0]
355
+ except Exception:
356
+ H = np.nan
357
+ return float(H)
358
+
359
+
360
+ def curvature_radius_stats(
361
+ x: np.ndarray,
362
+ y: np.ndarray,
363
+ t: np.ndarray,
364
+ max_radius: float = 1e6,
365
+ clip_inf: bool = True
366
+ ) -> Dict[str, Any]:
367
+ """
368
+ Compute robust curvature/radius statistics for a parametric curve (x(t), y(t)).
369
+
370
+ Args:
371
+ x, y: Coordinates of the curve
372
+ t: Parameter values
373
+ max_radius: Maximum radius to consider
374
+ clip_inf: Whether to clip infinite/very large radii
375
+
376
+ Returns:
377
+ dict: Dictionary containing various curvature statistics
378
+ """
379
+ x_t = np.gradient(x, t)
380
+ y_t = np.gradient(y, t)
381
+ x_tt = np.gradient(x_t, t)
382
+ y_tt = np.gradient(y_t, t)
383
+ denom = (x_t ** 2 + y_t ** 2) ** 1.5
384
+ num = np.abs(x_t * y_tt - y_t * x_tt)
385
+ with np.errstate(divide='ignore', invalid='ignore'):
386
+ kappa = np.where(denom > 0, num / denom, np.nan)
387
+ radius = np.where(np.isfinite(kappa) & (kappa != 0), 1.0 / kappa, np.nan)
388
+ if clip_inf:
389
+ radius = np.where(radius > max_radius, np.nan, radius)
390
+ finite = np.isfinite(radius)
391
+ stats = {
392
+ "count_total": len(radius),
393
+ "count_finite": int(np.sum(finite)),
394
+ "frac_finite": float(np.sum(finite) / len(radius)),
395
+ "mean": float(np.nanmean(radius)) if np.isfinite(np.nanmean(radius)) else np.nan,
396
+ "median": float(np.nanmedian(radius)) if np.isfinite(np.nanmedian(radius)) else np.nan,
397
+ "p10": float(np.nanpercentile(radius, 10)) if np.isfinite(np.nanpercentile(radius, 10)) else np.nan,
398
+ "p90": float(np.nanpercentile(radius, 90)) if np.isfinite(np.nanpercentile(radius, 90)) else np.nan,
399
+ "std": float(np.nanstd(radius)) if np.isfinite(np.nanstd(radius)) else np.nan,
400
+ "radius_array": radius,
401
+ "kappa_array": (1.0 / radius),
402
+ }
403
+ return stats
404
+
405
+
406
+ def compute_path_length(x: np.ndarray, y: np.ndarray) -> float:
407
+ """
408
+ Compute the total path length of a curve (x(t), y(t)).
409
+
410
+ Args:
411
+ x, y: Coordinates of the curve
412
+
413
+ Returns:
414
+ float: Total path length
415
+ """
416
+ dx = np.diff(x)
417
+ dy = np.diff(y)
418
+ seg_lengths = np.sqrt(dx * dx + dy * dy)
419
+ return float(np.sum(seg_lengths))
420
+
421
+
422
+ def compute_local_zscore(metrics: List[Dict]) -> List[Optional[float]]:
423
+ """
424
+ Compute local z-score of curvature median versus nearest neighbors.
425
+
426
+ Args:
427
+ metrics: List of metric dictionaries
428
+
429
+ Returns:
430
+ List of local z-scores (or None if computation fails)
431
+ """
432
+ n = len(metrics)
433
+ if n <= 1:
434
+ return [np.nan] * n
435
+
436
+ arr_init = np.array([[m["initial_x"], m["initial_y"]] for m in metrics])
437
+ rad_meds = np.array([m["curv_radius_median"] for m in metrics])
438
+ local_z = np.full(n, np.nan)
439
+
440
+ try:
441
+ from sklearn.neighbors import NearestNeighbors
442
+ use_sklearn = True
443
+ except Exception:
444
+ use_sklearn = False
445
+
446
+ if use_sklearn:
447
+ nbrs_k = min(5, n - 1)
448
+ nbrs = NearestNeighbors(n_neighbors=nbrs_k + 1).fit(arr_init)
449
+ distances, indices = nbrs.kneighbors(arr_init)
450
+ for i in range(n):
451
+ neigh_idx = indices[i, 1:]
452
+ neigh_vals = rad_meds[neigh_idx]
453
+ neigh_vals = neigh_vals[np.isfinite(neigh_vals)]
454
+ if not np.isfinite(rad_meds[i]) or len(neigh_vals) < 1:
455
+ local_z[i] = np.nan
456
+ else:
457
+ mu = np.mean(neigh_vals)
458
+ sigma = np.std(neigh_vals)
459
+ local_z[i] = (rad_meds[i] - mu) / sigma if sigma != 0 else np.nan
460
+ else:
461
+ nbrs_k = min(5, n - 1)
462
+ for i in range(n):
463
+ dists = np.linalg.norm(arr_init - arr_init[i : i + 1], axis=1)
464
+ order = np.argsort(dists)
465
+ neigh_idx = order[1 : 1 + nbrs_k]
466
+ neigh_vals = rad_meds[neigh_idx]
467
+ neigh_vals = neigh_vals[np.isfinite(neigh_vals)]
468
+ if not np.isfinite(rad_meds[i]) or len(neigh_vals) < 1:
469
+ local_z[i] = np.nan
470
+ else:
471
+ mu = np.mean(neigh_vals)
472
+ sigma = np.std(neigh_vals)
473
+ local_z[i] = (rad_meds[i] - mu) / sigma if sigma != 0 else np.nan
474
+
475
+ return list(local_z)
476
+
477
+
478
+ def robust_z(arr: np.ndarray) -> np.ndarray:
479
+ """
480
+ Compute robust z-score using median and IQR.
481
+
482
+ Args:
483
+ arr: Input array
484
+
485
+ Returns:
486
+ Array of robust z-scores
487
+ """
488
+ arr = np.array(arr, dtype=float)
489
+ finite = np.isfinite(arr)
490
+ out = np.full_like(arr, np.nan)
491
+ if np.sum(finite) == 0:
492
+ return out
493
+ median = np.nanmedian(arr[finite])
494
+ q1 = np.nanpercentile(arr[finite], 25)
495
+ q3 = np.nanpercentile(arr[finite], 75)
496
+ iqr = q3 - q1 if q3 - q1 != 0 else 1.0
497
+ out[finite] = (arr[finite] - median) / iqr
498
+ return out
499
+
500
+
501
+ def compute_anomaly_score(df) -> np.ndarray:
502
+ """
503
+ Compute anomaly score combining multiple indicators.
504
+
505
+ Uses robust z-scores (IQR-based) to combine:
506
+ - FTLE (higher = more anomalous)
507
+ - path_len (higher = more anomalous)
508
+ - max_kappa (higher = more anomalous)
509
+ - ftle_r2 (lower = more anomalous, so we subtract)
510
+ - hurst (higher = more anomalous)
511
+
512
+ Args:
513
+ df: DataFrame with metrics
514
+
515
+ Returns:
516
+ Array of anomaly scores
517
+ """
518
+ if df.empty:
519
+ return np.array([])
520
+
521
+ ftle_z = robust_z(df['ftle'].values)
522
+ path_z = robust_z(df['path_len'].values)
523
+ kappa_z = robust_z(df['max_kappa'].values)
524
+ r2_z = robust_z(df['ftle_r2'].fillna(0).values)
525
+ hurst_z = robust_z(df['hurst'].fillna(0).values)
526
+
527
+ score_arr = ftle_z + path_z + kappa_z - r2_z + hurst_z
528
+ return score_arr
529
+
530
+
531
+ def compute_shadowing_diagnostics(
532
+ solutions: List[Dict],
533
+ epsilon_threshold: float = 1e-3
534
+ ) -> Dict[str, Any]:
535
+ """
536
+ Compute shadowing diagnostics comparing trajectory solutions.
537
+
538
+ Args:
539
+ solutions: List of solution dictionaries (each with 'x', 'y', 't_full')
540
+ epsilon_threshold: Threshold for shadowing breakdown
541
+
542
+ Returns:
543
+ Dictionary with shadowing diagnostics
544
+ """
545
+ epsilon_t_list = []
546
+ shadowing_times = []
547
+
548
+ for sol in solutions:
549
+ x = sol['x']
550
+ y = sol['y']
551
+ t_full = sol['t_full']
552
+
553
+ eps = 1e-6 * (1.0 + abs(x[0]) + abs(y[0]))
554
+ xp0, yp0 = x[0] + eps, y[0] + 0.5 * eps
555
+ try:
556
+ sol_p = solve_ivp(
557
+ lambda t, s: [0, 0],
558
+ (t_full[0], t_full[-1]),
559
+ (xp0, yp0),
560
+ method='DOP853',
561
+ t_eval=t_full
562
+ )
563
+ if sol_p.success:
564
+ xp, yp = sol_p.y
565
+ dist = np.sqrt((x - xp) ** 2 + (y - yp) ** 2)
566
+ epsilon_t = np.maximum.accumulate(dist)
567
+ epsilon_t_list.append(epsilon_t)
568
+
569
+ exceed_indices = np.where(epsilon_t > epsilon_threshold)[0]
570
+ if len(exceed_indices) > 0:
571
+ first_exceed_idx = exceed_indices[0]
572
+ shadowing_times.append(t_full[first_exceed_idx])
573
+ else:
574
+ shadowing_times.append(None)
575
+ except Exception:
576
+ pass
577
+
578
+ return {
579
+ 'epsilon_t_list': epsilon_t_list,
580
+ 'shadowing_times': shadowing_times,
581
+ }
differ_hug/docs.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Documentation and system information for the ODE research platform.
3
+
4
+ This module provides documentation text in a Gradio-friendly format.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+
10
+ def documentation_markdown() -> str:
11
+ """
12
+ Return documentation as Markdown text for Gradio display.
13
+
14
+ Returns:
15
+ Markdown string with documentation content
16
+ """
17
+ return """---
18
+ **Curve Annotations:**
19
+ - Each curve is annotated with its `idx` at the final point.
20
+ - Table shows robust curvature statistics (median, p10, p90) and path length.
21
+ - FTLE diagnostics include R² of ln(dist) fit (ftle_r2). Anomaly score combines FTLE, path length, max curvature and R² reliability.
22
+
23
+ ---
24
+ **Column Descriptions:**
25
+ - `idx` — Index of the trajectory, starting from 0
26
+ - `ftle` — Finite-Time Lyapunov Exponent, computed as the slope of linear fit of ln(d(t)) vs t on a central window (25%–75% of t_eval) after clipping d(t) to a minimum (1e-12)
27
+ - `ftle_r2` — Coefficient of determination (R²) of the linear fit for FTLE, indicating reliability of the ftle estimate
28
+ - `amp` — Amplitude (max−min of radial distance √(x²+y²))
29
+ - `final_dist` — Final distance between the original trajectory and its companion trajectory with tiny perturbation
30
+ - `hurst` — Hurst exponent using the rescaled range (R/S) method calculated for x(t) and y(t) and averaged
31
+ - `curv_rad_mn` — Mean curvature radius computed from 1/κ(t) where κ(t) is the curvature
32
+ - `curv_rad_med` — Median curvature radius computed from 1/κ(t) where κ(t) is the curvature
33
+ - `curv_rad_std` — Standard deviation of curvature radius
34
+ - `curv_p10` — 10th percentile of curvature radius
35
+ - `curv_p90` — 90th percentile of curvature radius
36
+ - `curv_ct_fin` — Number of finite radius samples
37
+ - `initial_x` — X coordinate of the initial point
38
+ - `initial_y` — Y coordinate of the initial point
39
+ - `path_len` — Total arclength computed as sum of Euclidean distances between consecutive points along the trajectory
40
+ - `max_kappa` — Maximum finite curvature value, useful to detect sharp bends
41
+ - `frac_high_curv` — Fraction of time points with κ(t) above a threshold (default κ > 0.1, i.e., radius < 10), measuring density of sharp bends along the trajectory
42
+ - `curv_rad_lcl_z` — Local z-score of curve median curvature radius relative to nearest neighbors in initial condition space
43
+ - `anomaly_score` — Aggregated score combining robust z-scores (IQR-based) of ftle, path_len, max_kappa, and ftle_r2 (ftle + path_len + max_kappa − ftle_r2)
44
+
45
+ ---
46
+ """
47
+
48
+
49
+ def system_latex() -> str:
50
+ """
51
+ Return the ODE system as LaTeX string.
52
+
53
+ Returns:
54
+ LaTeX string for the gene regulatory ODE system
55
+ """
56
+ return r"""
57
+ $$\begin{cases}
58
+ \displaystyle \frac{dx}{dt} = \frac{K\,x^{1/\alpha}}{b^{1/\alpha} + x^{1/\alpha}} - \gamma_1\,x,\\[10pt]
59
+ \displaystyle \frac{dy}{dt} = \frac{K\,y^{1/\alpha}}{b^{1/\alpha} + y^{1/\alpha}} - \gamma_2\,y.
60
+ \end{cases}$$
61
+ """
62
+
63
+
64
+ def get_system_description() -> str:
65
+ """
66
+ Return a brief description of the gene regulatory ODE system.
67
+
68
+ Returns:
69
+ Short description string
70
+ """
71
+ return """Gene Regulatory ODE System
72
+
73
+ This system models gene regulation with cooperative sigmoidal activation:
74
+ - dx/dt = K * x^(1/alpha) / (b^(1/alpha) + x^(1/alpha)) - gamma1 * x
75
+ - dy/dt = K * y^(1/alpha) / (b^(1/alpha) + y^(1/alpha)) - gamma2 * y
76
+
77
+ Parameters:
78
+ - alpha: Controls the steepness of the sigmoidal response (1/alpha is the Hill coefficient)
79
+ - K: Maximum activation rate
80
+ - b: Half-activation constant
81
+ - gamma1, gamma2: Decay rates for x and y
82
+
83
+ The system exhibits bistability and can show multiple stable states depending on parameters."""
differ_hug/params.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Parameter handling for the ODE research platform.
3
+
4
+ This module provides functions to convert parameters to/from plain text
5
+ format for easy sharing and editing.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+
10
+
11
+ _ORDER = [
12
+ "t_number",
13
+ "t_train_end",
14
+ "t_full_end",
15
+ "alpha",
16
+ "K",
17
+ "b",
18
+ "gamma1",
19
+ "gamma2",
20
+ "initial_radius",
21
+ "num_points",
22
+ "circle_start",
23
+ "circle_end",
24
+ "center_x",
25
+ "center_y",
26
+ ]
27
+
28
+
29
+ def _fmt_value(v: Any) -> str:
30
+ """Format numbers concisely for plain text output."""
31
+ if isinstance(v, bool):
32
+ return "1" if v else "0"
33
+ if isinstance(v, int):
34
+ return str(v)
35
+ try:
36
+ fv = float(v)
37
+ s = ("%g" % fv)
38
+ return s
39
+ except Exception:
40
+ return str(v)
41
+
42
+
43
+ def parameters_to_text(params: Dict[str, Any]) -> str:
44
+ """
45
+ Convert parameter dictionary to plain text format.
46
+
47
+ Args:
48
+ params: Dictionary of parameter names and values
49
+
50
+ Returns:
51
+ Plain text string with "key=value" pairs separated by semicolons
52
+ """
53
+ parts = []
54
+ used = set()
55
+ for k in _ORDER:
56
+ if k in params:
57
+ parts.append(f"{k}={_fmt_value(params[k])}")
58
+ used.add(k)
59
+ for k, v in params.items():
60
+ if k not in used:
61
+ parts.append(f"{k}={_fmt_value(v)}")
62
+ return "; ".join(parts)
63
+
64
+
65
+ def text_to_parameters(text: str) -> Dict[str, Any]:
66
+ """
67
+ Parse plain text to parameter dictionary.
68
+
69
+ Args:
70
+ text: String with "key=value" pairs separated by semicolons
71
+
72
+ Returns:
73
+ Dictionary with parsed parameters
74
+ """
75
+ result: Dict[str, Any] = {}
76
+ if not isinstance(text, str):
77
+ return result
78
+ raw = text.replace("\n", ";")
79
+ for chunk in raw.split(";"):
80
+ if "=" not in chunk:
81
+ continue
82
+ key, val = chunk.split("=", 1)
83
+ key = key.strip()
84
+ val = val.strip()
85
+ if not key:
86
+ continue
87
+ if val.endswith("°"):
88
+ val = val[:-1]
89
+ try:
90
+ if val.lower().startswith("0x"):
91
+ result[key] = int(val, 16)
92
+ else:
93
+ iv = int(val)
94
+ result[key] = iv
95
+ continue
96
+ except Exception:
97
+ pass
98
+ try:
99
+ fv = float(val)
100
+ result[key] = fv
101
+ continue
102
+ except Exception:
103
+ pass
104
+ result[key] = val
105
+ return result
differ_hug/plotting.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Plotting functions for the ODE research platform.
3
+
4
+ This module provides functions to create matplotlib figures
5
+ for phase portraits, time series, and shadowing analysis.
6
+ """
7
+
8
+ import numpy as np
9
+ import matplotlib.pyplot as plt
10
+ from typing import List, Dict, Tuple, Optional
11
+
12
+
13
+ def make_phase_portrait_figure(
14
+ solutions: List[Dict],
15
+ selected_indices: List[int],
16
+ solver_type: str = "DOP853",
17
+ t_train_end: float = 1.0,
18
+ t_full_end: float = 3.0,
19
+ t_number: int = 100,
20
+ show_connections: bool = False,
21
+ connection_stride: int = 5,
22
+ ) -> plt.Figure:
23
+ """
24
+ Create phase portrait figure showing trajectories in phase space.
25
+
26
+ Args:
27
+ solutions: List of solution dictionaries with 'x', 'y', 't_full'
28
+ selected_indices: List of indices to display
29
+ solver_type: Type of solver used
30
+ t_train_end: Training interval end time
31
+ t_full_end: Full integration end time
32
+ t_number: Number of time points
33
+ show_connections: Whether to show connections between DOP853 and NN
34
+ connection_stride: Stride for connection lines
35
+
36
+ Returns:
37
+ matplotlib Figure object
38
+ """
39
+ fig, ax = plt.subplots(figsize=(8, 6))
40
+ styles = ['-', '--', '-.', ':']
41
+ colors = plt.cm.tab20.colors
42
+
43
+ for m, solution_data in enumerate(solutions):
44
+ if m not in selected_indices:
45
+ continue
46
+ color = colors[m % len(colors)]
47
+
48
+ x_dop = solution_data["x"]
49
+ y_dop = solution_data["y"]
50
+ ax.plot(x_dop, y_dop, linestyle='-', color=color, linewidth=1.2,
51
+ label=f'DOP853 traj {m}' if m == selected_indices[0] else "")
52
+
53
+ ax.plot(x_dop[0], y_dop[0], 'o', color=color, markersize=4)
54
+ ax.plot(x_dop[-1], y_dop[-1], 'x', color=color, markersize=6)
55
+ ax.text(x_dop[-1] + 0.01, y_dop[-1] + 0.01, f"{m}", fontsize=8, color=color)
56
+
57
+ if "x_nn" in solution_data and solution_data["x_nn"] is not None:
58
+ x_nn = solution_data["x_nn"]
59
+ y_nn = solution_data["y_nn"]
60
+ t_full = solution_data["t_full"]
61
+
62
+ ax.plot(x_nn, y_nn, linestyle='--', color=color, linewidth=1.0, alpha=0.7,
63
+ label=f'NN traj {m}' if m == selected_indices[0] else "")
64
+
65
+ ax.plot(x_dop[0], y_dop[0], '^', color=color, markersize=8, markeredgecolor='black')
66
+
67
+ if x_nn is not None and y_nn is not None:
68
+ train_idx = np.searchsorted(t_full, t_train_end)
69
+ if train_idx < len(x_dop):
70
+ ax.plot(x_nn[train_idx], y_nn[train_idx], '^', color=color, markersize=8,
71
+ markeredgecolor='black', markerfacecolor='none')
72
+
73
+ ax.plot([x_dop[train_idx], x_nn[train_idx]],
74
+ [y_dop[train_idx], y_nn[train_idx]],
75
+ color=color, linewidth=1.0, alpha=0.7, linestyle=':')
76
+
77
+ if show_connections:
78
+ for i in range(0, len(x_dop), connection_stride):
79
+ ax.plot([x_dop[i], x_nn[i]], [y_dop[i], y_nn[i]],
80
+ color=color, linewidth=0.5, alpha=0.3, linestyle='-')
81
+
82
+ ax.text(x_dop[-1] + 0.01, y_dop[-1] + 0.01, f"{m}", fontsize=8, color=color)
83
+ if "x_nn" in solution_data and solution_data["x_nn"] is not None:
84
+ if solution_data["x_nn"] is not None:
85
+ ax.text(solution_data["x_nn"][-1] + 0.01, solution_data["y_nn"][-1] + 0.01,
86
+ f"{m}", fontsize=8, color=color)
87
+
88
+ ax.set_title(f"Gene regulatory trajectories ({solver_type}) "
89
+ f"— t_train_end={t_train_end:.2f}, t_full_end={t_full_end:.2f}, t_points={t_number}")
90
+ ax.set_xlabel("x(t)")
91
+ ax.set_ylabel("y(t)")
92
+ ax.grid(True)
93
+
94
+ if len(selected_indices) <= 3:
95
+ ax.legend()
96
+ else:
97
+ from matplotlib.lines import Line2D
98
+ legend_elements = [
99
+ Line2D([0], [0], color='gray', lw=2, linestyle='-', label='DOP853'),
100
+ Line2D([0], [0], color='gray', lw=2, linestyle='--', label='NN')
101
+ ]
102
+ ax.legend(handles=legend_elements)
103
+
104
+ return fig
105
+
106
+
107
+ def make_time_series_figure(
108
+ solutions: List[Dict],
109
+ selected_indices: List[int],
110
+ solver_type: str = "DOP853",
111
+ t_train_end: float = 1.0,
112
+ t_full_end: float = 3.0,
113
+ t_number: int = 100,
114
+ ) -> plt.Figure:
115
+ """
116
+ Create time series figure showing x(t) and y(t) over time.
117
+
118
+ Args:
119
+ solutions: List of solution dictionaries
120
+ selected_indices: List of indices to display
121
+ solver_type: Type of solver used
122
+ t_train_end: Training interval end time
123
+ t_full_end: Full integration end time
124
+ t_number: Number of time points
125
+
126
+ Returns:
127
+ matplotlib Figure object
128
+ """
129
+ fig, ax_ts = plt.subplots(figsize=(8, 6))
130
+ colors = plt.cm.tab20.colors
131
+
132
+ for m, solution_data in enumerate(solutions):
133
+ if m not in selected_indices:
134
+ continue
135
+ color = colors[m % len(colors)]
136
+
137
+ t_full = solution_data["t_full"]
138
+ x_dop = solution_data["x"]
139
+ y_dop = solution_data["y"]
140
+
141
+ ax_ts.plot(t_full, x_dop, linestyle='-', color=color, linewidth=1.2,
142
+ label=f'x(t) traj {m}' if m == selected_indices[0] else "")
143
+ ax_ts.plot(t_full, y_dop, linestyle='--', color=color, linewidth=1.2,
144
+ label=f'y(t) traj {m}' if m == selected_indices[0] else "")
145
+
146
+ ax_ts.set_title(f"Time series ({solver_type}) — x(t) and y(t) — "
147
+ f"t_train_end={t_train_end:.2f}, t_full_end={t_full_end:.2f}, t_points={t_number}")
148
+ ax_ts.set_xlabel("t")
149
+ ax_ts.set_ylabel("x(t), y(t)")
150
+ ax_ts.grid(True)
151
+ ax_ts.legend()
152
+
153
+ return fig
154
+
155
+
156
+ def make_shadowing_figure(
157
+ solutions: List[Dict],
158
+ selected_indices: List[int],
159
+ solver_type: str = "DOP853",
160
+ t_train_end: float = 1.0,
161
+ t_full_end: float = 3.0,
162
+ t_number: int = 100,
163
+ epsilon_threshold: float = 1e-3,
164
+ ) -> Optional[plt.Figure]:
165
+ """
166
+ Create shadowing figure showing epsilon(t) vs time.
167
+
168
+ Args:
169
+ solutions: List of solution dictionaries
170
+ selected_indices: List of indices to display
171
+ solver_type: Type of solver used
172
+ t_train_end: Training interval end time
173
+ t_full_end: Full integration end time
174
+ t_number: Number of time points
175
+ epsilon_threshold: Threshold for shadowing breakdown
176
+
177
+ Returns:
178
+ matplotlib Figure object or None if no shadowing data available
179
+ """
180
+ if not selected_indices:
181
+ return None
182
+
183
+ fig, ax_shad = plt.subplots(figsize=(8, 6))
184
+ colors = plt.cm.tab20.colors
185
+
186
+ annotated = False
187
+ for m, solution_data in enumerate(solutions):
188
+ if m not in selected_indices:
189
+ continue
190
+ color = colors[m % len(colors)]
191
+
192
+ t_full = solution_data["t_full"]
193
+ x_dop = solution_data["x"]
194
+ y_dop = solution_data["y"]
195
+
196
+ eps = 1e-6 * (1.0 + abs(x_dop[0]) + abs(y_dop[0]))
197
+ xp0, yp0 = x_dop[0] + eps, y_dop[0] + 0.5 * eps
198
+ try:
199
+ from scipy.integrate import solve_ivp
200
+ sol_p = solve_ivp(
201
+ lambda t, s: [0, 0],
202
+ (t_full[0], t_full[-1]),
203
+ (xp0, yp0),
204
+ method='DOP853',
205
+ t_eval=t_full
206
+ )
207
+ if sol_p.success:
208
+ xp, yp = sol_p.y
209
+ dist = np.sqrt((x_dop - xp)**2 + (y_dop - yp)**2)
210
+ epsilon_t = np.maximum.accumulate(dist)
211
+
212
+ ax_shad.plot(epsilon_t, t_full, color=color, linewidth=1.2,
213
+ label=f'ε(t) traj {m}' if m == selected_indices[0] else "")
214
+
215
+ exceed_indices = np.where(epsilon_t > epsilon_threshold)[0]
216
+ if len(exceed_indices) > 0:
217
+ first_exceed_idx = exceed_indices[0]
218
+ shadowing_time = t_full[first_exceed_idx]
219
+ ax_shad.axhline(y=shadowing_time, color=color, linestyle=':', alpha=0.7,
220
+ label=f't*={shadowing_time:.2f}' if not annotated else "")
221
+ annotated = True
222
+ except Exception:
223
+ pass
224
+
225
+ ax_shad.axhline(y=t_train_end, color='red', linestyle='--', alpha=0.7,
226
+ label=f't_train_end={t_train_end}')
227
+
228
+ ax_shad.set_title(f"Shadowing ({solver_type}) — ε(t) vs t — "
229
+ f"t_train_end={t_train_end:.2f}, t_full_end={t_full_end:.2f}, t_points={t_number}")
230
+ ax_shad.set_xlabel("ε(t)")
231
+ ax_shad.set_ylabel("t")
232
+ ax_shad.grid(True)
233
+
234
+ if len(selected_indices) <= 3:
235
+ ax_shad.legend()
236
+ else:
237
+ from matplotlib.lines import Line2D
238
+ legend_elements = [
239
+ Line2D([0], [0], color='gray', lw=2, linestyle='-', label='ε(t)'),
240
+ Line2D([0], [0], color='red', lw=1, linestyle='--', label=f'train/extrap boundary')
241
+ ]
242
+ ax_shad.legend(handles=legend_elements)
243
+
244
+ return fig
245
+
246
+
247
+ def make_metrics_table_figure(
248
+ metrics_df,
249
+ show_extremes: bool = True,
250
+ ) -> plt.Figure:
251
+ """
252
+ Create a figure showing the metrics table.
253
+
254
+ Args:
255
+ metrics_df: DataFrame with metrics
256
+ show_extremes: Whether to highlight extreme values
257
+
258
+ Returns:
259
+ matplotlib Figure object
260
+ """
261
+ if metrics_df.empty:
262
+ fig, ax = plt.subplots(figsize=(10, 4))
263
+ ax.text(0.5, 0.5, "No metrics to display", ha='center', va='center', fontsize=14)
264
+ ax.set_axis_off()
265
+ return fig
266
+
267
+ fig, ax = plt.subplots(figsize=(12, 6))
268
+
269
+ column_rename_map = {
270
+ 'curv_radius_mean': 'curv_rad_mn',
271
+ 'curv_radius_median': 'curv_rad_med',
272
+ 'curv_radius_std': 'curv_rad_std',
273
+ 'curv_radius_local_zscore': 'curv_rad_lcl_z',
274
+ 'curv_count_finite': 'curv_ct_fin'
275
+ }
276
+
277
+ df_display = metrics_df.rename(columns=column_rename_map).copy()
278
+ df_display = df_display.reset_index(drop=True)
279
+
280
+ numeric_columns = df_display.select_dtypes(include=['number']).columns.tolist()
281
+
282
+ if show_extremes:
283
+ max_cols = ['idx', 'ftle', 'ftle_r2', 'amp', 'final_dist', 'hurst', 'curv_ct_fin',
284
+ 'path_len', 'max_kappa', 'frac_high_curv', 'anomaly_score']
285
+ min_cols = ['curv_rad_mn', 'curv_rad_med', 'curv_rad_std', 'curv_rad_lcl_z', 'curv_p10', 'curv_p90']
286
+
287
+ styles = [['' for _ in range(len(df_display.columns))] for _ in range(len(df_display))]
288
+
289
+ for i, col in enumerate(df_display.columns):
290
+ if col == 'idx':
291
+ continue
292
+ if col in max_cols and df_display[col].dtype in ['float64', 'int64', 'float32', 'int32']:
293
+ if not df_display[col].isna().all():
294
+ valid = df_display[col].dropna()
295
+ if len(valid) >= 2:
296
+ top2 = valid.nlargest(2)
297
+ for idx in top2.index:
298
+ styles[idx][i] = 'background-color: #D2691E'
299
+ elif len(valid) == 1:
300
+ styles[valid.index[0]][i] = 'background-color: #D2691E'
301
+ elif col in min_cols and df_display[col].dtype in ['float64', 'int64', 'float32', 'int32']:
302
+ if not df_display[col].isna().all():
303
+ valid = df_display[col].dropna()
304
+ if len(valid) >= 2:
305
+ bot2 = valid.nsmallest(2)
306
+ for idx in bot2.index:
307
+ styles[idx][i] = 'background-color: #00CED1'
308
+ elif len(valid) == 1:
309
+ styles[valid.index[0]][i] = 'background-color: #00CED1'
310
+
311
+ df_display = df_display.style.apply(lambda x: styles, axis=None)
312
+
313
+ ax.axis('off')
314
+
315
+ table_str = df_display.format("{:.3f}")._repr_html_()
316
+ ax.text(0.01, 0.99, table_str, transform=ax.transAxes, ha='left', va='top', fontsize=8)
317
+
318
+ return fig
requirements.txt CHANGED
@@ -1,8 +1,5 @@
1
- gradio==4.44.1
2
- huggingface_hub==0.23.0
3
- fastapi==0.111.0
4
- starlette==0.37.2
5
- jinja2==3.1.4
6
  numpy>=1.24.0
7
  scipy>=1.10.0
8
- matplotlib>=3.7.0
 
 
1
+ gradio>=4.44.1
 
 
 
 
2
  numpy>=1.24.0
3
  scipy>=1.10.0
4
+ matplotlib>=3.7.0
5
+ pandas>=1.5.0