Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions docs/howtos/head_plot_gui_integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Head Plot GUI Integration

## Overview
Head plots are now integrated into the GraphViewerScene and appear automatically in the GUI when calculations complete, following the same pattern as dot plots. Both use the DataFrame directly for optimal performance.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Is there a specific reason head plots are separated from the main GraphViewerScene workflow?


## Implementation Details

### Configuration Flag
Head plots are controlled by the `show_head_plot` flag in the config's `shown_outputs` section:

```json
{
"shown_outputs": {
"show_head_plot": true
}
}
```

### Integration Points

#### GraphViewerScene.py
- **HEAD_PLOT_SPEC**: Configuration dict defining:
- `flag`: Config key to enable head plots (`show_head_plot`)
- `title_prefix`: Display name prefix for generated plots
- `required_df_cols`: Required DataFrame columns (`HeadYaw`, `LF_Angle`, `RF_Angle`)
- `settings_key`: Config section for head plot settings (`head_plot_settings`)
- `default_settings`: Fallback values for plot parameters

- **build_head_plot_graphs()**: New function that:
1. Checks if `show_head_plot` flag is enabled
2. Validates required DataFrame columns are present
3. Extracts data directly from DataFrame using `_as_numeric_array()` helper
4. Gets time ranges from config (defaults to full dataset if not specified)
5. Calls `plot_head()` with `ctx=None` (no file output in GUI mode)
6. Returns dict of Plotly figures with descriptive names

- **set_data()**: Updated to:
- Build both dot plots and head plots from the same DataFrame
- Combine warnings from both builders
- Display all graphs in the sidebar list

### Data Flow (Optimized)
1. **Calculation Scene** completes and emits payload:
```python
{
"results_df": pandas.DataFrame, # Contains all necessary columns
"config": dict
}
```

2. **GraphViewerScene.set_data()** receives payload and:
- Calls `build_dot_plot_graphs(results_df, config)`
- Calls `build_head_plot_graphs(results_df, config)` ← **Optimized: uses DataFrame directly**
- Merges results and populates graph list

3. **User selects graph** from sidebar → rendered as PNG via kaleido

### Performance Optimization
**Key improvement**: Head plots now extract data directly from the DataFrame instead of reloading the CSV file:
- ✅ **No redundant file I/O**: Eliminates CSV re-read
- ✅ **No redundant parsing**: Avoids re-creating GraphDataLoader/GraphDataBundle
- ✅ **Lower memory footprint**: No duplicate data structures
- ✅ **Faster GUI response**: Data already in memory from calculations
- ✅ **Consistent pattern**: Both plot types use DataFrame extraction

### Head Plot Behavior
- When `split_plots_by_bout=true` (default):
- Multiple figures generated, one per time range
- Named: "Head Plot: head_plot_range_[start,_end]"
- When `split_plots_by_bout=false`:
- Single combined figure
- Named: "Head Plot"

### Error Handling
Graceful degradation with warning messages:
- **Flag disabled**: Head plot not built (silent)
- **Missing DataFrame columns**: Warning tooltip listing missing columns (e.g., "HeadYaw")
- **Empty DataFrame**: Warning: "Head plot skipped: DataFrame is empty."
- **Plot generation failure**: Warning with exception message

### Settings Integration
Head plot settings from config are merged with defaults:
```python
{
"head_plot_settings": {
"plot_draw_offset": 15,
"ignore_synchronized_fin_peaks": True,
"sync_fin_peaks_range": 3,
"fin_peaks_for_right_fin": False,
"split_plots_by_bout": True
},
"graph_cutoffs": {
"left_fin_angle": 10,
"right_fin_angle": 10
}
}
```

## Testing
Four test cases in `tests/unit/ui/test_graph_viewer_scene.py`:
1. **test_set_data_generates_requested_dot_plots**: Verifies dot plots still work
2. **test_set_data_skips_head_plot_when_flag_disabled**: Confirms head plots aren't generated when flag is false
3. **test_set_data_warns_when_head_plot_missing_required_columns**: Validates warning when DataFrame columns are missing
4. **test_set_data_generates_head_plot_when_all_data_present**: Confirms head plot generation with complete data

## Usage
1. Enable head plots in your config JSON:
```json
"shown_outputs": { "show_head_plot": true }
```
2. Run calculations in the GUI
3. Head plots appear automatically in the graph viewer sidebar alongside dot plots
4. Click to view static PNG renderings

## Dependencies
- **plotly**: For figure generation
- **kaleido**: For PNG export (required for GUI rendering)
- **plot_head**: Core plotting function from `src.core.graphs.plots.headplot`
- **pandas/numpy**: For DataFrame manipulation and array operations

