---
title: "Visualization Framework Architecture"
subtitle: "Interactive, Publication-Ready Plotting for Dynamical Systems"
author: "Gil Benezer"
date: today
format:
html:
toc: true
toc-depth: 5
code-fold: show
code-tools: true
theme: cosmo
execute:
eval: true
cache: true
warning: false
output: true
---
## Overview {#sec-overview}
The **Visualization Framework** provides interactive, publication-ready plotting for dynamical systems analysis. It consists of **4 core modules** organized into a **2-layer architecture**: centralized theming + specialized plotters.
::: {.callout-note}
## Framework Components
- **Theming Layer**: Centralized colors, styles, and themes
- **Plotter Layer**: Specialized visualization classes
- `TrajectoryPlotter` - Time-domain plots
- `PhasePortraitPlotter` - State-space plots
- `ControlPlotter` - Control analysis plots
:::
::: {.callout-important}
## User Interface
**Users should NOT directly instantiate plotter classes.** Instead, access plotters through system properties:
```python
# ✓ CORRECT: Access via system properties
system = Pendulum()
fig = system.plotter.plot_trajectory(t, x) # TrajectoryPlotter
fig = system.phase_plotter.plot_2d(x) # PhasePortraitPlotter
fig = system.control_plotter.plot_eigenvalue_map(eigs) # ControlPlotter
# ❌ INCORRECT: Direct instantiation (not recommended)
from cdesym.visualization.trajectory_plotter import TrajectoryPlotter
plotter = TrajectoryPlotter() # Don't do this!
fig = plotter.plot_trajectory(t, x)
```
The system properties automatically configure plotters with the correct backend and provide a consistent interface. Direct instantiation is only needed for framework development or advanced customization.
:::
```{python}
#| label: setup-imports
#| echo: false
#| output: false
# Setup for all code examples
import numpy as np
import sympy as sp
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from typing import Optional, Tuple, Dict, List
import warnings
warnings.filterwarnings('ignore')
# For demonstrations, create example systems
from cdesym import Pendulum
from cdesym.systems.builtin.stochastic.continuous.continuous_stochastic_pendulum import (
ContinuousStochasticPendulum
)
# Deterministic pendulum for basic examples
system = Pendulum()
# Stochastic pendulum for Monte Carlo examples
stochastic_system = ContinuousStochasticPendulum(
g=9.81, L=1.0, b=0.5, sigma=0.3
)
```
## Architecture Philosophy {#sec-architecture-philosophy}
**Themeable Interactive Visualization** - The visualization framework enables:
1. **Interactive Plots** - Plotly-based with zoom, pan, hover tooltips
2. **Centralized Theming** - Consistent colors and styles across all plots
3. **Backend Agnostic** - Works with NumPy, PyTorch, JAX seamlessly
4. **Specialized Plotters** - Dedicated classes for different visualization types
5. **Publication Ready** - High-quality output for papers and presentations
6. **System Integration** - Clean `system.plotter` and `system.control_plotter` APIs
7. **Accessible Design** - Colorblind-safe palettes available
## Framework Layers {#sec-framework-layers}
```
┌────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ (ContinuousSystemBase, DiscreteSystemBase) │
│ │
│ system.plotter ──────► TrajectoryPlotter │
│ system.phase_plotter ──────► PhasePortraitPlotter │
│ system.control_plotter ──────► ControlPlotter │
└──────────────────┬─────────────────────────────────────────┘
│ use theming from
↓
┌────────────────────────────────────────────────────────────┐
│ THEMING LAYER │
│ themes.py │
│ │
│ ColorSchemes: PlotThemes: │
│ • PLOTLY • DEFAULT │
│ • D3 • PUBLICATION │
│ • COLORBLIND_SAFE • DARK │
│ • TABLEAU │
│ • SEQUENTIAL_BLUE │
│ • DIVERGING_RED_BLUE │
└──────────────────┬─────────────────────────────────────────┘
│ used by
↓
┌────────────────────────────────────────────────────────────┐
│ PLOTTER LAYER │
│ │
│ TrajectoryPlotter, PhasePortraitPlotter, ControlPlotter │
└────────────────────────────────────────────────────────────┘
```
## Theming Layer {#sec-theming-layer}
### Color Schemes {#sec-color-schemes}
The framework provides several built-in color schemes optimized for different purposes:
```{python}
#| label: demo-color-schemes
#| fig-cap: "Built-in color schemes with accessibility options"
# Demonstrate different color schemes
schemes = {
'Plotly (Default)': ['#636EFA', '#EF553B', '#00CC96', '#AB63FA', '#FFA15A'],
'Colorblind Safe': ['#0173B2', '#DE8F05', '#029E73', '#CC78BC', '#CA9161'],
'Tableau': ['#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'],
}
fig = make_subplots(
rows=len(schemes), cols=1,
subplot_titles=list(schemes.keys()),
vertical_spacing=0.20
)
for i, (name, colors) in enumerate(schemes.items(), 1):
for j, color in enumerate(colors):
fig.add_trace(
go.Bar(x=[j], y=[1], marker_color=color, showlegend=False),
row=i, col=1
)
fig.update_xaxes(showticklabels=False, row=i, col=1) # Hide x ticks
fig.update_yaxes(showticklabels=False, row=i, col=1)
# Add single x-axis label at bottom
fig.add_annotation(
text="Color Index",
xref="paper", yref="paper",
x=0.5, y=-0.05,
showarrow=False,
font=dict(size=12)
)
fig.update_layout(
height=450,
title_text="Color Scheme Comparison",
showlegend=False,
margin=dict(b=60)
)
fig
```
### Plot Themes {#sec-plot-themes}
Three built-in themes provide different visual styles:
| Theme | Use Case | Features |
|-------|----------|----------|
| `default` | Interactive exploration | Plotly default, colorful |
| `publication` | Papers, presentations | Clean, high-contrast, serif fonts |
| `dark` | Presentations, demos | Dark background, reduced eye strain |
## TrajectoryPlotter: Time-Domain Visualization {#sec-trajectory-plotter}
::: {.callout-tip}
## Integration Methods for Visualization
For visualization purposes, **use `simulate()`** instead of `integrate()`:
- `simulate()` returns **regular time grids** (uniform spacing) - ideal for plotting
- `integrate()` returns **adaptive time points** (variable spacing) - can create uneven plots
```python
# ✓ RECOMMENDED for plotting: Regular time grid
result = system.simulate(x0, controller, t_span=(0, 10), dt=0.01)
fig = system.plotter.plot_trajectory(result['time'], result['states'])
# ⚠️ Works but may have uneven time spacing: Adaptive grid
result = system.integrate(x0, u=None, t_span=(0, 10), method='RK45')
# Or use t_eval for regular grid:
result = system.integrate(x0, u=None, t_span=(0, 10), t_eval=np.linspace(0, 10, 1001))
fig = system.plotter.plot_trajectory(result['t'], result['x'])
```
**Why this matters:**
- Adaptive methods choose time points based on error control (dense where dynamics change, sparse where smooth)
- This can create visually uneven plots with more points in some regions
- Regular grids ensure uniform visual appearance
- For comparison plots, all trajectories **must** have same time grid
:::
### Basic Trajectory Plot {#sec-basic-trajectory}
The most common visualization: state variables vs time.
```{python}
#| label: demo-trajectory-basic
#| fig-cap: "Simple pendulum trajectory showing angle and angular velocity over time"
# Simulate pendulum with regular time grid (recommended for plotting)
x0 = np.array([0.5, 0.0])
result = system.simulate(x0, controller=None, t_span=(0, 10), dt=0.01)
# Plot using system's built-in plotter
fig = system.plotter.plot_trajectory(
result['time'],
result['states'],
state_names=['θ (rad)', 'ω (rad/s)'],
title='Pendulum Dynamics'
)
fig
```
::: {.callout-note}
## Why `simulate()` for Plotting
This example uses `simulate()` rather than `integrate()` because:
- **Regular time grid** (uniform dt=0.01) makes for smooth, evenly-spaced plots
- **Time-major output** `result['states']` is (T, nx) - natural for plotting
- **Reconstructed controls** available if controller provided
For comparison, `integrate()` returns adaptive time points that may create visually uneven plots.
:::
### Batched Trajectories (Monte Carlo) {#sec-batched-trajectories}
Automatically handles multiple trajectories. This example uses the **stochastic pendulum** to show genuine stochastic dynamics:
```{python}
#| label: demo-trajectory-batch
#| fig-cap: "Monte Carlo simulation of stochastic pendulum showing 20 independent noise realizations"
# Use torch backend (now fixed!)
stochastic_system.set_default_backend('torch')
# Run Monte Carlo simulation
n_trials = 20
x0 = np.array([0.5, 0.0])
# Define output times
t_eval = np.linspace(0, 10, 1001)
# Collect trajectories
results = []
for i in range(n_trials):
result = stochastic_system.integrate(
x0,
u=None,
t_span=(0, 10),
method='euler',
dt=0.01,
t_eval=t_eval,
seed=i
)
# Convert torch tensor to numpy
x_np = result['x'].cpu().numpy() if hasattr(result['x'], 'cpu') else result['x']
results.append(x_np)
# Stack: (n_trials, T, nx)
x_batch = np.stack(results)
t_np = result['t'].cpu().numpy() if hasattr(result['t'], 'cpu') else result['t']
print(f"Batch shape: {x_batch.shape} (n_trials={n_trials}, T={len(t_np)}, nx=2)")
# Plot individual trajectories
fig = stochastic_system.plotter.plot_trajectory(
t_np,
x_batch,
state_names=['θ (rad)', 'ω (rad/s)'],
title=f'Stochastic Pendulum Monte Carlo (n={n_trials}, σ=0.3)',
color_scheme='colorblind_safe'
)
fig
```
::: {.callout-note}
## Stochastic Monte Carlo Visualization
This demonstrates **true stochastic dynamics** from Wiener process noise, described by the SDE:
$$dx = f(x, u)dt + g(x, u)dW$$
**What you see:**
- **Individual trajectories**: Each colored line shows one noise realization
- **Stochastic spread**: Trajectories diverge over time from accumulated Brownian forcing
- **Nonlinear effects**: Pendulum damping limits growth but uncertainty remains
**Key distinction**: The trajectory variability comes from **random Brownian forcing** $dW(t)$, not just different initial conditions. Starting from identical states (θ=0.5, ω=0), the 20 trajectories spread due to different noise realizations, visualizing inherent stochastic uncertainty.
For comparison, deterministic Monte Carlo shows variability only from initial condition uncertainty, not from dynamic randomness.
:::
::: {.callout-tip}
## SDE Integration
If you still want to use `integrate` directly despite the recommendation for `simulate`, specify output times with `t_eval`:
```python
# Define output times
t_eval = np.linspace(0, 10, 1001) # 1001 uniformly spaced points
result = system.integrate(
x0,
u=None,
t_span=(0, 10),
method='euler', # PyTorch torchsde method
dt=0.01, # Integration time step
t_eval=t_eval, # Output time points
seed=42
)
```
**Integration Parameters:**
- `dt=0.01`: Internal time step (controls accuracy)
- `t_eval`: Which points to return in output (controls density)
:::
::: {.callout-tip}
## Backend Conversion for Plotting
The plotter requires **NumPy arrays**, but integration returns **arrays of default backend type**:
```python
# Convert torch tensor to numpy
x_np = result['x'].cpu().numpy() # Move to CPU, then numpy
t_np = result['t'].cpu().numpy() # Same for time
```
**Why this is needed:**
- PyTorch solvers work with torch tensors (for autodiff/GPU)
- JAX solvers work with jax.numpy.array arrays (for TPU/JIT performance)
- Plotly requires NumPy arrays for visualization
:::
### State and Control Together {#sec-state-control}
Visualize both states and control inputs:
```{python}
#| label: demo-state-control
#| fig-cap: "Combined state and control visualization"
# Simple proportional controller
def controller(x, t):
K = np.array([[-0.5, -0.15]])
return -K @ x
# Simulate with controller
from cdesym.systems.base.core.continuous_system_base import ContinuousSystemBase
result = system.simulate(x0, controller=controller, t_span=(0, 10), dt=0.01)
# Plot states and controls
fig = system.plotter.plot_state_and_control(
result['time'],
result['states'],
result['controls'],
state_names=['θ (rad)', 'ω (rad/s)'],
control_names=['Torque (N⋅m)']
)
fig
```
### Comparing Multiple Designs {#sec-comparing-designs}
Compare different control strategies:
```{python}
#| label: demo-comparison
#| fig-cap: "Comparison of different control strategies"
# Simulate with different controllers
# Note: All must use same time grid for comparison
controllers = {
'P (K=1)': lambda x, t: np.array([-1.0 * x[0] - 0.5 * x[1]]),
'P (K=5)': lambda x, t: np.array([-5.0 * x[0] - 2.0 * x[1]]),
'No Control': None
}
# Use common dt for all simulations
dt = 0.01
trajectories = {}
t_common = np.arange(0, 10 + dt, dt)
for name, ctrl in controllers.items():
# Use simulate() to ensure regular time grid
result = system.simulate(x0, controller=ctrl, t_span=(0, 10), dt=dt)
trajectories[name] = result['states']
# Compare trajectories (all have same time grid)
fig = system.plotter.plot_comparison(
t_common,
trajectories,
state_names=['θ (rad)', 'ω (rad/s)']
)
fig
```
::: {.callout-important}
## Regular Time Grids Required for Comparison
When comparing multiple trajectories, **all must use the same time grid**. Use `simulate()` with the same `dt` for all runs, or use `integrate()` with the same `t_eval` array.
```python
# ✓ CORRECT: Same time grid
for name, ctrl in controllers.items():
result = system.simulate(x0, controller=ctrl, t_span=(0, 10), dt=0.01)
# ❌ WRONG: Different adaptive grids
for name, ctrl in controllers.items():
result = system.integrate(x0, u=ctrl, t_span=(0, 10))
# Different number of time points! Cannot compare directly.
```
:::
## PhasePortraitPlotter: State-Space Visualization {#sec-phase-portrait}
### 2D Phase Portrait {#sec-2d-phase}
Visualize dynamics in state space:
```{python}
#| label: demo-phase-2d
#| fig-cap: "2D phase portrait showing state-space trajectory"
# Simulate from multiple initial conditions (use simulate for regular grids)
initial_conditions = [
np.array([0.3, 0.0]),
np.array([1.0, 0.0]),
np.array([1.5, 0.5]),
]
trajectories = []
for x0_i in initial_conditions:
result = system.simulate(x0_i, controller=None, t_span=(0, 15), dt=0.01)
trajectories.append(result['states'])
x_all = np.stack(trajectories) # (n_traj, T, nx)
# Plot phase portrait
fig = system.phase_plotter.plot_2d(
x_all,
state_names=('θ (rad)', 'ω (rad/s)'),
equilibria=[np.array([0, 0])],
show_direction=True
)
fig
```
### With Vector Field {#sec-vector-field}
Add vector field to show dynamics everywhere:
```{python}
#| label: demo-phase-vectorfield
#| fig-cap: "Phase portrait with vector field overlay"
# Define vector field function
def pendulum_field(theta, omega):
"""Compute derivatives at any point in phase space."""
x = np.array([theta, omega])
dx = system(x, u=np.zeros(1))
return dx[0], dx[1]
# Plot with vector field
fig = system.phase_plotter.plot_2d(
x_all,
state_names=('θ (rad)', 'ω (rad/s)'),
vector_field=pendulum_field,
equilibria=[np.array([0, 0]), np.array([np.pi, 0])],
show_direction=True
)
fig
```
## ControlPlotter: Control Analysis {#sec-control-plotter}
### Eigenvalue Map {#sec-eigenvalue-map}
Visualize closed-loop stability with comparison:
```{python}
#| label: demo-eigenvalue-map
#| fig-cap: "Eigenvalue map comparing open-loop and closed-loop poles"
# Get linearization at origin
x_eq, u_eq = system.get_equilibrium('origin')
A, B = system.linearize(x_eq, u_eq)
# Design LQR controller
Q = np.diag([10, 1])
R = np.array([[0.1]])
lqr_result = system.control.design_lqr(A, B, Q, R, system_type='continuous')
# Plot eigenvalues using dictionary format (recommended for comparison)
eigenvalues_ol = np.linalg.eigvals(A)
eigenvalues_cl = lqr_result['controller_eigenvalues']
eigenvalue_sets = {
'Open-loop': eigenvalues_ol,
'Closed-loop (LQR)': eigenvalues_cl,
}
fig = system.control_plotter.plot_eigenvalue_map(
eigenvalue_sets,
system_type='continuous',
color_scheme='colorblind_safe',
theme='publication'
)
fig
```
::: {.callout-tip}
## Comparing Eigenvalue Sets
The `plot_eigenvalue_map()` method supports multiple input formats:
**Dictionary format (recommended for comparison):**
```python
eigenvalue_sets = {
'Open-loop': eigs_ol,
'Closed-loop (LQR)': eigs_cl,
}
fig = system.control_plotter.plot_eigenvalue_map(eigenvalue_sets)
```
**Concatenated with per-eigenvalue labels:**
```python
eigs_all = np.concatenate([eigs_ol, eigs_cl])
labels = ['Open-loop'] * len(eigs_ol) + ['Closed-loop'] * len(eigs_cl)
fig = system.control_plotter.plot_eigenvalue_map(eigs_all, labels=labels)
```
**Single set:**
```python
fig = system.control_plotter.plot_eigenvalue_map(eigs_cl, labels='LQR')
```
Each set gets a unique color and marker symbol for easy distinction!
:::
## Stochastic System Visualization {#sec-stochastic-viz}
### Comparing Noise Levels {#sec-noise-comparison}
Visualize impact of different noise intensities:
```{python}
#| label: demo-noise-comparison
#| fig-cap: "Effect of noise intensity on pendulum dynamics"
# Create systems with different noise levels
noise_levels = [0.1, 0.3, 0.5]
trajectories_noise = {}
# Use common time grid for comparison
dt = 0.01
t_common = np.arange(0, 15 + dt, dt)
for sigma in noise_levels:
sys_i = ContinuousStochasticPendulum(g=9.81, L=1.0, b=0.5, sigma=sigma)
sys_i.set_default_backend('torch')
x0 = np.array([0.3, 0.0])
result = sys_i.integrate(
x0, u=None, t_span=(0, 15),
method='euler', dt=dt, seed=42
)
# Convert to numpy for plotting
x_np = result['x'].cpu().numpy() if hasattr(result['x'], 'cpu') else result['x']
trajectories_noise[f'σ={sigma}'] = x_np
# Compare trajectories (pass t and trajectories separately)
fig = stochastic_system.plotter.plot_comparison(
t_common,
trajectories_noise,
state_names=['θ (rad)', 'ω (rad/s)']
)
fig.update_layout(title='Effect of Noise Intensity on Stochastic Pendulum')
fig
```
::: {.callout-tip}
## Noise Impact
Notice how increasing noise intensity ($\sigma$):
- Creates larger amplitude fluctuations
- Can induce transitions over energy barriers
- Affects regularity of oscillations
- Changes effective damping behavior
At moderate noise levels, the system exhibits **stochastic resonance** - an optimal noise level that actually enhances response to periodic forcing.
:::
### Stochastic Phase Portrait {#sec-stochastic-phase}
Phase portraits reveal noise-induced behavior:
```{python}
#| label: demo-stochastic-phase
#| fig-cap: "Phase portrait of stochastic pendulum showing noise-induced wandering"
# Switch to torch for SDE integration
stochastic_system.set_default_backend('torch')
# Multiple stochastic trajectories from same initial condition
n_trajectories = 3
stochastic_trajectories = []
for i in range(n_trajectories):
result = stochastic_system.integrate(
np.array([np.pi/2, 0.0]),
u=None,
t_span=(0, 20),
method='euler',
dt=0.01,
seed=i
)
# Convert to numpy
x_np = result['x'].cpu().numpy() if hasattr(result['x'], 'cpu') else result['x']
stochastic_trajectories.append(x_np)
x_stochastic = np.stack(stochastic_trajectories)
# Reset backend
stochastic_system.set_default_backend('numpy')
# Plot stochastic phase portrait
fig = stochastic_system.phase_plotter.plot_2d(
x_stochastic,
state_names=('θ (rad)', 'ω (rad/s)'),
equilibria=[np.array([0, 0])],
show_direction=True
)
fig.update_layout(title='Stochastic Pendulum Phase Portrait (Same IC, Different Noise)')
fig
```
::: {.callout-note}
## Stochastic vs Deterministic Phase Portraits
Unlike deterministic systems where each initial condition produces one unique trajectory, stochastic systems produce a **cloud of possible trajectories** from the same initial condition. This visualization shows:
- **Trajectory spreading** due to random forcing
- **Noise-induced wandering** in phase space
- **Probabilistic barrier crossing** (not deterministic separatrix)
- **Effective diffusion** in state space
The phase portrait becomes a probability distribution rather than a single curve!
:::
## ControlPlotter: Control Analysis {#sec-control-plotter-continued}
### Gain Comparison {#sec-gain-comparison}
Compare feedback gains for different designs:
```{python}
#| label: demo-gain-comparison
#| fig-cap: "Heatmap comparing LQR gains for different Q weights"
# Design LQR with different Q weights
Q_values = [1, 10, 100]
gains = {}
for q in Q_values:
Q_i = q * np.diag([10, 1])
result = system.control.design_lqr(A, B, Q_i, R, system_type='continuous')
gains[f'Q={q}'] = result['gain']
# Plot gain comparison
fig = system.control_plotter.plot_gain_comparison(
gains,
labels=['θ', 'ω']
)
fig
```
### Step Response {#sec-step-response}
Analyze closed-loop performance:
```{python}
#| label: demo-step-response
#| fig-cap: "Step response showing rise time, overshoot, and settling time"
# Create closed-loop system with reference tracking
from scipy import signal
# Output: measure angle only
C = np.array([[1, 0]])
D = np.zeros((1, 1))
# Closed-loop dynamics with state feedback u = -Kx
A_cl = A - B @ lqr_result['gain']
# For proper reference tracking, we need feedforward compensation.
# With u = -Kx + K_r*r, the steady-state output tracks the reference r.
# K_r is chosen so that y_ss = r, which requires: K_r = -1/(C @ inv(A_cl) @ B)
dc_gain = C @ np.linalg.inv(A_cl) @ B
K_r = -1.0 / dc_gain[0, 0]
# Create tracking system: ẋ = A_cl*x + B*K_r*r, y = Cx
# When r is a unit step, the input to the system is B*K_r
B_ref = B * K_r
sys_tracking = signal.StateSpace(A_cl, B_ref, C, D)
# Step response (now properly tracks the reference)
t_step = np.linspace(0, 5, 500)
t_out, y_out = signal.step(sys_tracking, T=t_step)
# Plot with metrics
fig = system.control_plotter.plot_step_response(
t_out,
y_out.squeeze(),
reference=1.0,
show_metrics=True
)
fig
```
::: {.callout-note}
## Reference Tracking with State Feedback
LQR designs a state feedback controller `u = -Kx` that regulates the state to zero. To track a non-zero reference, we add **feedforward compensation**:
$$u = -Kx + K_r \cdot r$$
where $K_r$ is chosen to achieve zero steady-state error. For the closed-loop system $\dot{x} = A_{cl}x + BK_r r$ with output $y = Cx$, the DC gain must equal 1:
$$K_r = -\frac{1}{C A_{cl}^{-1} B}$$
This ensures the output tracks step references with the stability and transient performance designed by LQR.
:::
## Publication-Ready Output {#sec-publication-ready}
### Publication Theme {#sec-pub-theme}
Switch to publication theme for papers:
```{python}
#| label: demo-publication-theme
#| fig-cap: "Same plot with publication theme (serif fonts, clean styling)"
# Get a fresh result for this example
x0_pub = np.array([0.5, 0.0])
result_pub = system.simulate(x0_pub, controller=None, t_span=(0, 10), dt=0.01)
# Same trajectory, publication theme
fig = system.plotter.plot_trajectory(
result_pub['time'],
result_pub['states'],
state_names=['θ (rad)', 'ω (rad/s)'],
title='Pendulum Dynamics',
theme='publication',
color_scheme='colorblind_safe'
)
fig
```
### Exporting Figures {#sec-exporting}
Plotly figures can be exported in multiple formats:
```python
# Export as HTML (interactive)
fig.write_html('pendulum_trajectory.html')
# Export as static image (requires kaleido)
fig.write_image('pendulum_trajectory.pdf', width=800, height=600)
fig.write_image('pendulum_trajectory.png', width=800, height=600, scale=2)
# Export as SVG (vector graphics)
fig.write_image('pendulum_trajectory.svg')
```
## Design Patterns {#sec-design-patterns}
### Pattern 1: Centralized Theming {#sec-pattern-theming}
All plotters use centralized themes:
```python
# ❌ BAD: Hardcoded colors
class BadPlotter:
def plot(self):
fig.add_trace(go.Scatter(line=dict(color='#1f77b4')))
# ✓ GOOD: Centralized theming
class GoodPlotter:
def plot(self, color_scheme='plotly', theme='default'):
colors = ColorSchemes.get_colors(color_scheme)
fig.add_trace(go.Scatter(line=dict(color=colors[0])))
fig = PlotThemes.apply_theme(fig, theme)
```
### Pattern 2: Backend Agnostic {#sec-pattern-backend}
Automatically converts PyTorch/JAX to NumPy:
```python
def plot_trajectory(self, t, x):
# Convert to NumPy for Plotly
t_np = self._to_numpy(t)
x_np = self._to_numpy(x)
# Plot with Plotly (requires NumPy)
fig = go.Figure()
fig.add_trace(go.Scatter(x=t_np, y=x_np[:, 0]))
return fig
def _to_numpy(self, arr):
if hasattr(arr, 'cpu'): # PyTorch
return arr.cpu().numpy()
elif hasattr(arr, '__array__'): # JAX
return np.array(arr)
return arr
```
### Pattern 3: Adaptive Layouts {#sec-pattern-adaptive}
Optimal subplot arrangements:
```python
def _determine_subplot_layout(self, n_plots: int) -> Tuple[int, int]:
"""
Determine optimal subplot grid.
1-2 plots: (n, 1) vertical
3-4 plots: (2, 2) square
5-6 plots: (2, 3)
7-9 plots: (3, 3)
"""
if n_plots <= 2:
return n_plots, 1
elif n_plots <= 4:
return 2, 2
else:
cols = int(np.ceil(np.sqrt(n_plots)))
rows = int(np.ceil(n_plots / cols))
return rows, cols
```
### Pattern 4: Batch Detection {#sec-pattern-batch}
Automatically detect and process batched data:
```python
def _process_batch(self, x: np.ndarray):
if x.ndim == 3: # (n_batch, T, nx)
return np.mean(x, axis=0), np.std(x, axis=0), True
elif x.ndim == 2: # (T, nx)
return x, None, False
else:
raise ValueError(f"Invalid shape: {x.shape}")
```
## Integration with Systems {#sec-integration}
### Property-Based Access (Recommended) {#sec-property-access}
All systems provide plotter properties for convenient access:
```python
# ✓ Access via system properties (recommended)
system.plotter # TrajectoryPlotter instance
system.phase_plotter # PhasePortraitPlotter instance
system.control_plotter # ControlPlotter instance
# Convenience method
system.plot(result) # Quick trajectory plot
```
::: {.callout-warning}
## Do Not Instantiate Plotters Directly
Plotters are **internal framework components** that should be accessed through system properties, not instantiated directly:
```python
# ❌ WRONG: Direct instantiation
from cdesym.visualization.trajectory_plotter import TrajectoryPlotter
from cdesym.visualization.phase_portrait import PhasePortraitPlotter
from cdesym.visualization.control_plots import ControlPlotter
plotter = TrajectoryPlotter(backend='numpy') # Don't do this!
phase = PhasePortraitPlotter(backend='numpy') # Don't do this!
control = ControlPlotter(backend='numpy') # Don't do this!
# ✓ CORRECT: Access via system
system = Pendulum()
fig = system.plotter.plot_trajectory(t, x)
fig = system.phase_plotter.plot_2d(x)
fig = system.control_plotter.plot_eigenvalue_map(eigs)
```
The system automatically creates and configures plotters with the correct backend. Direct instantiation is only needed for:
- Framework development
- Testing plotter components in isolation
- Advanced customization outside the system context
:::
### Typical Workflow {#sec-typical-workflow}
```python
# 1. Create system
system = Pendulum()
# 2. Simulate
result = system.simulate(x0, controller, t_span=(0, 10))
# 3. Visualize (plotters accessed through system)
fig = system.plotter.plot_trajectory(
result['time'],
result['states'],
theme='publication'
)
fig
```
## Available Plot Types {#sec-plot-types}
### Summary Table {#sec-plot-summary}
| Plotter | Method | Purpose |
|---------|--------|---------|
| **TrajectoryPlotter** | | |
| | `plot_trajectory()` | States vs time |
| | `plot_state_and_control()` | States + controls |
| | `plot_comparison()` | Compare multiple runs |
| **PhasePortraitPlotter** | | |
| | `plot_2d()` | 2D phase portrait |
| | `plot_3d()` | 3D phase portrait |
| | `plot_limit_cycle()` | Periodic orbits |
| **ControlPlotter** | | |
| | `plot_eigenvalue_map()` | Stability analysis |
| | `plot_gain_comparison()` | Compare gains |
| | `plot_step_response()` | Step response metrics |
| | `plot_impulse_response()` | Impulse response |
| | `plot_frequency_response()` | Bode plots |
| | `plot_nyquist()` | Nyquist diagram |
| | `plot_root_locus()` | Root locus |
| | `plot_controllability_gramian()` | Gramian heatmap |
| | `plot_observability_gramian()` | Gramian heatmap |
| | `plot_riccati_convergence()` | Solver convergence |
## Key Strengths {#sec-key-strengths}
::: {.callout-tip}
## Framework Advantages
1. **Centralized Theming** - Single source of truth for colors/styles
2. **Backend Agnostic** - NumPy/PyTorch/JAX transparent
3. **Interactive** - Plotly-based with zoom, pan, hover
4. **Publication Ready** - Professional defaults and themes
5. **Accessible** - Colorblind-safe palettes
6. **Specialized Plotters** - Right tool for each visualization type
7. **System Integration** - Clean `system.plotter` APIs
8. **Adaptive Layouts** - Optimal subplot arrangements
9. **Batch Support** - Monte Carlo visualization automatic
10. **Comprehensive** - 16+ plot types covering all needs
:::
## Summary
The visualization framework provides **publication-quality interactive plotting** seamlessly integrated with ControlDESymulation's dynamical systems. All plots are:
- ✅ Interactive with Plotly
- ✅ Themeable for different contexts
- ✅ Backend-agnostic
- ✅ Automatically adaptive
- ✅ Publication-ready
::: {.callout-important}
## Usage Reminder
Access visualization through system properties:
```python
system.plotter # Time-domain plots
system.phase_plotter # Phase space plots
system.control_plotter # Control analysis plots
```
**Do not directly instantiate** `TrajectoryPlotter`, `PhasePortraitPlotter`, or `ControlPlotter` classes—the system handles this automatically with proper configuration.
:::