-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Enable saving/loading of math-derived signals in plotlists #5
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
base: main
Are you sure you want to change the base?
Conversation
This change introduces the capability to save and load plotlists that include signals derived from various math operations, ensuring that the full state of the plotted signals can be reproduced.
Key changes include:
1. **`PlotSpec` Class:** I created a new `PlotSpec` class (`plot_spec.py`) to encapsulate all necessary information to define and reproduce a signal. This includes its name, source (file or math operation), unique ID, and derivation details (expression, operation parameters, input signals' `PlotSpec`s). It includes `to_dict` and `from_dict` methods for serialization.
2. **`MathsWidget` Modifications:**
* `VarInfo` and `DataItem` now store associated `PlotSpec` objects.
* Math operations (expressions, filters, differentiation, etc.) now generate a `PlotSpec` for their output signals, capturing the operation type, parameters, and input signal `PlotSpec`s.
* `MathsWidget` can use inputs from both `var_in` (file signals) and `var_out` (derived signals) for new operations.
* A new method `execute_operation_from_spec` allows `MathsWidget` to re-calculate a signal based on its `PlotSpec` and provided input `DataItem`s during plotlist loading.
3. **`CustomPlotItem` Update:** Now stores a `PlotSpec` for each trace. If a signal is plotted directly from a file, a basic "file" `PlotSpec` is created. If plotted from `MathsWidget`, it uses the detailed `PlotSpec` attached to the `DataItem`.
4. **Plot Saving/Loading Logic:**
* When saving, `PlotSpec` objects are converted to dictionaries via `to_dict()` and stored in the plotlist file.
* When loading, `PlotAreaWidget.generate_plots` uses a new helper `_reproduce_signal`. This function:
* Converts dictionaries back to `PlotSpec` objects.
* Handles "file" type signals by fetching them from the `DataFileWidget`.
* Handles "math" type signals by recursively reproducing input signals and then calling `MathsWidget.execute_operation_from_spec` to re-compute the derived signal.
* Uses a cache to avoid re-computing the same signal multiple times if it's an input to several other signals.
5. **Supporting Changes:**
* `DataModel` and `ThinModelMock` were updated to ensure `DataItem` objects (with their `PlotSpec`s) are correctly retrieved.
* `SubPlotWidget.plot_data_from_source` was adapted to handle plotting of these reproduced `DataItem`s.
* `DataFileWidget` now has a method to look up `DataItem`s by a file identifier and signal name.
This enables you to save complex analysis sessions involving multiple math-derived signals and reliably restore them 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.
Hello @miker2, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!
Summary of Changes
Hello team,
Gemini here, providing a summary of this pull request authored by @miker2.
This PR introduces a significant enhancement to the application's plotting capabilities, specifically enabling the saving and loading of plotlists that include signals derived from mathematical operations performed within the MathsWidget. Previously, only signals loaded directly from files could be reliably saved and restored in plot configurations. This change addresses that limitation by implementing a mechanism to capture the 'recipe' for derived signals.
The core of this feature is the introduction of a PlotSpec object, which encapsulates all the necessary information to uniquely identify and reproduce a signal, whether it originates from a file or is the result of a mathematical transformation. This PlotSpec is now associated with DataItems and CustomPlotItems. The saving process serializes these PlotSpecs, and the loading process uses them to either retrieve the original file data or re-execute the necessary math operations, including recursively reproducing any input signals required for a derived signal.
Highlights
- New PlotSpec Class: A new class
PlotSpecis introduced (plot_spec.py) to store metadata about a signal, including its source type ('file' or various 'math' types), unique ID, file details (if applicable), and math operation details (expression, parameters, input PlotSpecs) if it's a derived signal. It includesto_dictandfrom_dictmethods for serialization. - DataItem and VarInfo Updates: The
DataItemclass (data_model.py) and the internalVarInfodataclass (maths_widget.py) are updated to include aplot_specattribute to store the associatedPlotSpecobject. - MathsWidget Integration: The
MathsWidget(maths_widget.py) is modified to generate and attachPlotSpecobjects to theDataItems it produces from mathematical operations (expressions, filters, diff/int, running window/minmax). It also gains a new methodexecute_operation_from_specto re-compute a signal based on a providedPlotSpecand its inputs during loading. - CustomPlotItem Enhancement: The
CustomPlotItem(custom_plot_item.py) now stores thePlotSpecfor the trace it represents. When a signal is plotted, thePlotSpecis either provided (for derived signals) or a default 'file'PlotSpecis created. - Plot Saving/Loading Logic: The plot saving mechanism (
sub_plot_widget.py) is updated to serialize thePlotSpecof each trace to a dictionary. The loading logic (plot_manager.py) introduces a recursive helper function_reproduce_signalthat uses the savedPlotSpecdictionaries to either fetch file-based signals viaDataFileWidgetor re-calculate math-derived signals using the newMathsWidget.execute_operation_from_specmethod, utilizing a cache to avoid redundant computations. - DataFileWidget Lookup: The
DataFileWidget(data_file_widget.py) is updated to store references to itsVarListWidgetinstances and includes a new methodget_data_item_by_file_id_and_nameto facilitate retrieving originalDataItems based on the file identifier stored in thePlotSpec.
Changelog
Click here to see the changelog
- custom_plot_item.py
- Import
PlotSpecandTYPE_CHECKING(lines 17-23) - Add
plot_spec_from_sourceparameter to__init__(line 29) - Initialize
self.plot_spec, creating a default 'file' PlotSpec ifplot_spec_from_sourceis None (lines 74-97) - Update
get_plot_specto return the storedself.plot_specobject (line 157)
- Import
- data_file_widget.py
- Add
self.var_listslist to trackVarListWidgetinstances (line 47) - Store
VarListWidgetinstance directly inself.sourcesand add it toself.var_listswhen opening a file (lines 76-78) - Update
close_fileto remove the closed widget fromself.var_listsandself.sources, and reset slider limits if no files remain (lines 94-105) - Modify
get_data_file_by_nameto use.get()for safer access (line 121) - Add
get_data_item_by_file_id_and_namemethod to retrieveDataItems from loaded files using file identifier and signal name (lines 123-157) - Add validity check for
idxinget_time(lines 166-169)
- Add
- data_model.py
- Import
PlotSpec(line 7) - Add
plot_specparameter toDataItem.__init__and store it (line 15-18) - Add
plot_specproperty and setter toDataItem(lines 33-39) - Update
get_data_by_nameto return theDataItemobject and ensure a 'file'PlotSpecis created if missing for file-loaded items (lines 113-138)
- Import
- maths/diff_int.py
- Add
get_operation_detailsandget_source_typemethods toDifferentiateSpec(lines 22-27) - Add
get_operation_detailsandget_source_typemethods toIntegrateSpec(lines 46-51)
- Add
- maths/filter.py
- Add
get_operation_detailsandget_source_typemethods toFilterSpec(lines 87-98)
- Add
- maths/maths_base.py
- Import
PlotSpec(line 10) - Modify
eventFilterto create and assign aPlotSpecto the outputDataItemfor math operations, capturing input specs and operation details (lines 81-112) - Update
add_new_varsignature to require and store theplot_spec(line 112, 115)
- Import
- maths/running_minmax.py
- Add
get_operation_detailsandget_source_typemethods toRunningMinMaxSpec(lines 113-123)
- Add
- maths/running_window.py
- Import
WindowTypes(line 19) - Add
get_operation_detailsandget_source_typemethods toRunningWindowSpec(lines 114-124)
- Import
- maths_widget.py
- Import
PlotSpec,WindowTypes,MinMaxType(lines 19-24) - Add
plot_specattribute toVarInfodataclass (line 63) - Modify
dropEventto create and store a 'file'PlotSpecfor dropped items inVarInfo(lines 195-205) - Modify
evaluate_mathto create and assign a 'math_expr'PlotSpecto the outputDataItemand pass it toadd_new_var(lines 260-295) - Update
add_new_varsignature to require and store theplot_specinVarInfo(line 297, 305) - Modify
start_dragto ensure theDataItembeing pickled has itsPlotSpecset from theVarInfo(lines 317-327) - Add
execute_operation_from_specmethod to reproduce math operations based on aPlotSpecand inputDataItems (lines 381-510)
- Import
- plot_manager.py
- Import
PlotSpec,DataItem,TYPE_CHECKING,Dict,Any,MathsWidget,DataFileWidget(lines 9-15) - Add
_reproduce_signalrecursive helper method to reconstructDataItems fromPlotSpecdictionaries, handling 'file' and 'math' sources and using a cache (lines 309-414) - Modify
generate_plotsto use_reproduce_signalto obtainDataItems for plotting, passing necessary widget references and a cache (lines 440-496) - Update
generate_plotsto pass theDataItem, a source reference, and a flag tosub_plot_widget.plot_data_from_source(lines 491-494) - Update
generate_plotsto useplot_dictvariable name consistently (lines 451, 499)
- Import
- plot_spec.py
- New file defining the
PlotSpecclass with attributes for signal metadata andto_dict/from_dictmethods (lines 1-46)
- New file defining the
- sub_plot_widget.py
- Import
PlotSpec,DataItem(lines 9-10) - Modify
plot_data_from_sourcesignature to acceptname_or_data_item,source_or_none, andis_reproduced_itemflag (line 125) - Update
plot_data_from_sourcelogic to handle plotting either from a name/source pair (original) or aDataItem(reproduced), ensuringPlotSpec, data, and time are correctly obtained (lines 133-183) - Pass the obtained
PlotSpecto theCustomPlotItemconstructor (lines 194-196) - Update
onClosesignal connection logic to handle the potentially differentsource_for_custom_itemand add error handling/warnings (lines 201-207) - Update
get_plot_infoto call.to_dict()on thePlotSpecobjects before adding them to the plot info dictionary (line 257)
- Import
Using Gemini Code Assist
The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.
Invoking Gemini
You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.
| Feature | Command | Description |
|---|---|---|
| Code Review | /gemini review |
Performs a code review for the current pull request in its current state. |
| Pull Request Summary | /gemini summary |
Provides a summary of the current pull request in its current state. |
| Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments. |
| Help | /gemini help |
Displays a list of available commands. |
Customization
To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.
Limitations & Feedback
Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments to provide feedback.
You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.
A signal derived,
Its recipe saved,
Through math it's revived,
On the plot, it's engraved.
Footnotes
-
Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩
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.
Code Review
This pull request introduces a very valuable feature for saving and loading plotlists, especially those with math-derived signals. The new PlotSpec class and the associated logic for signal reproduction are well-designed and address a complex problem. The changes are extensive and show a good understanding of the application's architecture.
I've identified a few areas for potential improvement and one likely bug. Overall, this is a strong contribution.
Summary of Findings
- Potential Bug in Plot Y-Range Setting: In
plot_manager.py,generate_plotsmethod, there's a reference toplot["yrange"]afterplotwas likely renamed toplot_dict. This could lead to aNameError. - Ad-hoc Attribute Assignment: The dynamic assignment of
idxtoVarListWidgetinstances indata_file_widget.pycould be made more explicit by definingidxinVarListWidget's__init__. - Placeholder Identifiers: Fallback string identifiers like
"unknown_source_widget"are used. Consider defining these as shared constants if their usage grows. - Robustness of Math Operation Reproduction: The
execute_operation_from_specinmaths_widget.pyis complex. While it appears to correctly reimplement operations, continued testing for edge cases is important. Error handling viaprintcould be enhanced with custom exceptions or logging. - Clarity of Refactored Methods: Methods like
SubPlotWidget.plot_data_from_sourcehave become more complex. Adding docstrings would improve maintainability.
Merge Readiness
This pull request introduces a significant and valuable feature. The implementation is largely robust and well-thought-out.
Before merging, I recommend addressing the potential NameError in plot_manager.py (marked as 'high' severity). The 'medium' severity comments are suggestions for improving maintainability and robustness, and can be addressed now or in follow-up work at the author's discretion.
As a reviewer, I am not authorized to approve pull requests. Please ensure further review and approval from authorized maintainers before merging.
| if clear_existing and "yrange" in plot.keys(): | ||
| if clear_existing and "yrange" in plot_dict.keys(): # Use plot_dict | ||
| # Don't mess up the y-range if plots are being appended. | ||
| subplot.set_y_range(*plot["yrange"]) |
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.
| file_id = "unknown_source_widget" # Default file identifier | ||
|
|
||
| # Attempt to get a more specific file_id from the source | ||
| # source is typically VarListWidget or MathsWidget (or their models) | ||
| if hasattr(source, 'filename') and source.filename: # Check for direct filename (e.g. VarListWidget) | ||
| file_id = source.filename | ||
| elif hasattr(source, 'idx') and source.idx is not None: # Check for direct idx (e.g. VarListWidget) | ||
| file_id = str(source.idx) | ||
| # If source is MathsWidget, it's more complex as it doesn't have a single 'filename' or 'idx' | ||
| # However, if plot_spec_from_source is None, it implies the data isn't from a known math operation. | ||
| # This logic assumes that if data comes from MathsWidget, plot_spec_from_source should be set. | ||
|
|
||
| self.plot_spec = PlotSpec( | ||
| name=name, | ||
| source_type="file", # Default to "file" if not otherwise specified | ||
| original_name=name, # Assume original_name is same as current name if not specified | ||
| file_source_identifier=file_id |
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.
The use of placeholder strings like "unknown_source_widget" for file_id when a more specific identifier can't be found is a reasonable fallback. However, if these kinds of identifiers become more common or need to be managed more strictly across different parts of the application (e.g., here and in DataModel), would it be worth defining them as constants in a shared module to ensure consistency and avoid typos?
| tab_idx = self.tabs.addTab(var_list, tab_name) # addTab returns the index | ||
| # self.sources[filepath] = self.tabs.widget(self.tabs.count() - 1) # Old way | ||
| self.sources[filepath] = var_list # Store the instance directly | ||
| var_list.idx = tab_idx # Assign tab index as an ad-hoc idx if needed for PlotSpec compatibility |
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.
Assigning var_list.idx = tab_idx dynamically adds an attribute to VarListWidget instances. While this works for PlotSpec compatibility, it can make the VarListWidget class contract less explicit. If idx is a consistently needed piece of information for VarListWidget, would it be clearer to formally add it as an optional parameter in its __init__ method and store it as a regular instance attribute?
| if self.tabs.count() > 0: | ||
| self._update_range_slider() | ||
| else: # Reset slider if no files are open | ||
| self.controller.plot_manager.update_slider_limits(0, 1.e9) |
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.
The value 1.e9 is used here to reset the slider limits when no files are open. If this large default maximum is used in other places or has a specific meaning (e.g., 'effectively infinite for practical purposes'), could defining it as a named constant (e.g., DEFAULT_SLIDER_MAX_RANGE) improve readability and maintainability?
| file_id = "unknown_data_model_source" | ||
| # A better approach would be if data_loader.source (filename) was stored in DataModel | ||
| # and accessible here, or if DataItem was initialized with it. | ||
| # Let's assume self.filename could exist if DataModel was enhanced. | ||
| # if hasattr(self, 'filename') and self.filename: | ||
| # file_id = self.filename | ||
|
|
||
| item.plot_spec = PlotSpec( | ||
| name=item.var_name, | ||
| source_type="file", | ||
| original_name=item.var_name, | ||
| file_source_identifier=file_id # Placeholder, actual file ID needs better handling |
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.
The fallback file_id = "unknown_data_model_source" is noted. The comment on lines 124-128 about potentially enhancing DataModel to store the filename (perhaps from data_loader.source during initialization) is a good point. This would make the file_source_identifier more robust here. For now, this fallback is understandable, but it's a good candidate for future refinement.
| def get_data_by_name(self, name) -> DataItem | None: | ||
| # TODO(rose@): This is a bit of a hack in order to make drag/drop plotting work. | ||
| # If we decide to keep this, the model should be formalized. | ||
| data = None | ||
| # Make a lookup of the variable names... oi vey | ||
| v_lookup = {v.var_name: k for k, v in self._vars.items()} | ||
| try: | ||
| vid = v_lookup[name] | ||
| data = self._vars[vid].data | ||
| except KeyError: | ||
| print(f"Unknown key: {name}") | ||
| return data | ||
| # This method should return a DataItem, similar to DataModel.get_data_by_name | ||
|
|
||
| # self.parent is MathsWidget, self.parent._vars stores VarInfo objects | ||
| for var_info in self.parent._vars.values(): | ||
| if var_info.var_name == name: | ||
| # Construct a DataItem on the fly | ||
| # VarInfo contains: var_name, data, source (VarListWidget), plot_spec | ||
| data_item = DataItem( | ||
| var_name=var_info.var_name, | ||
| data=var_info.data, | ||
| plot_spec=var_info.plot_spec | ||
| ) | ||
| # Try to set the time attribute for the DataItem | ||
| # The source in VarInfo is typically the VarListWidget from which the data originated | ||
| # or was derived in the context of MathsWidget. | ||
| if hasattr(var_info.source, 'time'): | ||
| data_item._time = var_info.source.time | ||
| elif hasattr(var_info.source, 'model') and hasattr(var_info.source.model(), 'time'): | ||
| # If source is a widget, its model might have time | ||
| data_item._time = var_info.source.model().time | ||
| else: | ||
| # Fallback or if time is not critical for this DataItem's usage via ThinModelMock | ||
| # print(f"Warning: Time data not found for {var_info.var_name} in ThinModelMock") | ||
| pass # _time will remain None | ||
| return data_item | ||
|
|
||
| print(f"Unknown key: {name} in ThinModelMock") | ||
| return None |
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.
The ThinModelMock.get_data_by_name method and the TODO comment on line 350 acknowledge this as a hack. While this PR focuses on PlotSpec, this area remains a point of technical debt. Is there a plan to refactor this as part of unifying the plotting mechanisms, as the comment suggests? This current implementation, while functional for the drag/drop, could be a source of confusion or bugs later.
| def execute_operation_from_spec(self, output_spec: PlotSpec, input_data_items: list[DataItem]) -> DataItem | None: | ||
| """ | ||
| Executes a mathematical operation defined by output_spec using input_data_items. | ||
| This is used for reproducing signals when loading plots. | ||
| """ | ||
| if not input_data_items: | ||
| print(f"Error: No input data items provided for operation: {output_spec.name}") | ||
| return None | ||
|
|
||
| result_array = None | ||
| op_details = output_spec.operation_details if output_spec.operation_details else {} | ||
|
|
||
| # Common setup for DataItem | ||
| new_data_item_name = output_spec.name | ||
| # Time array should be consistent with inputs; use the first input's time. | ||
| # This assumes all inputs to an operation share a compatible time basis. | ||
| time_array = input_data_items[0].time | ||
| # avg_dt might be needed for some operations | ||
| avg_dt = np.mean(np.diff(time_array)).item() if time_array is not None and len(time_array) > 1 else 0.0 | ||
|
|
||
|
|
||
| if output_spec.source_type == "math_expr": | ||
| expression_str = output_spec.expression | ||
| if not expression_str: | ||
| print(f"Error: No expression string found in PlotSpec for: {output_spec.name}") | ||
| return None | ||
|
|
||
| e_data = {} | ||
| # The expression uses 'x0', 'x1', ... which correspond to input_data_items | ||
| # Their PlotSpecs are output_spec.input_plot_specs | ||
| # The actual variable names used in the expression ('x0', 'x1') are implicitly mapped by order. | ||
| for i, item in enumerate(input_data_items): | ||
| e_data[f'x{i}'] = item.data | ||
|
|
||
| try: | ||
| expr_parsed = self.parser.parse(expression_str) | ||
| result_array = expr_parsed.evaluate(e_data) | ||
| except Exception as e: | ||
| print(f"Error evaluating expression '{expression_str}' for '{output_spec.name}': {e}") | ||
| return None | ||
|
|
||
| # Implementation for specific math operations | ||
| elif output_spec.source_type == "math_filter": | ||
| from scipy import signal as scipy_signal # Avoid conflict with PyQt signal | ||
| input_data = input_data_items[0].data | ||
| if not all(k in op_details for k in ['order', 'type', 'cutoff', 'filtfilt']): | ||
| print(f"Error: Missing parameters in operation_details for filter: {output_spec.name}") | ||
| return None | ||
| fs = 1 / avg_dt if avg_dt > 0 else 1.0 # Avoid division by zero | ||
| Wn = op_details['cutoff'] / (0.5 * fs) | ||
| b, a = scipy_signal.butter(op_details['order'], Wn, btype=op_details['type']) | ||
| if op_details['filtfilt']: | ||
| result_array = scipy_signal.filtfilt(b, a, input_data, method='gust') | ||
| else: | ||
| result_array = scipy_signal.lfilter(b, a, input_data) | ||
|
|
||
| elif output_spec.source_type == "math_diff": | ||
| input_data = input_data_items[0].data | ||
| if time_array is not None and len(time_array) == len(input_data) and len(time_array) > 1: | ||
| result_array = np.concatenate(([0], np.diff(input_data) / np.diff(time_array))) | ||
| else: | ||
| print(f"Error: Invalid time_array for differentiation: {output_spec.name}") | ||
| return None | ||
|
|
||
| elif output_spec.source_type == "math_integrate": | ||
| input_data = input_data_items[0].data | ||
| if time_array is not None and len(time_array) == len(input_data) and len(time_array) > 1: | ||
| result_array = np.cumsum(input_data * np.concatenate(([0], np.diff(time_array)))) | ||
| else: | ||
| print(f"Error: Invalid time_array for integration: {output_spec.name}") | ||
| return None | ||
|
|
||
| elif output_spec.source_type == "math_running_minmax": | ||
| from scipy.ndimage.filters import maximum_filter1d, minimum_filter1d | ||
| input_data = input_data_items[0].data | ||
| if not all(k in op_details for k in ['type', 'window_sz', 'is_ticks']): | ||
| print(f"Error: Missing parameters for running_minmax: {output_spec.name}") | ||
| return None | ||
|
|
||
| window_sz_ticks = op_details['window_sz'] | ||
| if not op_details['is_ticks']: # Convert time window to ticks | ||
| if avg_dt <= 0: | ||
| print(f"Error: avg_dt is <=0, cannot convert time window to ticks for {output_spec.name}") | ||
| return None | ||
| window_sz_ticks = round(op_details['window_sz'] / avg_dt) | ||
| window_sz_ticks = int(max(1, window_sz_ticks)) # Ensure positive integer | ||
|
|
||
| func = minimum_filter1d if op_details['type'] == MinMaxType.MIN.name.lower() else maximum_filter1d # Requires MinMaxType enum or string comparison | ||
| offset = math.ceil(0.5 * window_sz_ticks) - 1 | ||
| result_array = func(input_data, size=window_sz_ticks, mode='nearest', origin=int(offset)) | ||
|
|
||
|
|
||
| elif output_spec.source_type == "math_running_window": | ||
| from scipy import signal as scipy_signal # Avoid conflict | ||
| input_data = input_data_items[0].data | ||
| if not all(k in op_details for k in ['type', 'window_sz', 'is_ticks']): | ||
| print(f"Error: Missing parameters for running_window: {output_spec.name}") | ||
| return None | ||
|
|
||
| window_sz_ticks = op_details['window_sz'] | ||
| if not op_details['is_ticks']: | ||
| if avg_dt <= 0: | ||
| print(f"Error: avg_dt is <=0, cannot convert time window to ticks for {output_spec.name}") | ||
| return None | ||
| window_sz_ticks = round(op_details['window_sz'] / avg_dt) | ||
| window_sz_ticks = int(max(1, window_sz_ticks)) | ||
|
|
||
| if op_details['type'] == WindowTypes.MEAN.name.lower(): # Requires WindowTypes enum or string comparison | ||
| result_array = np.convolve(input_data, np.ones(window_sz_ticks) / float(window_sz_ticks), mode='same') | ||
| else: # MEDIAN | ||
| if window_sz_ticks % 2 == 0: window_sz_ticks += 1 # Median filter window must be odd | ||
| result_array = scipy_signal.medfilt(input_data, kernel_size=window_sz_ticks) | ||
|
|
||
| else: | ||
| print(f"Error: Unknown math source_type '{output_spec.source_type}' for '{output_spec.name}'") | ||
| return None | ||
|
|
||
| if result_array is None: | ||
| print(f"Error: Math operation did not produce a result_array for '{output_spec.name}'") | ||
| return None | ||
|
|
||
| # Create the DataItem | ||
| new_data_item = DataItem(name=new_data_item_name, data=result_array, plot_spec=output_spec) | ||
| new_data_item._time = time_array | ||
|
|
||
| # Add to MathsWidget's internal tracking (_vars and var_out list) | ||
| # The 'source' for VarInfo when reproducing is self (MathsWidget), as it's the reproducer. | ||
| self.add_new_var(new_data_item, self, output_spec) | ||
|
|
||
| return new_data_item |
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.
This execute_operation_from_spec method is critical for reproducing signals and is quite comprehensive. A few points:
- Error Handling: Currently, errors are reported using
print(). For more robust error management, especially if this component were part of a larger system or library, would it be beneficial to raise specific exceptions that calling code could catch and handle more gracefully (e.g.,MathOperationError,MissingParameterError)? - Enum String Comparison: Comparisons like
op_details['type'] == MinMaxType.MIN.name.lower()(line 468) andWindowTypes.MEAN.name.lower()(line 488) rely on the string representation of enum names. This is consistent with howget_operation_detailsseems to store them. It's generally okay but ensure that the string format (.name.lower()) is consistently used when these details are generated and consumed. - Code Duplication in Imports:
from scipy import signal as scipy_signalis imported twice locally (lines 424, 474). Ifscipy.signalusage expands or for general cleanliness, a single import at the top of the file or class level might be preferred, though local imports are fine for limiting scope.
| current_model_identifier = vl.filename if hasattr(vl, 'filename') else str(vl.idx) | ||
| if current_model_identifier == plot_spec.file_source_identifier: | ||
| found_model = vl.model() # DataModel | ||
| break |
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.
The logic to match file_source_identifier by checking vl.filename and then str(vl.idx) is a good fallback mechanism. The comment in DataFileWidget.get_data_item_by_file_id_and_name about this being less robust if tab order changes is pertinent here too. This seems like the best approach given the current structure.
| class PlotSpec: | ||
| def __init__(self, | ||
| name: str, | ||
| source_type: str, | ||
| unique_id: Optional[str] = None, # Allow providing one, else generate | ||
| file_source_identifier: Optional[str] = None, | ||
| original_name: Optional[str] = None, | ||
| expression: Optional[str] = None, | ||
| operation_details: Optional[Dict[str, Any]] = None, | ||
| input_plot_specs: Optional[List['PlotSpec']] = None): | ||
| self.name = name | ||
| self.source_type = source_type | ||
| self.unique_id = unique_id if unique_id is not None else str(uuid.uuid4()) | ||
| self.file_source_identifier = file_source_identifier | ||
| self.original_name = original_name | ||
| self.expression = expression | ||
| self.operation_details = operation_details | ||
| self.input_plot_specs: List['PlotSpec'] = [] if input_plot_specs is None else input_plot_specs | ||
|
|
||
| def to_dict(self) -> Dict[str, Any]: | ||
| return { | ||
| 'name': self.name, | ||
| 'unique_id': self.unique_id, | ||
| 'source_type': self.source_type, | ||
| 'file_source_identifier': self.file_source_identifier, | ||
| 'original_name': self.original_name, | ||
| 'expression': self.expression, | ||
| 'operation_details': self.operation_details, | ||
| 'input_plot_specs': [spec.to_dict() for spec in self.input_plot_specs] if self.input_plot_specs else [] | ||
| } | ||
|
|
||
| @staticmethod | ||
| def from_dict(data: Dict[str, Any]) -> 'PlotSpec': | ||
| return PlotSpec( | ||
| name=data['name'], | ||
| source_type=data['source_type'], | ||
| unique_id=data['unique_id'], | ||
| file_source_identifier=data.get('file_source_identifier'), | ||
| original_name=data.get('original_name'), | ||
| expression=data.get('expression'), | ||
| operation_details=data.get('operation_details'), | ||
| input_plot_specs=[PlotSpec.from_dict(spec_data) for spec_data in data.get('input_plot_specs', [])] | ||
| ) |
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.
The PlotSpec class is well-designed for its purpose, providing clear serialization (to_dict) and deserialization (from_dict) methods. The use of uuid.uuid4() for unique_id is a good choice for ensuring unique identification of signal specifications. The handling of optional fields with .get() in from_dict is also robust.
| def plot_data_from_source(self, name_or_data_item, source_or_none, is_reproduced_item=False): | ||
| data_item: DataItem | None = None | ||
| name_to_plot: str = "" | ||
| y_data: np.ndarray | None = None | ||
| time_data: np.ndarray | None = None | ||
| plot_spec_for_custom_item: PlotSpec | None = None | ||
| source_for_custom_item = source_or_none # This will be the source for CustomPlotItem | ||
|
|
||
| if is_reproduced_item: | ||
| if not isinstance(name_or_data_item, DataItem): | ||
| print("Error: Expected DataItem when is_reproduced_item is True.") | ||
| return | ||
| data_item = name_or_data_item | ||
| # Ensure plot_spec exists on the reproduced item, critical for CustomPlotItem | ||
| if not data_item.plot_spec: | ||
| print(f"Error: Reproduced DataItem '{data_item.var_name}' is missing its PlotSpec.") | ||
| return # Cannot proceed without a PlotSpec for the CustomPlotItem | ||
|
|
||
| plot_spec_for_custom_item = data_item.plot_spec | ||
| name_to_plot = plot_spec_for_custom_item.name # Use name from PlotSpec for consistency | ||
| y_data = data_item.data | ||
| time_data = data_item.time | ||
| # source_for_custom_item is already set to source_or_none (e.g. maths_widget_ref) | ||
| else: | ||
| if not isinstance(name_or_data_item, str): | ||
| print("Error: Expected signal name string when is_reproduced_item is False.") | ||
| return | ||
| if source_or_none is None: | ||
| print("Error: Source widget cannot be None when is_reproduced_item is False.") | ||
| return | ||
|
|
||
| name_to_plot = name_or_data_item | ||
| current_source_widget = source_or_none | ||
|
|
||
| data_item = current_source_widget.model().get_data_by_name(name_to_plot) | ||
|
|
||
| if data_item is None: | ||
| print(f"Error: Could not retrieve DataItem for '{name_to_plot}' from source.") | ||
| return | ||
|
|
||
| plot_spec_for_custom_item = data_item.plot_spec # This should exist due to prior subtasks | ||
| y_data = data_item.data | ||
| time_data = data_item.time if data_item.time is not None else current_source_widget.time | ||
| # source_for_custom_item is already set (it's current_source_widget) | ||
|
|
||
| if y_data is None: # Should be caught by data_item is None earlier, but as a safeguard | ||
| print(f"Error: No y_data available for '{name_to_plot}'.") | ||
| return | ||
| if not isinstance(y_data, np.ndarray): | ||
| print(f"Warning: Data for '{name_to_plot}' is not a numpy array. Attempting conversion.") | ||
| try: | ||
| y_data = np.array(y_data) | ||
| except Exception as e: | ||
| print(f"Error converting data for '{name_to_plot}' to numpy array: {e}") | ||
| return | ||
|
|
||
| if time_data is None: | ||
| print(f"Error: No time_data available for '{name_to_plot}'. Cannot plot.") | ||
| return | ||
|
|
||
| item = self.pw.getPlotItem().plot(x=source.time, | ||
| y=y_data, | ||
| pen=pg.mkPen(color=self._get_color(self.cidx), | ||
| width=CustomPlotItem.PEN_WIDTH), | ||
| name=name, | ||
| # clipToView=True, | ||
| autoDownsample=True, | ||
| downsampleMethod='peak') | ||
|
|
||
| label = CustomPlotItem(self, item, source, self.parent().plot_manager()._tick) | ||
| # Actual plotting | ||
| pg_plot_item = self.pw.getPlotItem().plot(x=time_data, | ||
| y=y_data, | ||
| pen=pg.mkPen(color=self._get_color(self.cidx), | ||
| width=CustomPlotItem.PEN_WIDTH), | ||
| name=name_to_plot, | ||
| autoDownsample=True, | ||
| downsampleMethod='peak') | ||
|
|
||
| label = CustomPlotItem(self, pg_plot_item, source_for_custom_item, | ||
| self.parent().plot_manager()._tick, | ||
| plot_spec_from_source=plot_spec_for_custom_item) | ||
| self._traces.append(label) | ||
| self._labels.addWidget(label) | ||
| self.parent().plot_manager().timeValueChanged.connect(label.on_time_changed) | ||
|
|
||
| source.onClose.connect(lambda: self.remove_item(item, label)) | ||
| self.cidx += 1 | ||
| if hasattr(source_for_custom_item, 'onClose') and source_for_custom_item.onClose is not None: | ||
| try: | ||
| source_for_custom_item.onClose.connect(lambda: self.remove_item(pg_plot_item, label)) | ||
| except Exception as e: # Broad exception to catch issues with signal connection | ||
| print(f"Warning: Could not connect onClose for source of '{name_to_plot}'. Error: {e}") | ||
| elif not is_reproduced_item: # Only warn if not reproduced, as reproduced might not have traditional source | ||
| print(f"Warning: Source for '{name_to_plot}' does not have 'onClose' signal or it's None.") | ||
|
|
||
|
|
||
| self.cidx += 1 | ||
| self.update_plot_yrange() |
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.
The refactoring of plot_data_from_source to handle both new plots and reproduced DataItem objects is a significant improvement.
- The logic to differentiate behavior based on
is_reproduced_itemis clear. - Passing
plot_spec_for_custom_itemtoCustomPlotItemis crucial. - The handling of
source_for_custom_itemand itsonClosesignal connection (lines 201-207) with atry-exceptblock and warnings is a good defensive measure.
Consider adding a docstring to this complex method to explain the roles of name_or_data_item, source_or_none, and is_reproduced_item for future maintainers.
This change introduces the capability to save and load plotlists that include signals derived from various math operations, ensuring that the full state of the plotted signals can be reproduced.
Key changes include:
PlotSpecClass: I created a newPlotSpecclass (plot_spec.py) to encapsulate all necessary information to define and reproduce a signal. This includes its name, source (file or math operation), unique ID, and derivation details (expression, operation parameters, input signals'PlotSpecs). It includesto_dictandfrom_dictmethods for serialization.MathsWidgetModifications:VarInfoandDataItemnow store associatedPlotSpecobjects.PlotSpecfor their output signals, capturing the operation type, parameters, and input signalPlotSpecs.MathsWidgetcan use inputs from bothvar_in(file signals) andvar_out(derived signals) for new operations.execute_operation_from_specallowsMathsWidgetto re-calculate a signal based on itsPlotSpecand provided inputDataItems during plotlist loading.CustomPlotItemUpdate: Now stores aPlotSpecfor each trace. If a signal is plotted directly from a file, a basic "file"PlotSpecis created. If plotted fromMathsWidget, it uses the detailedPlotSpecattached to theDataItem.Plot Saving/Loading Logic:
PlotSpecobjects are converted to dictionaries viato_dict()and stored in the plotlist file.PlotAreaWidget.generate_plotsuses a new helper_reproduce_signal. This function:PlotSpecobjects.DataFileWidget.MathsWidget.execute_operation_from_specto re-compute the derived signal.Supporting Changes:
DataModelandThinModelMockwere updated to ensureDataItemobjects (with theirPlotSpecs) are correctly retrieved.SubPlotWidget.plot_data_from_sourcewas adapted to handle plotting of these reproducedDataItems.DataFileWidgetnow has a method to look upDataItems by a file identifier and signal name.This enables you to save complex analysis sessions involving multiple math-derived signals and reliably restore them later.