## Alignment with Dotplot Pattern
Following `dotplot_gui_plan.md`:
- ✅ Emit richer payloads (DataFrame contains all necessary data)
- ✅ Interpret config flags inside GraphViewerScene
- ✅ Extract data directly from DataFrame (no file I/O on GUI path)
- ✅ Call plotting function directly with `ctx=None`
- ✅ Populate scene with returned figures
- ✅ Surface runtime issues via tooltips and empty-state messages
- ✅ Keep responsive by avoiding redundant data loading

## Architecture Decision: DataFrame-Based Approach

### Why Extract from DataFrame?
The initial implementation reloaded the CSV file via `GraphDataLoader`, but this was redundant since:
- `leftFinAngles` = DataFrame column `LF_Angle`
- `rightFinAngles` = DataFrame column `RF_Angle`
- `headYaw` = DataFrame column `HeadYaw`

### Comparison

| Aspect | Old Approach (CSV Reload) | New Approach (DataFrame) |
|--------|---------------------------|--------------------------|
| Data Source | Reload CSV via GraphDataLoader | Extract from existing DataFrame |
| File I/O | Re-reads entire CSV | None - data in memory |
| Memory | Duplicate loader/bundle objects | Single DataFrame reference |
| Performance | Slower (disk I/O + parsing) | Faster (memory access only) |
| Coupling | Requires csv_path in payload | Only needs DataFrame |
| Consistency | Different from dot plots | Same pattern as dot plots |

### Benefits of Current Implementation
1. **Performance**: No redundant file I/O or parsing
2. **Simplicity**: Both plot types use same DataFrame extraction pattern
3. **Maintainability**: Less coupling to file system paths
4. **Memory efficiency**: No duplicate data structures
5. **GUI responsiveness**: Data already available from calculations

## Future Enhancements
- Add integration test with real calculation pipeline
- Support dynamic plot refresh on config change
- Add export-to-file button in GUI for saving plots manually
- Consider adding time range editor for interactive bout selection
Empty file.
207 changes: 207 additions & 0 deletions src/core/graphs/plots/headplot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
from __future__ import annotations

import math
import os
import webbrowser
from dataclasses import dataclass
from typing import Sequence, Optional

import plotly.graph_objects as go

from ..io import OutputContext


@dataclass(frozen=True)
class HeadPlotResult:
"""Metadata returned after rendering a head plot."""
figures: list[go.Figure]
labels: list[str]
output_base: Optional[str]


def rotate_point(origin: tuple[float, float], point: tuple[float, float], angle_deg: float) -> tuple[float, float]:
"""Rotate a point around an origin by a given angle in degrees."""
ox, oy = origin
px, py = point
angle_rad = math.radians(angle_deg)
qx = ox + math.cos(angle_rad) * (px - ox) - math.sin(angle_rad) * (py - oy)
qy = oy + math.sin(angle_rad) * (px - ox) + math.cos(angle_rad) * (py - oy)
return qx, qy


def plot_head(
head_yaw: Sequence[float],
left_fin_values: Sequence[float],
right_fin_values: Sequence[float],
time_ranges: Sequence[tuple[int, int]],
head_settings: dict,
cutoffs: dict,
ctx: Optional[OutputContext] = None,
open_plot: bool = False,
) -> HeadPlotResult:
"""
Plot head frames at fin peaks with optional tabbed HTML output.
"""
left_fin_cutoff = cutoffs["left_fin_angle"]
right_fin_cutoff = cutoffs["right_fin_angle"]

draw_offset = head_settings["plot_draw_offset"]
remove_synced_peaks = head_settings["ignore_synchronized_fin_peaks"]
sync_time_range = head_settings["sync_fin_peaks_range"]
use_right_side_finpeaks = head_settings["fin_peaks_for_right_fin"]
split_by_bout = head_settings["split_plots_by_bout"]

head_rows_by_bout = []
fin_values = right_fin_values if use_right_side_finpeaks else left_fin_values
cutoff = right_fin_cutoff if use_right_side_finpeaks else left_fin_cutoff
opposing_fin_values = left_fin_values if use_right_side_finpeaks else right_fin_values
opposing_cutoff = left_fin_cutoff if use_right_side_finpeaks else right_fin_cutoff

for start_index, end_index in time_ranges:
current_head_rows = [start_index]
on_peak = False
current_max_val = 0
current_max_index = 0
for i in range(start_index, end_index + 1):
if not on_peak and fin_values[i] > cutoff:
current_max_val = fin_values[i]
current_max_index = i
on_peak = True
elif on_peak and fin_values[i] > cutoff:
if fin_values[i] > current_max_val:
current_max_val = fin_values[i]
current_max_index = i
elif on_peak and fin_values[i] <= cutoff:
valid_point = True
if remove_synced_peaks:
for j in range(max(0, current_max_index - sync_time_range), min(current_max_index + sync_time_range, end_index)):
if opposing_fin_values[j] > opposing_cutoff:
valid_point = False
if valid_point:
current_head_rows.append(current_max_index)
on_peak = False
head_rows_by_bout.append(current_head_rows)

