-
Notifications
You must be signed in to change notification settings - Fork 1
headplot func w test runner #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ngup1
wants to merge
5
commits into
main
Choose a base branch
from
feature/nilesh/head-plot
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| ## 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.