head_x_points = [-0.5, 0, 0.5, 0, 0, 0]
head_y_points = [-1, 0, -1, 0, -5, -10]
origin = (head_x_points[1], head_y_points[1])

figures = []
labels = []
output_base = None

if split_by_bout:
for bout_num, head_rows in enumerate(head_rows_by_bout):
fig = go.Figure()
fig.update_yaxes(scaleanchor="x", scaleratio=1, visible=False)
fig.update_xaxes(constrain="domain", visible=False)
fig.update_layout(showlegend=False, title="Head Plot")

current_offset = 0
for row in head_rows:
new_head_x = []
new_head_y = []
for px, py in zip(head_x_points, head_y_points):
nx, ny = rotate_point(origin, (px, py), -head_yaw[row])
new_head_x.append(nx + current_offset)
new_head_y.append(ny)
current_offset += draw_offset
fig.add_trace(go.Scatter(x=new_head_x, y=new_head_y, mode='lines', line=dict(color="black")))
figures.append(fig)
start, end = time_ranges[bout_num]
label = f"head_plot_range_[{start},_{end}]"
labels.append(label)

if ctx:
html_path = os.path.join(ctx.output_folder, f"{label}.html")
png_path = os.path.join(ctx.output_folder, f"{label}.png")
fig.write_html(html_path)
try:
fig.write_image(png_path)
except Exception as e:
print(f"Warning: Could not write PNG for {label}: {e}")
print("Hint: Upgrade plotly and kaleido: pip install -U plotly kaleido")
output_base = "head_plot"

if ctx:
tabs_html = f"""
<html>
<head>
<style>
body {{ font-family: sans-serif; background-color: #f9f9f9; }}
#tabs {{ margin-bottom: 10px; }}
.tab-button {{ padding: 10px 16px; cursor: pointer; display: inline-block; background-color: #eee; border: 1px solid #ccc; border-bottom: none; margin-right: 5px; border-radius: 6px 6px 0 0; font-weight: bold; }}
.tab-button.active {{ background-color: #fff; border-bottom: 1px solid #fff; }}
.tab {{ display: none; }}
.tab.active {{ display: block; }}
</style>
</head>
<body>
<div id="tabs">
"""
for i, label in enumerate(labels):
active_class = "active" if i == 0 else ""
tabs_html += f'<div class="tab-button {active_class}" onclick="showTab({i})">{label}</div>'
tabs_html += '</div>'
for i, fig in enumerate(figures):
fig_html = fig.to_html(include_plotlyjs=(i == 0), full_html=False)
active_style = "active" if i == 0 else ""
tabs_html += f'<div class="tab {active_style}">{fig_html}</div>'
tabs_html += """
<script>
function showTab(index) {
const tabs = document.getElementsByClassName('tab');
const buttons = document.getElementsByClassName('tab-button');
for (let i = 0; i < tabs.length; i++) {
tabs[i].classList.remove('active');
buttons[i].classList.remove('active');
}
tabs[index].classList.add('active');
buttons[index].classList.add('active');
}
</script>
</body>
</html>
"""
tabs_path = os.path.join(ctx.output_folder, "head_plots_tabbed.html")
with open(tabs_path, 'w', encoding='utf-8') as f:
f.write(tabs_html)
if open_plot:
webbrowser.open(tabs_path)
else:
fig = go.Figure()
fig.update_layout(showlegend=False, title="Head Plot")
current_offset = 0
for head_rows in head_rows_by_bout:
for row in head_rows:
new_head_x = []
new_head_y = []
for px, py in zip(head_x_points, head_y_points):
nx, ny = rotate_point(origin, (px, py), -head_yaw[row])
new_head_x.append(nx + current_offset)
new_head_y.append(ny)
current_offset += draw_offset
fig.add_trace(go.Scatter(x=new_head_x, y=new_head_y, mode='lines', line=dict(color="black")))
if ctx:
html_path = os.path.join(ctx.output_folder, "head_plot_plotly.html")
png_path = os.path.join(ctx.output_folder, "head_plot.png")
fig.write_html(html_path)
try:
fig.write_image(png_path)
except Exception as e:
print(f"Warning: Could not write PNG: {e}")
print("Hint: Upgrade plotly and kaleido: pip install -U plotly kaleido")
output_base = "head_plot"
if open_plot:
fig.show()

return HeadPlotResult(figures=figures, labels=labels, output_base=output_base)









Loading