From ef235a95e54cfd3a1b095b20963f3c7e97ba5510 Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Wed, 10 Dec 2025 15:38:59 +0000 Subject: [PATCH 01/14] start factoring out non-csv dependent charting code --- .../core/adaptor/impl/injected_commands.py | 4 +- .../core/charts/plot_functions.py | 80 +++++++++++++++---- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py index ac6488a5e..b2ec27f51 100644 --- a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py +++ b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py @@ -28,7 +28,7 @@ import ansys.platform.instancemanagement as pypim -from ansys.systemcoupling.core.charts.plot_functions import create_and_show_plot +from ansys.systemcoupling.core.charts.plot_functions import create_and_show_plot_csv from ansys.systemcoupling.core.charts.plotdefinition_manager import ( DataTransferSpec, InterfaceSpec, @@ -249,7 +249,7 @@ def _show_plot(session: SessionProtocol, **kwargs): ) spec.plot_time = is_transient - return create_and_show_plot(spec, [file_path]) + return create_and_show_plot_csv(spec, [file_path]) def get_injected_cmd_data() -> list: diff --git a/src/ansys/systemcoupling/core/charts/plot_functions.py b/src/ansys/systemcoupling/core/charts/plot_functions.py index cc8acf4bc..773865288 100644 --- a/src/ansys/systemcoupling/core/charts/plot_functions.py +++ b/src/ansys/systemcoupling/core/charts/plot_functions.py @@ -21,8 +21,13 @@ # SOFTWARE. import threading -from typing import Callable +from typing import Callable, Protocol +from ansys.systemcoupling.core.charts.chart_datatypes import ( + InterfaceInfo, + InterfaceSeriesData, + TimestepData, +) from ansys.systemcoupling.core.charts.csv_chartdata import CsvChartDataReader from ansys.systemcoupling.core.charts.live_csv_datasource import LiveCsvDataSource from ansys.systemcoupling.core.charts.message_dispatcher import MessageDispatcher @@ -33,16 +38,31 @@ from ansys.systemcoupling.core.charts.plotter import Plotter -def create_and_show_plot(spec: PlotSpec, csv_list: list[str]) -> Plotter: +class ChartDataReader(Protocol): + def read_metadata(self) -> bool: ... + def read_new_data(self) -> None: ... + @property + def metadata(self) -> InterfaceInfo: ... + @property + def data(self) -> InterfaceSeriesData: ... + @property + def timestep_data(self) -> TimestepData: ... + + +class LiveDataSource(Protocol): + def cancel(self) -> None: ... + def read_data(self) -> None: ... + + +def _make_csv_data_reader(interface_name: str) -> ChartDataReader: + return CsvChartDataReader(interface_name) + + +def _create_and_show_impl(spec: PlotSpec, reader: ChartDataReader) -> Plotter: if len(spec.interfaces) != 1: raise ValueError("Plots currently only support one interface") - if len(spec.interfaces) != len(csv_list): - raise ValueError( - "'csv_list' should have length equal to the number of interfaces" - ) manager = PlotDefinitionManager(spec) - reader = CsvChartDataReader(spec.interfaces[0].name, csv_list[0]) plotter = Plotter(manager) reader.read_metadata() @@ -60,26 +80,31 @@ def create_and_show_plot(spec: PlotSpec, csv_list: list[str]) -> Plotter: return plotter -def solve_with_live_plot( - spec: PlotSpec, - csv_list: list[str], - solve_func: Callable[[], None], -): +def create_and_show_plot_csv(spec: PlotSpec, csv_list: list[str]) -> Plotter: + """Create and show a plot based on System Coupling CSV chart data.""" if len(spec.interfaces) != 1: raise ValueError("Plots currently only support one interface") if len(spec.interfaces) != len(csv_list): raise ValueError( "'csv_list' should have length equal to the number of interfaces" ) + reader = _make_csv_data_reader(spec.interfaces[0].name) + return _create_and_show_impl(spec, reader) + +def _solve_with_live_plot_impl( + spec, + make_live_data_source: Callable[[str, Callable], LiveDataSource], + solve_func: Callable[[], None], +): + if len(spec.interfaces) != 1: + raise ValueError("Plots currently only support one interface") manager = PlotDefinitionManager(spec) dispatcher = MessageDispatcher() - plotter = Plotter(manager, dispatcher.dispatch_messages) + plotter = Plotter(manager, request_update=dispatcher.dispatch_messages) dispatcher.set_plotter(plotter) - data_source = LiveCsvDataSource( - spec.interfaces[0].name, csv_list[0], dispatcher.put_msg - ) + data_source = make_live_data_source(spec.interfaces[0].name, dispatcher.put_msg) data_thread = threading.Thread(target=data_source.read_data) def solve(): @@ -96,5 +121,26 @@ def solve(): data_thread.join() solve_thread.join() + +def solve_with_live_plot_csv( + spec: PlotSpec, + csv_list: list[str], + solve_func: Callable[[], None], +): + if len(spec.interfaces) != 1: + raise ValueError("Plots currently only support one interface") + if len(spec.interfaces) != len(csv_list): + raise ValueError( + "'csv_list' should have length equal to the number of interfaces" + ) + + _solve_with_live_plot_impl( + spec, + lambda interface_name, put_msg: LiveCsvDataSource( + interface_name, csv_list[0], put_msg + ), + solve_func, + ) + # Show a non-blocking static plot - return create_and_show_plot(spec, csv_list) + return create_and_show_plot_csv(spec, csv_list) From 4e5135d6205ccd1897878a87348cd9a7fd493abd Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Mon, 15 Dec 2025 11:55:55 +0000 Subject: [PATCH 02/14] minor changes --- .../core/charts/csv_chartdata.py | 12 +++--- .../core/charts/plotdefinition_manager.py | 41 ++++++++++--------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/ansys/systemcoupling/core/charts/csv_chartdata.py b/src/ansys/systemcoupling/core/charts/csv_chartdata.py index 15e054fd8..d10ac5af3 100644 --- a/src/ansys/systemcoupling/core/charts/csv_chartdata.py +++ b/src/ansys/systemcoupling/core/charts/csv_chartdata.py @@ -267,8 +267,8 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo assert_(intf_info.display_name == "", "display_name should be empty") intf_info.display_name = intf_disp_name series_info = TransferSeriesInfo( - data_index, - series_type, + data_index=data_index, + series_type=series_type, transfer_display_name=trans_disp_name, # get(..., 0) for case where transfer_disambig empty (see note above) disambiguation_index=transfer_disambig.get(trans_disp_name, 0), @@ -286,8 +286,8 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo # have index data_index+3. intf_info.transfer_info.append( TransferSeriesInfo( - data_index, - series_type, + data_index=data_index, + series_type=series_type, transfer_display_name=trans_disp_name, disambiguation_index=transfer_disambig.get( trans_disp_name, 0 @@ -304,8 +304,8 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo prev_part_name = "" intf_info.transfer_info.append( TransferSeriesInfo( - data_index, - series_type, + data_index=data_index, + series_type=series_type, transfer_display_name=trans_disp_name, disambiguation_index=transfer_disambig.get(trans_disp_name, 0), participant_display_name=part_disp_name, diff --git a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py index d77905302..9c018aa09 100644 --- a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py +++ b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py @@ -30,6 +30,7 @@ class DataTransferSpec: # It's not ideal, but we have to work in terms of display names for transfers, # as that is all we have in the data (the CSV data, at least). + # TODO: add optional internal name field which we will use if provided. display_name: str show_convergence: bool = True show_transfer_values: bool = True @@ -48,26 +49,26 @@ class PlotSpec: plot_time: bool = False -""" -Convergence subplot: - title - Data Transfer Convergence for - x-axis label - Iteration/Time - y-axis label - RMS Change in Target Value - - # x-data: [] - y-data: [([], )] - - # y-data - we actually want an index - - -Transfer values subplot: - title - - () - x-axis label: Iteration/time - y-axis label: - - y-data: [([], +# x-axis label - Iteration/Time +# y-axis label - RMS Change in Target Value +# +# # x-data: [] +# y-data: [([], )] +# +# # y-data - we actually want an index +# +# +# Transfer values subplot: +# title - - () +# x-axis label: Iteration/time +# y-axis label: +# +# y-data: [([], Date: Mon, 15 Dec 2025 18:43:32 +0000 Subject: [PATCH 03/14] rationalise cross-ref indexing in charts --- .../core/charts/chart_datatypes.py | 26 ++----- .../core/charts/csv_chartdata.py | 54 +++----------- .../core/charts/live_csv_datasource.py | 1 - .../core/charts/plot_functions.py | 6 +- .../core/charts/plotdefinition_manager.py | 37 ++++------ .../systemcoupling/core/charts/plotter.py | 8 +-- tests/charts/test_csv_chartdata.py | 70 +++++++++++-------- 7 files changed, 73 insertions(+), 129 deletions(-) diff --git a/src/ansys/systemcoupling/core/charts/chart_datatypes.py b/src/ansys/systemcoupling/core/charts/chart_datatypes.py index a62f0eff6..601d978b4 100644 --- a/src/ansys/systemcoupling/core/charts/chart_datatypes.py +++ b/src/ansys/systemcoupling/core/charts/chart_datatypes.py @@ -45,10 +45,6 @@ class TransferSeriesInfo: Attributes ---------- - data_index : int - This is used by the data source processor to associate this information (likely - obtained from heading or other metadata) with the correct data series. - It indexes into the full list of series associated with a given interface. series_type : SeriesType The type of line series. transfer_display_name : str @@ -62,22 +58,20 @@ class TransferSeriesInfo: participant_display_name : str, optional The display name of the participant. This is required for transfer value series but not for convergence series. - line_suffixes: list[str] - This should always be empty for convergence series. For transfer value series, - it should contain the suffixes for any component series that exist. That is, - suffixes for complex components, "real" or "imag", and suffixes for vector - components, "x", "y", "z", or a combination of complex and vector component - types. The data indexes for individual components of the transfer are assumed - to be contiguous from ``data_index``. + component_suffix: str, optional + The suffix for this component series, if applicable. This is only needed for + transfer value series that have multiple components, such as complex or vector + values, and is otherwise None. Suffixes for complex components are "real" and + "imag", and suffixes for vector components are "x", "y", and "z". A combination + of complex and vector suffixes is possible, such as "y real" and "x imag". """ - data_index: int series_type: SeriesType transfer_display_name: str disambiguation_index: int # Remainder for non-CONVERGENCE series only participant_display_name: Optional[str] = None - line_suffixes: list[str] = field(default_factory=list) + component_suffix: Optional[str] = None @dataclass @@ -115,9 +109,6 @@ class SeriesData: transfer_index : int Index of the ``TransferSeriesInfo`` metadata for this series within the ``InterfaceInfo`` for the interface this series is associated with. - component_index : int, optional - The component index if this series is one of a set of complex and/or - vector components of the transfer. Otherwise is ``None``. start_index : int, optional The starting iteration of the ``data`` field. This defaults to 0 and only needs to be set to a different value if incremental data, such @@ -128,10 +119,7 @@ class SeriesData: """ transfer_index: int # Index into transfer_info of associated InterfaceInfo - component_index: Optional[int] = None # Component index if applicable - start_index: int = 0 # Use when providing incremental data - data: list[float] = field(default_factory=list) diff --git a/src/ansys/systemcoupling/core/charts/csv_chartdata.py b/src/ansys/systemcoupling/core/charts/csv_chartdata.py index d10ac5af3..138bbe125 100644 --- a/src/ansys/systemcoupling/core/charts/csv_chartdata.py +++ b/src/ansys/systemcoupling/core/charts/csv_chartdata.py @@ -157,14 +157,8 @@ def read_new_data(self): def _init_data(self): series_data_list: list[SeriesData] = [] - for i, trans_info in enumerate(self._metadata.transfer_info): - if not trans_info.line_suffixes: - series_data_list.append(SeriesData(transfer_index=i)) - else: - for j in range(len(trans_info.line_suffixes)): - series_data_list.append( - SeriesData(transfer_index=i, component_index=j) - ) + for i in range(len(self._metadata.transfer_info)): + series_data_list.append(SeriesData(transfer_index=i)) self._data = InterfaceSeriesData(self._metadata, series=series_data_list) def _process_curr_data(self): @@ -245,7 +239,6 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo data_index = i - start_index series_type, intf_or_part_disp_name, trans_disp_name = _parse_header(header) if series_type == SeriesType.CONVERGENCE: - prev_part_name = "" # If there are no convergence headings, transfer_disambig will # remain unpopulated. In this case, assume for now that there @@ -267,7 +260,6 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo assert_(intf_info.display_name == "", "display_name should be empty") intf_info.display_name = intf_disp_name series_info = TransferSeriesInfo( - data_index=data_index, series_type=series_type, transfer_display_name=trans_disp_name, # get(..., 0) for case where transfer_disambig empty (see note above) @@ -276,39 +268,13 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo intf_info.transfer_info.append(series_info) else: part_disp_name = intf_or_part_disp_name - suffix = _parse_suffix(header, part_disp_name) - if suffix: - if prev_part_name != part_disp_name: - # Start a new series info for a group of components - # Thus if there are 3 components, say, they will - # implicitly have data indexes, data_index, data_index+1, - # data_index+2, and the next TransferSeriesInfo will - # have index data_index+3. - intf_info.transfer_info.append( - TransferSeriesInfo( - data_index=data_index, - series_type=series_type, - transfer_display_name=trans_disp_name, - disambiguation_index=transfer_disambig.get( - trans_disp_name, 0 - ), - participant_display_name=part_disp_name, - line_suffixes=[suffix], - ) - ) - prev_part_name = part_disp_name - else: - # Append component info to current series info - intf_info.transfer_info[-1].line_suffixes.append(suffix) - else: - prev_part_name = "" - intf_info.transfer_info.append( - TransferSeriesInfo( - data_index=data_index, - series_type=series_type, - transfer_display_name=trans_disp_name, - disambiguation_index=transfer_disambig.get(trans_disp_name, 0), - participant_display_name=part_disp_name, - ) + intf_info.transfer_info.append( + TransferSeriesInfo( + series_type=series_type, + transfer_display_name=trans_disp_name, + disambiguation_index=transfer_disambig.get(trans_disp_name, 0), + participant_display_name=part_disp_name, + component_suffix=_parse_suffix(header, part_disp_name) or None, ) + ) return intf_info diff --git a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py index 8b588fb2e..6755427df 100644 --- a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py +++ b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py @@ -70,7 +70,6 @@ def read_data(self): line_series_incr = SeriesData( line_series.transfer_index, - line_series.component_index, start_index=start_index, data=line_series.data[start_index:], ) diff --git a/src/ansys/systemcoupling/core/charts/plot_functions.py b/src/ansys/systemcoupling/core/charts/plot_functions.py index 773865288..3f044095d 100644 --- a/src/ansys/systemcoupling/core/charts/plot_functions.py +++ b/src/ansys/systemcoupling/core/charts/plot_functions.py @@ -54,10 +54,6 @@ def cancel(self) -> None: ... def read_data(self) -> None: ... -def _make_csv_data_reader(interface_name: str) -> ChartDataReader: - return CsvChartDataReader(interface_name) - - def _create_and_show_impl(spec: PlotSpec, reader: ChartDataReader) -> Plotter: if len(spec.interfaces) != 1: raise ValueError("Plots currently only support one interface") @@ -88,7 +84,7 @@ def create_and_show_plot_csv(spec: PlotSpec, csv_list: list[str]) -> Plotter: raise ValueError( "'csv_list' should have length equal to the number of interfaces" ) - reader = _make_csv_data_reader(spec.interfaces[0].name) + reader = CsvChartDataReader(spec.interfaces[0].name, csv_list[0]) return _create_and_show_impl(spec, reader) diff --git a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py index 9c018aa09..b68052375 100644 --- a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py +++ b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py @@ -240,7 +240,7 @@ def set_metadata(self, metadata: InterfaceInfo): # The integer is the "disambiguation_index" the transfer's TransferSeriesInfo. transfer_value_line_count: dict[tuple[str, int], int] = {} - for transfer in metadata.transfer_info: + for data_index, transfer in enumerate(metadata.transfer_info): transfer_key = ( transfer.transfer_display_name, transfer.disambiguation_index, @@ -257,7 +257,7 @@ def set_metadata(self, metadata: InterfaceInfo): ] = "" if conv_subplot := self._conv_subplots.get(interface_name): - data_index_map[transfer.data_index] = (conv_subplot, iconv) + data_index_map[data_index] = (conv_subplot, iconv) # Add a new series list to y_data, and label to series_labels # Both will be at position iconv of respective lists # conv_subplot.y_data.append([]) @@ -277,28 +277,17 @@ def set_metadata(self, metadata: InterfaceInfo): "", value_type ) itransval = transfer_value_line_count.get(transfer_key, 0) - if not transfer.line_suffixes: - data_index_map[transfer.data_index] = ( - transfer_value_subplot, - itransval, - ) - transfer_value_subplot.series_labels.append( - transfer.participant_display_name - ) - itransval += 1 - transfer_value_line_count[transfer_key] = itransval - else: - for i, suffix in enumerate(transfer.line_suffixes): - data_index_map[transfer.data_index + i] = ( - transfer_value_subplot, - itransval + i, - ) - transfer_value_subplot.series_labels.append( - transfer.participant_display_name + suffix - ) - transfer_value_line_count[transfer_key] = itransval + len( - transfer.line_suffixes - ) + label = transfer.participant_display_name + if transfer.component_suffix: + label += transfer.component_suffix + + data_index_map[data_index] = ( + transfer_value_subplot, + itransval, + ) + transfer_value_subplot.series_labels.append(label) + itransval += 1 + transfer_value_line_count[transfer_key] = itransval # This will be what allows us to update subplot data as new data received self._data_index_map[interface_name] = data_index_map diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index 5f3bce82b..bf9ee7536 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -204,14 +204,8 @@ def update_line_series(self, series_data: SeriesData): "Attempt to add series data to plot before metadata provided." ) - trans = self._metadata.transfer_info[series_data.transfer_index] - offset = ( - series_data.component_index - if series_data.component_index is not None - else 0 - ) subplot_defn, subplot_line_index = self._mgr.subplot_for_data_index( - self._metadata.name, trans.data_index + offset + self._metadata.name, series_data.transfer_index ) if subplot_defn is None: # This can happen if the list of plots being show is filtered. diff --git a/tests/charts/test_csv_chartdata.py b/tests/charts/test_csv_chartdata.py index 308b9e2ff..c5ae24dca 100644 --- a/tests/charts/test_csv_chartdata.py +++ b/tests/charts/test_csv_chartdata.py @@ -105,43 +105,37 @@ def test_parse_header(): assert info0.transfer_display_name == "input" assert info0.series_type == SeriesType.CONVERGENCE assert info0.participant_display_name is None - assert info0.data_index == 0 - assert info0.line_suffixes == [] + assert info0.component_suffix is None info1 = info.transfer_info[1] assert info1.transfer_display_name == "input" assert info1.series_type == SeriesType.WEIGHTED_AVERAGE assert info1.participant_display_name == "rootFind" - assert info1.data_index == 1 - assert info1.line_suffixes == [] + assert info1.component_suffix is None info2 = info.transfer_info[2] assert info2.transfer_display_name == "input" assert info2.series_type == SeriesType.WEIGHTED_AVERAGE assert info2.participant_display_name == "rootFind 2" - assert info2.data_index == 2 - assert info2.line_suffixes == [] + assert info2.component_suffix is None info3 = info.transfer_info[3] assert info3.transfer_display_name == "input2" assert info3.series_type == SeriesType.CONVERGENCE assert info3.participant_display_name is None - assert info3.data_index == 3 - assert info3.line_suffixes == [] + assert info3.component_suffix is None info4 = info.transfer_info[4] assert info4.transfer_display_name == "input2" assert info4.series_type == SeriesType.WEIGHTED_AVERAGE assert info4.participant_display_name == "rootFind 2" - assert info4.data_index == 4 - assert info4.line_suffixes == [] + assert info4.component_suffix is None info5 = info.transfer_info[5] assert info5.transfer_display_name == "input2" assert info5.series_type == SeriesType.WEIGHTED_AVERAGE assert info5.participant_display_name == "rootFind" - assert info5.data_index == 5 - assert info5.line_suffixes == [] + assert info5.component_suffix is None def test_parse_header_components(): @@ -165,49 +159,67 @@ def test_parse_header_components(): assert info.display_name == "Intf-1" assert info.is_transient - assert len(info.transfer_info) == 6 + assert len(info.transfer_info) == 10 info0 = info.transfer_info[0] assert info0.transfer_display_name == "input" assert info0.series_type == SeriesType.CONVERGENCE assert info0.participant_display_name is None - assert info0.data_index == 0 - assert info0.line_suffixes == [] + assert info0.component_suffix is None info1 = info.transfer_info[1] assert info1.transfer_display_name == "input" assert info1.series_type == SeriesType.SUM assert info1.participant_display_name == "part1" - assert info1.data_index == 1 - assert info1.line_suffixes == ["x", "y", "z"] + assert info1.component_suffix == "x" - info2 = info.transfer_info[2] + info1 = info.transfer_info[2] + assert info1.transfer_display_name == "input" + assert info1.series_type == SeriesType.SUM + assert info1.participant_display_name == "part1" + assert info1.component_suffix == "y" + + info1 = info.transfer_info[3] + assert info1.transfer_display_name == "input" + assert info1.series_type == SeriesType.SUM + assert info1.participant_display_name == "part1" + assert info1.component_suffix == "z" + + info2 = info.transfer_info[4] assert info2.transfer_display_name == "input" assert info2.series_type == SeriesType.SUM assert info2.participant_display_name == "part2" - assert info2.data_index == 4 - assert info2.line_suffixes == ["x", "y", "z"] + assert info2.component_suffix == "x" - info3 = info.transfer_info[3] + info2 = info.transfer_info[5] + assert info2.transfer_display_name == "input" + assert info2.series_type == SeriesType.SUM + assert info2.participant_display_name == "part2" + assert info2.component_suffix == "y" + + info2 = info.transfer_info[6] + assert info2.transfer_display_name == "input" + assert info2.series_type == SeriesType.SUM + assert info2.participant_display_name == "part2" + assert info2.component_suffix == "z" + + info3 = info.transfer_info[7] assert info3.transfer_display_name == "input2" assert info3.series_type == SeriesType.CONVERGENCE assert info3.participant_display_name is None - assert info3.data_index == 7 - assert info3.line_suffixes == [] + assert info3.component_suffix is None - info4 = info.transfer_info[4] + info4 = info.transfer_info[8] assert info4.transfer_display_name == "input2" assert info4.series_type == SeriesType.SUM assert info4.participant_display_name == "part2" - assert info4.data_index == 8 - assert info4.line_suffixes == [] + assert info4.component_suffix is None - info5 = info.transfer_info[5] + info5 = info.transfer_info[9] assert info5.transfer_display_name == "input2" assert info5.series_type == SeriesType.SUM assert info5.participant_display_name == "part1" - assert info5.data_index == 9 - assert info5.line_suffixes == [] + assert info5.component_suffix is None def test_timestep(data1): From 835a41cc81b6e77d7f1784974e6d0efbc007d78a Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Wed, 17 Dec 2025 09:59:12 +0000 Subject: [PATCH 04/14] live plotting of csv files more or less working --- examples/temp_non_sphinx/fluent_fluent.py | 81 ----------- .../core/adaptor/impl/injected_commands.py | 130 +++++++++++++++--- .../core/charts/live_csv_datasource.py | 3 + .../core/charts/message_dispatcher.py | 22 +-- .../core/charts/plotdefinition_manager.py | 7 +- 5 files changed, 129 insertions(+), 114 deletions(-) delete mode 100644 examples/temp_non_sphinx/fluent_fluent.py diff --git a/examples/temp_non_sphinx/fluent_fluent.py b/examples/temp_non_sphinx/fluent_fluent.py deleted file mode 100644 index 06a7bb7b3..000000000 --- a/examples/temp_non_sphinx/fluent_fluent.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. -# SPDX-License-Identifier: MIT -# -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -"""Example case for PyAnsys-side integration of participants in a PySystemCoupling case. - -This functionality is still under development and should be regarded as being at best Beta. - -Once stable, this needs to be moved to the usual examples location and made into a proper -Sphinx gallery example. -""" - -import ansys.fluent.core as pyfluent - -import ansys.systemcoupling.core as pysyc -from ansys.systemcoupling.core import LOG - -LOG.set_level("INFO") -LOG.log_to_stdout() - - -def _make_fluent_session(filename: str): - fluent_args = dict( - precision="double", processor_count=1, mode="solver", product_version="24.1.0" - ) - session = pyfluent.launch_fluent(**fluent_args) - session.file.read(file_type="case", file_name=filename) - - return session - - -syc = pysyc.launch(version="24.1", extra_args=["-l5"]) - -pipe_fluid_session = _make_fluent_session("pipefluid/pipefluid.cas.h5") -fluid_name = syc.setup.add_participant(participant_session=pipe_fluid_session) - -pipe_solid_session = _make_fluent_session("pipesolid/pipesolid.cas.h5") -solid_name = syc.setup.add_participant(participant_session=pipe_solid_session) -interface = syc.setup.add_interface( - side_one_participant=fluid_name, - side_one_regions=["wall"], - side_two_participant=solid_name, - side_two_regions=["innerwall"], -) - -# Comment this to force validation error on solve. (Currently hangs if this happens.) -syc._native_api.AddThermalDataTransfers(Interface=interface) - -syc.setup.solution_control.minimum_iterations = 2 -syc.setup.solution_control.maximum_iterations = ( - 2 # should be 100, but for testing 2 is enough -) - -syc.setup.solution_control.available_ports.option = "UserDefined" - -syc.setup.print_state() - -try: - syc.solution.solve() -finally: - pipe_fluid_session.exit() - pipe_solid_session.exit() - syc.exit() diff --git a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py index b2ec27f51..0e3042b45 100644 --- a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py +++ b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py @@ -28,7 +28,10 @@ import ansys.platform.instancemanagement as pypim -from ansys.systemcoupling.core.charts.plot_functions import create_and_show_plot_csv +from ansys.systemcoupling.core.charts.plot_functions import ( + create_and_show_plot_csv, + solve_with_live_plot_csv, +) from ansys.systemcoupling.core.charts.plotdefinition_manager import ( DataTransferSpec, InterfaceSpec, @@ -102,6 +105,11 @@ def get_injected_cmd_map( "solve": lambda **kwargs: _wrap_solve( get_solution_root_object(), part_mgr, **kwargs ), + "solve_with_plot": lambda **kwargs: _solve_with_live_plot( + session, + lambda: _wrap_solve(get_solution_root_object(), part_mgr, **kwargs), + **kwargs, + ), "interrupt": lambda **kwargs: rpc.interrupt(**kwargs), "abort": lambda **kwargs: rpc.abort(**kwargs), "show_plot": lambda **kwargs: _show_plot(session, **kwargs), @@ -199,19 +207,10 @@ def _ensure_file_available(session: SessionProtocol, filepath: str) -> str: return new_name -def _show_plot(session: SessionProtocol, **kwargs): +def _create_plot_spec( + session: SessionProtocol, interface_name: str, **kwargs +) -> PlotSpec: setup = session.setup - working_dir = kwargs.pop("working_dir", ".") - interface_name = kwargs.pop("interface_name", None) - if interface_name is None: - interfaces = setup.coupling_interface.get_object_names() - if len(interfaces) == 0: - return - if len(interfaces) > 1: - raise RuntimeError( - "show_plot() currently only supports a single interface." - ) - interface_name = interfaces[0] interface_object = setup.coupling_interface[interface_name] interface_disp_name = interface_object.display_name @@ -232,10 +231,6 @@ def _show_plot(session: SessionProtocol, **kwargs): # TODO : better way to do this? is_transient = setup.solution_control.time_step_size is not None - file_path = _ensure_file_available( - session, os.path.join(working_dir, "SyC", f"{interface_name}.csv") - ) - spec = PlotSpec() intf_spec = InterfaceSpec(interface_name, interface_disp_name) spec.interfaces.append(intf_spec) @@ -248,10 +243,50 @@ def _show_plot(session: SessionProtocol, **kwargs): ) ) spec.plot_time = is_transient + return spec + + +def _get_interface_name( + session: SessionProtocol, interface_name: str | None = None +) -> str: + if interface_name is None: + setup = session.setup + interfaces = setup.coupling_interface.get_object_names() + if len(interfaces) == 0: + return + if len(interfaces) > 1: + raise RuntimeError("plots currently only support a single interface.") + interface_name = interfaces[0] + return interface_name + + +def _show_plot(session: SessionProtocol, **kwargs): + working_dir = kwargs.pop("working_dir", ".") + interface_name = kwargs.pop("interface_name", None) + interface_name = _get_interface_name(session, interface_name) + file_path = _ensure_file_available( + session, os.path.join(working_dir, "SyC", f"{interface_name}.csv") + ) + spec = _create_plot_spec(session, interface_name, **kwargs) return create_and_show_plot_csv(spec, [file_path]) +def _solve_with_live_plot( + session: SessionProtocol, solve_func: Callable[[], None], **kwargs +): + working_dir = kwargs.pop("working_dir", ".") + interface_name = kwargs.pop("interface_name", None) + interface_name = _get_interface_name(session, interface_name) + file_path = os.path.join(working_dir, "SyC", f"{interface_name}.csv") + spec = _create_plot_spec(session, interface_name, **kwargs) + solve_with_live_plot_csv( + spec, + [file_path], + solve_func, + ) + + def get_injected_cmd_data() -> list: """Get a list of injected command data in the right form to insert at a convenient point in the current processing. @@ -466,6 +501,67 @@ def get_injected_cmd_data() -> list: Shows plots of transfer values and convergence for data transfers of a coupling interface. + essentialArgNames: + - interface_name + optionalArgNames: + - transfer_names + - working_dir + - show_convergence + - show_transfer_values + defaults: + - None + - "." + - True + - True + args: + - #!!python/tuple + - interface_name + - pyname: interface_name + Type: + type: String + doc: |- + Specification of which interface to plot. + - #!!python/tuple + - transfer_names + - pyname: transfer_names + Type: + type: String List + doc: |- + Specification of which data transfers to plot. Defaults + to ``None``, which means plot all data transfers. + - #!!python/tuple + - working_dir + - pyname: working_dir + Type: + type: String + doc: |- + Working directory (defaults = "."). + - #!!python/tuple + - show_convergence + - pyname: show_convergence + Type: + type: Logical + doc: |- + Whether to show convergence plots (defaults to ``True``). + - #!!python/tuple + - show_transfer_values + - pyname: show_transfer_values + Type: + type: Logical + doc: |- + Whether to show transfer value plots (defaults to ``True``). +- name: solve_with_plot + pyname: solve_with_plot + exposure: solution + isInjected: true + isQuery: false + retType: + doc: |- + Solves, showing a live plot of transfer values and convergence for data transfers + of a coupling interface. + + (This functionality is experimental and incomplete.) + essentialArgNames: - interface_name optionalArgNames: diff --git a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py index 6755427df..13c3d9e7a 100644 --- a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py +++ b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py @@ -58,6 +58,9 @@ def read_data(self): last_round = False while True: self._csv_reader.read_new_data() + timestep_data = self._csv_reader.timestep_data + if timestep_data.time: + self._put_msg(Message(type=MsgType.TIMESTEP_DATA, data=timestep_data)) data = self._csv_reader.data if not self._last_data_len: self._last_data_len = [0] * len(data.series) diff --git a/src/ansys/systemcoupling/core/charts/message_dispatcher.py b/src/ansys/systemcoupling/core/charts/message_dispatcher.py index 2e7025a3d..c708c6da3 100644 --- a/src/ansys/systemcoupling/core/charts/message_dispatcher.py +++ b/src/ansys/systemcoupling/core/charts/message_dispatcher.py @@ -68,17 +68,17 @@ def dispatch_messages(self): while True: try: msg: Message = self._q.get(timeout=0.001) - msg_t = msg.type print(f"dispatch message of type: {msg.type.name}") - if msg_t == MsgType.METADATA: - self._plotter.set_metadata(msg.data) - elif msg_t == MsgType.TIMESTEP_DATA: - self._plotter.set_timestep_data(msg.data) - elif msg_t == MsgType.SERIES_DATA: - self._plotter.update_line_series(msg.data) - elif msg_t in (MsgType.END_OF_DATA, MsgType.NO_DATA_AVAILABLE): - return - elif msg_t == MsgType.CLOSE_PLOT: - self._plotter.close() + match msg.type: + case MsgType.METADATA: + self._plotter.set_metadata(msg.data) + case MsgType.TIMESTEP_DATA: + self._plotter.set_timestep_data(msg.data) + case MsgType.SERIES_DATA: + self._plotter.update_line_series(msg.data) + case MsgType.END_OF_DATA | MsgType.NO_DATA_AVAILABLE: + return + case MsgType.CLOSE_PLOT: + self._plotter.close() except queue.Empty: return diff --git a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py index b68052375..fe0a6ebd4 100644 --- a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py +++ b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py @@ -50,25 +50,22 @@ class PlotSpec: # -# TODO: is this useful? clean it up if so +# Plots are labelled as follows: # # Convergence subplot: # title - Data Transfer Convergence for # x-axis label - Iteration/Time # y-axis label - RMS Change in Target Value # -# # x-data: [] # y-data: [([], )] # -# # y-data - we actually want an index -# -# # Transfer values subplot: # title - - () # x-axis label: Iteration/time # y-axis label: # # y-data: [([], Date: Mon, 22 Dec 2025 17:11:14 +0000 Subject: [PATCH 05/14] WIP: support multi intf-static plots working? --- examples/00-systemcoupling/cht_pipe.py | 4 +- .../core/adaptor/impl/injected_commands.py | 169 ++++++++++++++---- .../core/charts/chart_datatypes.py | 3 + .../core/charts/csv_chartdata.py | 4 +- .../core/charts/live_csv_datasource.py | 3 +- .../core/charts/plot_functions.py | 36 ++-- .../core/charts/plotdefinition_manager.py | 139 +++++++------- .../systemcoupling/core/charts/plotter.py | 123 ++++++++++++- tests/charts/test_message_dipatcher.py | 10 +- tests/charts/test_plotdefinition_manager.py | 109 ++++++++--- 10 files changed, 453 insertions(+), 147 deletions(-) diff --git a/examples/00-systemcoupling/cht_pipe.py b/examples/00-systemcoupling/cht_pipe.py index a7c8286fb..1027bf56e 100644 --- a/examples/00-systemcoupling/cht_pipe.py +++ b/examples/00-systemcoupling/cht_pipe.py @@ -80,7 +80,7 @@ # ---------------------------------------- # Launch MAPDL. -mapdl = pymapdl.launch_mapdl() +mapdl = pymapdl.launch_mapdl(version=252) mapdl.clear() mapdl.prep7() @@ -170,7 +170,7 @@ # Set up the fluid analysis and read the pre-created mesh file # ------------------------------------------------------------ -fluent = pyfluent.launch_fluent(start_transcript=False) +fluent = pyfluent.launch_fluent(start_transcript=False, product_version=252) fluent.file.read(file_type="mesh", file_name=fluent_msh_file) # %% diff --git a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py index 0e3042b45..ea07056e8 100644 --- a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py +++ b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py @@ -24,7 +24,7 @@ import os import random import time -from typing import Callable, Dict, Optional, Protocol +from typing import Any, Callable, Dict, Optional, Protocol import ansys.platform.instancemanagement as pypim @@ -208,22 +208,11 @@ def _ensure_file_available(session: SessionProtocol, filepath: str) -> str: def _create_plot_spec( - session: SessionProtocol, interface_name: str, **kwargs + session: SessionProtocol, + interface_and_transfer_names: dict[str, list[str]], + **kwargs, ) -> PlotSpec: setup = session.setup - interface_object = setup.coupling_interface[interface_name] - interface_disp_name = interface_object.display_name - - if (transfer_names := kwargs.pop("transfer_names", None)) is None: - transfer_names = interface_object.data_transfer.get_object_names() - - if len(transfer_names) == 0: - return None - - transfer_disp_names = [ - interface_object.data_transfer[trans_name].display_name - for trans_name in transfer_names - ] show_convergence = kwargs.pop("show_convergence", True) show_transfer_values = kwargs.pop("show_transfer_values", True) @@ -232,16 +221,25 @@ def _create_plot_spec( is_transient = setup.solution_control.time_step_size is not None spec = PlotSpec() - intf_spec = InterfaceSpec(interface_name, interface_disp_name) - spec.interfaces.append(intf_spec) - for transfer in transfer_disp_names: - intf_spec.transfers.append( - DataTransferSpec( - display_name=transfer, - show_convergence=show_convergence, - show_transfer_values=show_transfer_values, + for interface_name, transfer_names in interface_and_transfer_names.items(): + interface_object = setup.coupling_interface[interface_name] + interface_disp_name = interface_object.display_name + if not transfer_names: + continue + intf_spec = InterfaceSpec(interface_name, interface_disp_name) + spec.interfaces.append(intf_spec) + transfer_disp_names = [ + interface_object.data_transfer[trans_name].display_name + for trans_name in transfer_names + ] + for transfer in transfer_disp_names: + intf_spec.transfers.append( + DataTransferSpec( + display_name=transfer, + show_convergence=show_convergence, + show_transfer_values=show_transfer_values, + ) ) - ) spec.plot_time = is_transient return spec @@ -260,16 +258,125 @@ def _get_interface_name( return interface_name +def _get_interface_and_transfer_names( + session: SessionProtocol, arg_dict: Dict[str, Any] +) -> dict[str, list]: + + # Argument handling is complicated but necessary to provide flexibility + # + # We want to make the common situation of a single interface easy to use + # but we also want to support multiple interfaces. + # + # If there is a single interface, and charts are needed on all transfers, + # then no arguments are needed. The transfer list can be filtered by + # optionally providing 'transfer_names'. + # + # If there are multiple interfaces, then 'interface_name' can be provided + # to select one interface, again with optional 'transfer_names' to filter. + # + # There are no other situations where it is valid to provide 'interface_name' + # and/or 'transfer_names'. + # + # If there are multiple interfaces, and no filtering is required, no arguments + # are needed. + # + # If there are multiple interfaces, and all transfers are needed on some + # interfaces, then 'interface_names' may be provided to select those interfaces. + # In this case there is no filtering of transfers. + # + # For full control, 'interface_and_transfer_names' may be provided to specify + # exactly which interfaces and which transfers on those interfaces are needed + # in the form of a dictionary mapping interface names to lists of transfer names. + # Additionally, the list of transfer names may be None to indicate that all + # transfers on that interface are needed. + + interface_name = arg_dict.pop("interface_name", None) + interface_names = arg_dict.pop("interface_names", None) + transfer_names = arg_dict.pop("transfer_names", None) + interface_and_transfer_names = arg_dict.pop("interface_and_transfer_names", None) + + if interface_and_transfer_names is not None: + if ( + interface_name is not None + or interface_names is not None + or transfer_names is not None + ): + raise RuntimeError( + "'interface_and_transfer_names' cannot be used with " + "'interface_name', 'interface_names', or 'transfer_names'." + ) + + setup = session.setup + if interface_names is not None: + if interface_name is not None or transfer_names is not None: + raise RuntimeError( + "'interface_names' cannot be used with " + "'interface_name' or 'transfer_names'." + ) + interface_and_transfer_names = { + intf_name: None for intf_name in interface_names + } + else: + interface_names = setup.coupling_interface.get_object_names() + + if transfer_names is not None: + # There must be a single interface in this case or interface_name must be specified + if len(interface_names) != 1 or interface_name is not None: + raise RuntimeError( + "'transfer_names' cannot be used when there is more than " + "one interface and 'interface_name' is not specified." + ) + if interface_name is not None and interface_name not in interface_names: + raise RuntimeError(f"Interface '{interface_name}' does not exist.") + interface_name = interface_name or interface_names[0] + interface_and_transfer_names = {interface_name: transfer_names} + elif interface_name is not None: + interface_and_transfer_names = {interface_name: None} + elif not interface_and_transfer_names: + # Nothing specified so just generate a full interface and transfer dict + interface_and_transfer_names = { + intf_name: None for intf_name in interface_names + } + + # At this point we have a dictionary of interface names to either None or a list + # of transfer names. We need to validate and fill in any None transfer names. + validated_interface_transfer_map = {} + for intf_name, trans_names in interface_and_transfer_names.items(): + if intf_name not in interface_names: + raise RuntimeError(f"Interface '{intf_name}' does not exist.") + actual_transfer_names = setup.coupling_interface[ + intf_name + ].data_transfer.get_object_names() + if trans_names: + if unknown_transfers := set(trans_names) - set(actual_transfer_names): + raise RuntimeError( + f"Data transfers '{unknown_transfers}' do not exist on " + f"interface '{intf_name}'." + ) + validated_interface_transfer_map[intf_name] = trans_names + else: + validated_interface_transfer_map[intf_name] = actual_transfer_names + return validated_interface_transfer_map + + def _show_plot(session: SessionProtocol, **kwargs): working_dir = kwargs.pop("working_dir", ".") - interface_name = kwargs.pop("interface_name", None) - interface_name = _get_interface_name(session, interface_name) - file_path = _ensure_file_available( - session, os.path.join(working_dir, "SyC", f"{interface_name}.csv") - ) - spec = _create_plot_spec(session, interface_name, **kwargs) - return create_and_show_plot_csv(spec, [file_path]) + # Take copy of arguments as _get_interface_and_transfer_names + # potentially pops items from the dictionary. Note that this + # is the desired behaviour for when we pass it on to + # _create_plot_spec later. + kw_dict = dict(kwargs) + interface_and_transfer_names = _get_interface_and_transfer_names(session, kw_dict) + file_paths = [] + for interface_name in interface_and_transfer_names.keys(): + file_path = _ensure_file_available( + session, os.path.join(working_dir, "SyC", f"{interface_name}.csv") + ) + file_paths.append(file_path) + + spec = _create_plot_spec(session, interface_and_transfer_names, **kw_dict) + return create_and_show_plot_csv(spec, file_paths) def _solve_with_live_plot( diff --git a/src/ansys/systemcoupling/core/charts/chart_datatypes.py b/src/ansys/systemcoupling/core/charts/chart_datatypes.py index 601d978b4..dc75451e9 100644 --- a/src/ansys/systemcoupling/core/charts/chart_datatypes.py +++ b/src/ansys/systemcoupling/core/charts/chart_datatypes.py @@ -106,6 +106,8 @@ class SeriesData: Attributes ---------- + interface_name: str + The name of the interface this series is associated with. transfer_index : int Index of the ``TransferSeriesInfo`` metadata for this series within the ``InterfaceInfo`` for the interface this series is associated with. @@ -118,6 +120,7 @@ class SeriesData: step-based data by using a time step to iteration mapping. """ + interface_name: str transfer_index: int # Index into transfer_info of associated InterfaceInfo start_index: int = 0 # Use when providing incremental data data: list[float] = field(default_factory=list) diff --git a/src/ansys/systemcoupling/core/charts/csv_chartdata.py b/src/ansys/systemcoupling/core/charts/csv_chartdata.py index 138bbe125..a7ae24045 100644 --- a/src/ansys/systemcoupling/core/charts/csv_chartdata.py +++ b/src/ansys/systemcoupling/core/charts/csv_chartdata.py @@ -158,7 +158,9 @@ def read_new_data(self): def _init_data(self): series_data_list: list[SeriesData] = [] for i in range(len(self._metadata.transfer_info)): - series_data_list.append(SeriesData(transfer_index=i)) + series_data_list.append( + SeriesData(interface_name=self._metadata.name, transfer_index=i) + ) self._data = InterfaceSeriesData(self._metadata, series=series_data_list) def _process_curr_data(self): diff --git a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py index 13c3d9e7a..0d00b5f45 100644 --- a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py +++ b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py @@ -72,7 +72,8 @@ def read_data(self): continue line_series_incr = SeriesData( - line_series.transfer_index, + interface_name=line_series.interface_name, + transfer_index=line_series.transfer_index, start_index=start_index, data=line_series.data[start_index:], ) diff --git a/src/ansys/systemcoupling/core/charts/plot_functions.py b/src/ansys/systemcoupling/core/charts/plot_functions.py index 3f044095d..c7fdb9936 100644 --- a/src/ansys/systemcoupling/core/charts/plot_functions.py +++ b/src/ansys/systemcoupling/core/charts/plot_functions.py @@ -54,23 +54,26 @@ def cancel(self) -> None: ... def read_data(self) -> None: ... -def _create_and_show_impl(spec: PlotSpec, reader: ChartDataReader) -> Plotter: - if len(spec.interfaces) != 1: - raise ValueError("Plots currently only support one interface") - +def _create_and_show_impl(spec: PlotSpec, readers: list[ChartDataReader]) -> Plotter: manager = PlotDefinitionManager(spec) plotter = Plotter(manager) - reader.read_metadata() - plotter.set_metadata(reader.metadata) + for ireader, reader in enumerate(readers): + reader.read_metadata() + plotter.set_metadata(reader.metadata) - reader.read_new_data() - data = reader.data - if reader.metadata.is_transient: - plotter.set_timestep_data(reader.timestep_data) + reader.read_new_data() + data = reader.data - for line_series in data.series: - plotter.update_line_series(line_series) + # Each reader has own timestep data but they should be consistent + # so we only use the first one. + # This is an artifact of using the CSV files as the data source. + # (A streaming data source would only have one timestep data.) + if ireader == 0 and reader.metadata.is_transient: + plotter.set_timestep_data(reader.timestep_data) + + for line_series in data.series: + plotter.update_line_series(line_series) plotter.show_plot(noblock=True) return plotter @@ -78,14 +81,15 @@ def _create_and_show_impl(spec: PlotSpec, reader: ChartDataReader) -> Plotter: def create_and_show_plot_csv(spec: PlotSpec, csv_list: list[str]) -> Plotter: """Create and show a plot based on System Coupling CSV chart data.""" - if len(spec.interfaces) != 1: - raise ValueError("Plots currently only support one interface") if len(spec.interfaces) != len(csv_list): raise ValueError( "'csv_list' should have length equal to the number of interfaces" ) - reader = CsvChartDataReader(spec.interfaces[0].name, csv_list[0]) - return _create_and_show_impl(spec, reader) + readers = [ + CsvChartDataReader(intf.name, csvfile) + for intf, csvfile in zip(spec.interfaces, csv_list) + ] + return _create_and_show_impl(spec, readers) def _solve_with_live_plot_impl( diff --git a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py index fe0a6ebd4..09d5a0608 100644 --- a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py +++ b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py @@ -99,13 +99,15 @@ class SubplotDefinition: series_labels: list[str] = field(default_factory=list) -class PlotDefinitionManager: - def __init__(self, spec: PlotSpec): - self._plot_spec = spec - self._data_index_map: dict[str, dict[int, tuple[SubplotDefinition, int]]] = {} - self._conv_subplots: dict[str, SubplotDefinition] = {} - self._transfer_subplots: dict[tuple[str, str, int], SubplotDefinition] = {} +class SubplotManager: + def __init__(self, is_transient: bool, intf_spec: InterfaceSpec): + self._is_time: bool = is_transient + self._intf_spec = intf_spec + + self._conv_subplot: SubplotDefinition | None = None + self._transfer_subplots: dict[tuple[str, int], SubplotDefinition] = {} self._subplots: list[SubplotDefinition] = [] + self._data_index_map: dict[int, tuple[SubplotDefinition, int]] = {} self._allocate_subplots() @property @@ -113,7 +115,7 @@ def subplots(self) -> list[SubplotDefinition]: return self._subplots def subplot_for_data_index( - self, interface_name: str, data_index: int + self, data_index: int ) -> tuple[Optional[SubplotDefinition], int]: """Return the subplot definition, and the line index within the subplot, corresponding to a given ``data_index``. @@ -124,7 +126,7 @@ def subplot_for_data_index( return a tuple ``(None, -1)`` """ try: - return self._data_index_map[interface_name][data_index] + return self._data_index_map[data_index] except KeyError: return (None, -1) @@ -150,56 +152,49 @@ def get_layout(self) -> tuple[int, int]: return (nrow, ncol) def _allocate_subplots(self): - is_time = self._plot_spec.plot_time - conv_subplots = {} transfer_subplots = {} subplots = [] - for interface in self._plot_spec.interfaces: - conv = SubplotDefinition( - title=f"Data transfer convergence on {interface.display_name}", - is_log_y=True, - x_axis_label="Time" if is_time else "Iteration", - y_axis_label="RMS Change in target value", - ) - # Add this now so that it is before transfer values plots but we may end - # up removing it if none of the transfers add a convergence line to it - conv_index = len(subplots) - subplots.append(conv) - keep_conv = False - transfer_disambig: dict[str, int] = {} - for transfer in interface.transfers: - if transfer.display_name in transfer_disambig: - transfer_disambig[transfer.display_name] += 1 - else: - transfer_disambig[transfer.display_name] = 0 - if transfer.show_convergence: - keep_conv = True - if transfer.show_transfer_values: - transfer_value = SubplotDefinition( - # NB: is a placeholder - substitute later from metadata info - title=f"{interface.display_name} - {transfer.display_name} ()", - is_log_y=False, - x_axis_label="Time" if is_time else "Iteration", - y_axis_label="", - ) - transfer_subplots[ - ( - interface.name, - transfer.display_name, - transfer_disambig[transfer.display_name], - ) - ] = transfer_value - subplots.append(transfer_value) - if keep_conv: - conv_subplots[interface.name] = conv + conv = SubplotDefinition( + title=f"Data transfer convergence on {self._intf_spec.display_name}", + is_log_y=True, + x_axis_label="Time" if self._is_time else "Iteration", + y_axis_label="RMS Change in target value", + ) + # Add this now so that it is before transfer values plots but we may end + # up removing it if none of the transfers add a convergence line to it + subplots.append(conv) + keep_conv = False + transfer_disambig: dict[str, int] = {} + for transfer in self._intf_spec.transfers: + if transfer.display_name in transfer_disambig: + transfer_disambig[transfer.display_name] += 1 else: - subplots[conv_index] = None + transfer_disambig[transfer.display_name] = 0 + if transfer.show_convergence: + keep_conv = True + if transfer.show_transfer_values: + transfer_value = SubplotDefinition( + # NB: is a placeholder - substitute later from metadata info + title=f"{self._intf_spec.display_name} - {transfer.display_name} ()", + is_log_y=False, + x_axis_label="Time" if self._is_time else "Iteration", + y_axis_label="", + ) + transfer_subplots[ + ( + transfer.display_name, + transfer_disambig[transfer.display_name], + ) + ] = transfer_value + subplots.append(transfer_value) + if keep_conv: + self._conv_subplot = conv + # Clean out inactive convergence plots self._subplots = [subplot for subplot in subplots if subplot is not None] for i, subplot in enumerate(self._subplots): subplot.index = i - self._conv_subplots: dict[str, SubplotDefinition] = conv_subplots - self._transfer_subplots: dict[tuple[str, str, int], SubplotDefinition] = ( + self._transfer_subplots: dict[tuple[str, int], SubplotDefinition] = ( transfer_subplots ) @@ -216,18 +211,16 @@ def set_metadata(self, metadata: InterfaceInfo): # the active ones. However, some additional work has to be done to filter the # transfers shown on the convergence subplot and we have to go back to the plot # spec to get a list of active transfers. - active_transfers = [] - for intf in self._plot_spec.interfaces: - if intf.name == metadata.name: - active_transfers = [trans.display_name for trans in intf.transfers] - break - if not active_transfers: + if self._intf_spec.name == metadata.name: + active_transfers = [ + trans.display_name for trans in self._intf_spec.transfers + ] + else: # TODO: should this be an exception? return # map from source data index to corresponding (subplot, line index within subplot) data_index_map: dict[int, tuple[SubplotDefinition, int]] = {} - interface_name = metadata.name iconv = 0 # Keep a running count of the transfer value lines associated with a given @@ -253,16 +246,14 @@ def set_metadata(self, metadata: InterfaceInfo): active_transfers.index(transfer.transfer_display_name) ] = "" - if conv_subplot := self._conv_subplots.get(interface_name): - data_index_map[data_index] = (conv_subplot, iconv) - # Add a new series list to y_data, and label to series_labels - # Both will be at position iconv of respective lists - # conv_subplot.y_data.append([]) - conv_subplot.series_labels.append(transfer.transfer_display_name) - iconv += 1 + data_index_map[data_index] = (self._conv_subplot, iconv) + # Add a new series list to y_data, and label to series_labels + # Both will be at position iconv of respective lists + self._conv_subplot.series_labels.append(transfer.transfer_display_name) + iconv += 1 else: transfer_value_subplot = self._transfer_subplots.get( - (interface_name, transfer_key[0], transfer_key[1]) + (transfer_key[0], transfer_key[1]) ) if transfer_value_subplot: value_type = ( @@ -287,4 +278,18 @@ def set_metadata(self, metadata: InterfaceInfo): transfer_value_line_count[transfer_key] = itransval # This will be what allows us to update subplot data as new data received - self._data_index_map[interface_name] = data_index_map + self._data_index_map = data_index_map + + +class PlotDefinitionManager: + def __init__(self, spec: PlotSpec): + self._subplot_mgrs: dict[str, SubplotManager] = {} + self._init(spec) + + def _init(self, spec: PlotSpec): + is_time = spec.plot_time + for interface in spec.interfaces: + self._subplot_mgrs[interface.name] = SubplotManager(is_time, interface) + + def subplot_mgr(self, interface_name: str) -> SubplotManager: + return self._subplot_mgrs[interface_name] diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index bf9ee7536..09da4ceb8 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -36,6 +36,7 @@ ) from ansys.systemcoupling.core.charts.plotdefinition_manager import ( PlotDefinitionManager, + SubplotManager, ) from ansys.systemcoupling.core.util.assertion import assert_ @@ -161,16 +162,17 @@ def _update_xy_data( # TODO: Only handles one interface at the moment! Generalise to multiple -class Plotter: +class FigurePlotter: def __init__( self, - mgr: PlotDefinitionManager, + plot_number: int, + mgr: SubplotManager, request_update: Optional[Callable[[], None]] = None, ): self._mgr = mgr self._request_update = request_update - self._fig: Figure = plt.figure() + self._fig: Figure = plt.figure(plot_number) self._subplot_lines: list[list[Line2D]] = [] self._subplot_limits_set: list[bool] = [] self._metadata: Optional[InterfaceInfo] = None @@ -184,6 +186,7 @@ def set_metadata(self, metadata: InterfaceInfo): self._mgr.set_metadata(metadata) # We now have enough information to create the (empty) plots self._init_plots() + self._fig.suptitle(f"Interface: {metadata.name}", fontsize=10) def set_timestep_data(self, timestep_data: TimestepData): @@ -205,7 +208,7 @@ def update_line_series(self, series_data: SeriesData): ) subplot_defn, subplot_line_index = self._mgr.subplot_for_data_index( - self._metadata.name, series_data.transfer_index + series_data.transfer_index ) if subplot_defn is None: # This can happen if the list of plots being show is filtered. @@ -229,10 +232,10 @@ def update_line_series(self, series_data: SeriesData): series_data.start_index, ) - self.update_limits(subplot_defn.index, subplot_defn.is_log_y, x_new, y_new) + self._update_limits(subplot_defn.index, subplot_defn.is_log_y, x_new, y_new) subplot_line.set_data(x_new, y_new) - def update_limits(self, subplot_index, is_log_y, x_new, y_new): + def _update_limits(self, subplot_index, is_log_y, x_new, y_new): axes = self._fig.axes[subplot_index] are_limits_initialised = self._subplot_limits_set[subplot_index] @@ -336,3 +339,111 @@ def _init_plots(self): self._subplot_lines.append(lines) # The limits on this subplot are essentially unset until we start getting data self._subplot_limits_set.append(False) + + +class Plotter: + def __init__( + self, + mgr: PlotDefinitionManager, + request_update: Optional[Callable[[], None]] = None, + ): + self._mgr = mgr + self._request_update = request_update + + self._figures: list[FigurePlotter] = [] + self._interface_to_figure_index: dict[str, int] = {} + + self._is_transient: bool | None = None + + # Empty if not transient: + self._times: list[float] = [] # Time value at each time step + self._time_indexes: list[int] = [] # Iteration to take value at time i from + + def set_metadata(self, metadata: InterfaceInfo): + if self._is_transient is None: + self._is_transient = metadata.is_transient + elif self._is_transient != metadata.is_transient: + raise RuntimeError( + "Attempt to set metadata with inconsistent transient setting." + ) + + ifig = len(self._figures) + self._interface_to_figure_index[metadata.name] = ifig + self._figures.append( + FigurePlotter( + ifig + 1, self._mgr.subplot_mgr(metadata.name), self._request_update + ) + ) + # TODO: move this into constructor of FigurePlotter? + self._figures[ifig].set_metadata(metadata) + + def set_timestep_data(self, timestep_data: TimestepData): + + if timestep_data.timestep and not self._is_transient: + raise RuntimeError("Attempt to set timestep data on non-transient case") + + self._time_indexes, self._times = _process_timestep_data(timestep_data) + + def update_line_series(self, series_data: SeriesData): + """Update the line series determined by the provided ``series_data`` with the + incremental data that it contains. + + The ``series_data`` contains the "start index" in the full series, the index + to start writing the new data. + """ + ifig = self._fig_index(series_data.interface_name) + self._figures[ifig].update_line_series(series_data) + + def close(self): + if self._fig: + plt.close(self._fig) + + def show_plot(self, noblock=False): + if noblock: + with plt.ion(): + self._show_plots() + else: + self._show_plots() + + def _show_plots(self): + for fig in self._figures: + fig.show_plot() + plt.show() + + def show_animated(self): + # NB: if using the wait_for_metadata() approach + # supported by MessageDispatcher, do it here like + # this (assume the wait function is stored as an + # attribute): + # + # assert_(self._wait_for_metadata is not None) + # metadata = self._wait_for_metadata() + # if metadata is not None: + # self.set_metadata(metadata) + # else: + # return + assert_(self._request_update is not None) + + self.ani = FuncAnimation( + self._fig, + self._update_animation, + # frames=x_axis_pts, + save_count=sys.maxsize, + # init_func=self._init_plots, + blit=False, + interval=200, + repeat=False, + ) + plt.show() + + def _fig_index(self, interface_name: str) -> int: + if interface_name not in self._interface_to_figure_index: + raise RuntimeError( + f"Attempt to set or update plot data for unknown interface " + f"'{interface_name}'." + ) + return self._interface_to_figure_index[interface_name] + + def _update_animation(self, frame: int): + # print("calling update animation") + return self._request_update() diff --git a/tests/charts/test_message_dipatcher.py b/tests/charts/test_message_dipatcher.py index ee7f6b0c5..7e2745315 100644 --- a/tests/charts/test_message_dipatcher.py +++ b/tests/charts/test_message_dipatcher.py @@ -72,11 +72,15 @@ def test_message_dispatcher(): ), ) ) - dispatcher.put_msg(Message(MsgType.SERIES_DATA, SeriesData(0, data=[0.1, 0.5]))) dispatcher.put_msg( - Message(MsgType.SERIES_DATA, SeriesData(0, data=[0.1, 0.5, 0.8])) + Message(MsgType.SERIES_DATA, SeriesData("interface-1", 0, data=[0.1, 0.5])) + ) + dispatcher.put_msg( + Message(MsgType.SERIES_DATA, SeriesData("interface-1", 0, data=[0.1, 0.5, 0.8])) + ) + dispatcher.put_msg( + Message(MsgType.SERIES_DATA, SeriesData("interface-1", 0, data=[0.1])) ) - dispatcher.put_msg(Message(MsgType.SERIES_DATA, SeriesData(0, data=[0.1]))) dispatcher.put_msg(Message(MsgType.CLOSE_PLOT)) thread.join() diff --git a/tests/charts/test_plotdefinition_manager.py b/tests/charts/test_plotdefinition_manager.py index bdf346cf7..7bf7134c0 100644 --- a/tests/charts/test_plotdefinition_manager.py +++ b/tests/charts/test_plotdefinition_manager.py @@ -46,6 +46,30 @@ def spec(): return PlotSpec([intf], plot_time=True) +@pytest.fixture +def spec2(): + transfers1 = [ + DataTransferSpec( + display_name="trans1", show_convergence=True, show_transfer_values=True + ), + DataTransferSpec( + display_name="trans2", show_convergence=True, show_transfer_values=True + ), + ] + transfers2 = [ + DataTransferSpec( + display_name="Trans1", show_convergence=True, show_transfer_values=False + ), + DataTransferSpec( + display_name="Trans2", show_convergence=False, show_transfer_values=True + ), + ] + + intf1 = InterfaceSpec(name="intf1", display_name="Intf-1", transfers=transfers1) + intf2 = InterfaceSpec(name="intf2", display_name="Intf-2", transfers=transfers2) + return PlotSpec([intf1, intf2], plot_time=True) + + @pytest.fixture def metadata(): headers = [ @@ -62,41 +86,86 @@ def metadata(): return parse_csv_metadata("intf1", headers) +@pytest.fixture +def metadata2(): + headers = [ + "Iteration", + "Step", + "Time", + "Data Transfer Convergence (RMS Change in Target Value): Intf-2 - Trans1", + "Trans1 (Weighted Average): rootFind", + "Trans1 (Weighted Average): rootFind 2", + "Data Transfer Convergence (RMS Change in Target Value): Intf-2 - Trans2", + "Trans2 (Weighted Average): rootFind 2", + "Trans2 (Weighted Average): rootFind", + ] + return parse_csv_metadata("intf2", headers) + + def test_init_from_spec(spec): pdm = PlotDefinitionManager(spec) - assert len(pdm.subplots) == 3 - assert pdm.get_layout() == (2, 2) + try: + mgr = pdm.subplot_mgr("intf1") + except KeyError: + pytest.fail("Subplot manager for interface 'intf1' not found") - assert pdm.subplots[0].is_log_y - assert not pdm.subplots[1].is_log_y - assert not pdm.subplots[2].is_log_y + assert len(mgr.subplots) == 3 + assert mgr.get_layout() == (2, 2) - assert pdm.subplots[0].title == "Data transfer convergence on Intf-1" - assert pdm.subplots[0].y_axis_label == "RMS Change in target value" - assert pdm.subplots[1].title == "Intf-1 - trans1 ()" - assert pdm.subplots[2].title == "Intf-1 - trans2 ()" + assert mgr.subplots[0].is_log_y + assert not mgr.subplots[1].is_log_y + assert not mgr.subplots[2].is_log_y + + assert mgr.subplots[0].title == "Data transfer convergence on Intf-1" + assert mgr.subplots[0].y_axis_label == "RMS Change in target value" + assert mgr.subplots[1].title == "Intf-1 - trans1 ()" + assert mgr.subplots[2].title == "Intf-1 - trans2 ()" assert ( - pdm.subplots[0].x_axis_label - == pdm.subplots[1].x_axis_label - == pdm.subplots[2].x_axis_label + mgr.subplots[0].x_axis_label + == mgr.subplots[1].x_axis_label + == mgr.subplots[2].x_axis_label == "Time" ) - for sp in pdm.subplots: + for sp in mgr.subplots: assert sp.series_labels == [] def test_set_metadata(spec, metadata): pdm = PlotDefinitionManager(spec) - pdm.set_metadata(metadata) + mgr = pdm.subplot_mgr(metadata.name) + mgr.set_metadata(metadata) + + assert len(mgr.subplots) == 3 + + assert mgr.subplots[1].title == "Intf-1 - trans1 (Weighted Average)" + assert mgr.subplots[2].title == "Intf-1 - trans2 (Weighted Average)" + + assert mgr.subplots[0].series_labels == ["trans1", "trans2"] + assert mgr.subplots[1].series_labels == ["rootFind", "rootFind 2"] + assert mgr.subplots[2].series_labels == ["rootFind 2", "rootFind"] + + +def test_set_metadata_two_interfaces(spec2, metadata, metadata2): + pdm = PlotDefinitionManager(spec2) + mgr1 = pdm.subplot_mgr(metadata.name) + mgr2 = pdm.subplot_mgr(metadata2.name) + + mgr1.set_metadata(metadata) + mgr2.set_metadata(metadata2) - assert len(pdm.subplots) == 3 + assert len(mgr1.subplots) == 3 + assert len(mgr2.subplots) == 2 - assert pdm.subplots[1].title == "Intf-1 - trans1 (Weighted Average)" - assert pdm.subplots[2].title == "Intf-1 - trans2 (Weighted Average)" + assert mgr1.subplots[1].title == "Intf-1 - trans1 (Weighted Average)" + assert mgr1.subplots[2].title == "Intf-1 - trans2 (Weighted Average)" + assert mgr2.subplots[0].title == "Data transfer convergence on Intf-2" + assert mgr2.subplots[1].title == "Intf-2 - Trans2 (Weighted Average)" - assert pdm.subplots[0].series_labels == ["trans1", "trans2"] - assert pdm.subplots[1].series_labels == ["rootFind", "rootFind 2"] - assert pdm.subplots[2].series_labels == ["rootFind 2", "rootFind"] + assert mgr1.subplots[0].series_labels == ["trans1", "trans2"] + assert mgr1.subplots[1].series_labels == ["rootFind", "rootFind 2"] + assert mgr1.subplots[2].series_labels == ["rootFind 2", "rootFind"] + assert mgr2.subplots[0].series_labels == ["Trans1", "Trans2"] + assert mgr2.subplots[1].series_labels == ["rootFind 2", "rootFind"] From dcd124f46bb159f0f29f4ac330b6a6645b9f4af6 Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Mon, 5 Jan 2026 13:10:46 +0000 Subject: [PATCH 06/14] towards multi-figure animations; not working yet --- .../core/adaptor/impl/injected_commands.py | 16 +-- .../core/charts/csv_chartdata.py | 1 + .../core/charts/live_csv_datasource.py | 99 +++++++++++++------ .../core/charts/message_dispatcher.py | 5 +- .../core/charts/plot_functions.py | 14 ++- .../core/charts/plotdefinition_manager.py | 4 + .../systemcoupling/core/charts/plotter.py | 72 +++++++------- 7 files changed, 130 insertions(+), 81 deletions(-) diff --git a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py index ea07056e8..d8e90ac1e 100644 --- a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py +++ b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py @@ -107,7 +107,7 @@ def get_injected_cmd_map( ), "solve_with_plot": lambda **kwargs: _solve_with_live_plot( session, - lambda: _wrap_solve(get_solution_root_object(), part_mgr, **kwargs), + lambda: _wrap_solve(get_solution_root_object(), part_mgr), **kwargs, ), "interrupt": lambda **kwargs: rpc.interrupt(**kwargs), @@ -383,13 +383,17 @@ def _solve_with_live_plot( session: SessionProtocol, solve_func: Callable[[], None], **kwargs ): working_dir = kwargs.pop("working_dir", ".") - interface_name = kwargs.pop("interface_name", None) - interface_name = _get_interface_name(session, interface_name) - file_path = os.path.join(working_dir, "SyC", f"{interface_name}.csv") - spec = _create_plot_spec(session, interface_name, **kwargs) + # Take copy as in _show_plot + kw_dict = dict(kwargs) + interface_and_transfer_names = _get_interface_and_transfer_names(session, kw_dict) + file_paths = [ + os.path.join(working_dir, "SyC", f"{interface_name}.csv") + for interface_name in interface_and_transfer_names.keys() + ] + spec = _create_plot_spec(session, interface_and_transfer_names, **kw_dict) solve_with_live_plot_csv( spec, - [file_path], + file_paths, solve_func, ) diff --git a/src/ansys/systemcoupling/core/charts/csv_chartdata.py b/src/ansys/systemcoupling/core/charts/csv_chartdata.py index a7ae24045..3070987c5 100644 --- a/src/ansys/systemcoupling/core/charts/csv_chartdata.py +++ b/src/ansys/systemcoupling/core/charts/csv_chartdata.py @@ -164,6 +164,7 @@ def _init_data(self): self._data = InterfaceSeriesData(self._metadata, series=series_data_list) def _process_curr_data(self): + assert_(self._data is not None, "Metadata must be read before data") raw_data = self._csv_reader.data last_data_len = len(self._data.series[0].data) diff --git a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py index 0d00b5f45..5af23a23d 100644 --- a/src/ansys/systemcoupling/core/charts/live_csv_datasource.py +++ b/src/ansys/systemcoupling/core/charts/live_csv_datasource.py @@ -27,64 +27,101 @@ from ansys.systemcoupling.core.charts.chart_datatypes import SeriesData from ansys.systemcoupling.core.charts.csv_chartdata import CsvChartDataReader from ansys.systemcoupling.core.charts.message_dispatcher import Message, MsgType +from ansys.systemcoupling.core.util.logging import LOG class LiveCsvDataSource: def __init__( self, - interface_name: str, - csvfile: Union[str, TextIO], + interface_names: list[str], + csvfiles: list[Union[str, TextIO]], put_msg: Callable[[Message], None], ): - self._csv_reader = CsvChartDataReader(interface_name, csvfile) + self._csv_readers = [ + CsvChartDataReader(name, csvfile) + for name, csvfile in zip(interface_names, csvfiles) + ] self._put_msg = put_msg self._is_cancelled = threading.Event() - self._last_data_len = [] + self._last_data_len: list[list[int]] = [[] for _ in range(len(interface_names))] + LOG.debug("LiveCsvDataSource initialized for interfaces: %s", interface_names) + LOG.debug( + " _last_data_len initialized with length %s", len(self._last_data_len) + ) def cancel(self): self._is_cancelled.set() def read_data(self): + metadata_read = [False] * len(self._csv_readers) while not self._is_cancelled.is_set(): - if not self._csv_reader.read_metadata(): - time.sleep(0.5) - else: - self._put_msg( - Message(type=MsgType.METADATA, data=self._csv_reader.metadata) - ) + for i_series, csv_reader in enumerate(self._csv_readers): + if not metadata_read[i_series] and csv_reader.read_metadata(): + LOG.debug("Read metadata for interface index: %s", i_series) + self._put_msg( + Message(type=MsgType.METADATA, data=csv_reader.metadata) + ) + metadata_read[i_series] = True + if all(metadata_read): break + time.sleep(0.1) + nstep = 0 + ntime = 0 last_round = False while True: - self._csv_reader.read_new_data() - timestep_data = self._csv_reader.timestep_data - if timestep_data.time: - self._put_msg(Message(type=MsgType.TIMESTEP_DATA, data=timestep_data)) - data = self._csv_reader.data - if not self._last_data_len: - self._last_data_len = [0] * len(data.series) - for i, line_series in enumerate(data.series): - start_index = self._last_data_len[i] - new_data_len = len(line_series.data) + for i_intf, csv_reader in enumerate(self._csv_readers): + LOG.debug( + "LiveCsvDataSource reading new data for interface index: %s", + i_intf, + ) + csv_reader.read_new_data() + timestep_data = csv_reader.timestep_data + if ( + len(timestep_data.time) > ntime + or len(timestep_data.timestep) > nstep + ): + nstep = len(timestep_data.timestep) + ntime = len(timestep_data.time) + self._put_msg( + Message(type=MsgType.TIMESTEP_DATA, data=timestep_data) + ) + data = csv_reader.data + if not self._last_data_len[i_intf]: + self._last_data_len[i_intf] = [0] * len(data.series) + for i_series, line_series in enumerate(data.series): + start_index = self._last_data_len[i_intf][i_series] + new_data_len = len(line_series.data) - if new_data_len - start_index == 0: - continue + if new_data_len - start_index == 0: + continue - line_series_incr = SeriesData( - interface_name=line_series.interface_name, - transfer_index=line_series.transfer_index, - start_index=start_index, - data=line_series.data[start_index:], - ) - self._put_msg(Message(type=MsgType.SERIES_DATA, data=line_series_incr)) - self._last_data_len[i] = new_data_len + LOG.debug( + " interface %s series %s: sending data from index %s to %s", + i_intf, + i_series, + start_index, + new_data_len, + ) + + line_series_incr = SeriesData( + interface_name=line_series.interface_name, + transfer_index=line_series.transfer_index, + start_index=start_index, + data=line_series.data[start_index:], + ) + self._put_msg( + Message(type=MsgType.SERIES_DATA, data=line_series_incr) + ) + self._last_data_len[i_intf][i_series] = new_data_len if last_round: self._put_msg(Message(type=MsgType.END_OF_DATA)) break elif self._is_cancelled.is_set(): + LOG.debug("LiveCsvDataSource cancellation detected") # Allow opportunity for any trailing updates to file last_round = True - time.sleep(0.5) + time.sleep(0.5 / len(self._csv_readers)) diff --git a/src/ansys/systemcoupling/core/charts/message_dispatcher.py b/src/ansys/systemcoupling/core/charts/message_dispatcher.py index c708c6da3..5d801ddba 100644 --- a/src/ansys/systemcoupling/core/charts/message_dispatcher.py +++ b/src/ansys/systemcoupling/core/charts/message_dispatcher.py @@ -30,6 +30,7 @@ SeriesData, TimestepData, ) +from ansys.systemcoupling.core.util.logging import LOG class PlotterProtocol(Protocol): @@ -62,19 +63,21 @@ def set_plotter(self, plotter: PlotterProtocol): self._plotter = plotter def put_msg(self, msg: Message): + LOG.debug("put message of type: %s", msg.type.name) self._q.put(msg) def dispatch_messages(self): while True: try: msg: Message = self._q.get(timeout=0.001) - print(f"dispatch message of type: {msg.type.name}") + LOG.debug("dispatch message of type: %s", msg.type.name) match msg.type: case MsgType.METADATA: self._plotter.set_metadata(msg.data) case MsgType.TIMESTEP_DATA: self._plotter.set_timestep_data(msg.data) case MsgType.SERIES_DATA: + LOG.debug(" series data has length: %s", len(msg.data.data)) self._plotter.update_line_series(msg.data) case MsgType.END_OF_DATA | MsgType.NO_DATA_AVAILABLE: return diff --git a/src/ansys/systemcoupling/core/charts/plot_functions.py b/src/ansys/systemcoupling/core/charts/plot_functions.py index c7fdb9936..2ae551c46 100644 --- a/src/ansys/systemcoupling/core/charts/plot_functions.py +++ b/src/ansys/systemcoupling/core/charts/plot_functions.py @@ -97,14 +97,14 @@ def _solve_with_live_plot_impl( make_live_data_source: Callable[[str, Callable], LiveDataSource], solve_func: Callable[[], None], ): - if len(spec.interfaces) != 1: - raise ValueError("Plots currently only support one interface") manager = PlotDefinitionManager(spec) dispatcher = MessageDispatcher() plotter = Plotter(manager, request_update=dispatcher.dispatch_messages) dispatcher.set_plotter(plotter) - data_source = make_live_data_source(spec.interfaces[0].name, dispatcher.put_msg) + data_source = make_live_data_source( + [intf.name for intf in spec.interfaces], dispatcher.put_msg + ) data_thread = threading.Thread(target=data_source.read_data) def solve(): @@ -117,9 +117,9 @@ def solve(): solve_thread.start() plotter.show_animated() + solve_thread.join() data_source.cancel() data_thread.join() - solve_thread.join() def solve_with_live_plot_csv( @@ -127,8 +127,6 @@ def solve_with_live_plot_csv( csv_list: list[str], solve_func: Callable[[], None], ): - if len(spec.interfaces) != 1: - raise ValueError("Plots currently only support one interface") if len(spec.interfaces) != len(csv_list): raise ValueError( "'csv_list' should have length equal to the number of interfaces" @@ -136,8 +134,8 @@ def solve_with_live_plot_csv( _solve_with_live_plot_impl( spec, - lambda interface_name, put_msg: LiveCsvDataSource( - interface_name, csv_list[0], put_msg + lambda interface_names, put_msg: LiveCsvDataSource( + interface_names, csv_list, put_msg ), solve_func, ) diff --git a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py index 09d5a0608..18ebac8ed 100644 --- a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py +++ b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py @@ -291,5 +291,9 @@ def _init(self, spec: PlotSpec): for interface in spec.interfaces: self._subplot_mgrs[interface.name] = SubplotManager(is_time, interface) + @property + def interface_names(self) -> list[str]: + return list(self._subplot_mgrs.keys()) + def subplot_mgr(self, interface_name: str) -> SubplotManager: return self._subplot_mgrs[interface_name] diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index 09da4ceb8..deb836b8a 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -22,6 +22,7 @@ import math import sys +import time from typing import Callable, Optional, Union from matplotlib.animation import FuncAnimation @@ -39,6 +40,7 @@ SubplotManager, ) from ansys.systemcoupling.core.util.assertion import assert_ +from ansys.systemcoupling.core.util.logging import LOG def _process_timestep_data( @@ -161,12 +163,12 @@ def _update_xy_data( return (x_new, y_new) -# TODO: Only handles one interface at the moment! Generalise to multiple class FigurePlotter: def __init__( self, plot_number: int, mgr: SubplotManager, + metadata: InterfaceInfo, request_update: Optional[Callable[[], None]] = None, ): self._mgr = mgr @@ -175,18 +177,21 @@ def __init__( self._fig: Figure = plt.figure(plot_number) self._subplot_lines: list[list[Line2D]] = [] self._subplot_limits_set: list[bool] = [] - self._metadata: Optional[InterfaceInfo] = None + self._metadata = metadata # Empty if not transient: self._times: list[float] = [] # Time value at each time step self._time_indexes: list[int] = [] # Iteration to take value at time i from - def set_metadata(self, metadata: InterfaceInfo): - self._metadata = metadata - self._mgr.set_metadata(metadata) + self._init_from_metadata() + + self._animation: FuncAnimation | None = None + + def _init_from_metadata(self): + self._mgr.set_metadata(self._metadata) # We now have enough information to create the (empty) plots self._init_plots() - self._fig.suptitle(f"Interface: {metadata.name}", fontsize=10) + self._fig.suptitle(f"Interface: {self._metadata.name}", fontsize=10) def set_timestep_data(self, timestep_data: TimestepData): @@ -269,11 +274,6 @@ def close(self): if self._fig: plt.close(self._fig) - def show_plot(self, noblock=False): - if noblock: - plt.ion() - plt.show() - def show_animated(self): # NB: if using the wait_for_metadata() approach # supported by MessageDispatcher, do it here like @@ -288,7 +288,10 @@ def show_animated(self): # return assert_(self._request_update is not None) - self.ani = FuncAnimation( + if self._animation is not None: + return + + self._animation = FuncAnimation( self._fig, self._update_animation, # frames=x_axis_pts, @@ -298,10 +301,11 @@ def show_animated(self): interval=200, repeat=False, ) - plt.show() + # plt.show() def _update_animation(self, frame: int): # print("calling update animation") + LOG.debug("FigurePlotter updating animation frame: %s", frame) return self._request_update() def _init_plots(self): @@ -352,6 +356,8 @@ def __init__( self._figures: list[FigurePlotter] = [] self._interface_to_figure_index: dict[str, int] = {} + self._is_animated: bool = False + self._pending_metadata: bool = False self._is_transient: bool | None = None @@ -371,11 +377,15 @@ def set_metadata(self, metadata: InterfaceInfo): self._interface_to_figure_index[metadata.name] = ifig self._figures.append( FigurePlotter( - ifig + 1, self._mgr.subplot_mgr(metadata.name), self._request_update + ifig + 1, + self._mgr.subplot_mgr(metadata.name), + metadata, + self._request_update, ) ) - # TODO: move this into constructor of FigurePlotter? - self._figures[ifig].set_metadata(metadata) + if self._is_animated: + self._pending_metadata = False + self._figures[-1].show_animated() def set_timestep_data(self, timestep_data: TimestepData): @@ -395,20 +405,15 @@ def update_line_series(self, series_data: SeriesData): self._figures[ifig].update_line_series(series_data) def close(self): - if self._fig: - plt.close(self._fig) + for fig in self._figures: + plt.close(fig) def show_plot(self, noblock=False): if noblock: with plt.ion(): - self._show_plots() + plt.show() else: - self._show_plots() - - def _show_plots(self): - for fig in self._figures: - fig.show_plot() - plt.show() + plt.show() def show_animated(self): # NB: if using the wait_for_metadata() approach @@ -423,17 +428,14 @@ def show_animated(self): # else: # return assert_(self._request_update is not None) + self._is_animated = True + self._pending_metadata = True + while self._pending_metadata: + self._request_update() + time.sleep(0.1) - self.ani = FuncAnimation( - self._fig, - self._update_animation, - # frames=x_axis_pts, - save_count=sys.maxsize, - # init_func=self._init_plots, - blit=False, - interval=200, - repeat=False, - ) + for fig in self._figures: + fig.show_animated() plt.show() def _fig_index(self, interface_name: str) -> int: From ddb6829b6a5d289e83a197981c3f0ce0d653ef0a Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Mon, 5 Jan 2026 16:54:40 +0000 Subject: [PATCH 07/14] separate set_metadata from figure creation; multiple transient working! --- .../systemcoupling/core/charts/plotter.py | 76 +++++++------------ 1 file changed, 26 insertions(+), 50 deletions(-) diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index deb836b8a..78526341a 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -22,7 +22,6 @@ import math import sys -import time from typing import Callable, Optional, Union from matplotlib.animation import FuncAnimation @@ -168,7 +167,7 @@ def __init__( self, plot_number: int, mgr: SubplotManager, - metadata: InterfaceInfo, + metadata: InterfaceInfo | None = None, request_update: Optional[Callable[[], None]] = None, ): self._mgr = mgr @@ -183,10 +182,18 @@ def __init__( self._times: list[float] = [] # Time value at each time step self._time_indexes: list[int] = [] # Iteration to take value at time i from - self._init_from_metadata() + if metadata: + self._init_from_metadata() self._animation: FuncAnimation | None = None + def set_metadata(self, metadata: InterfaceInfo): + if self._metadata: + raise RuntimeError("Attempt to set metadata more than once per figure.") + + self._metadata = metadata + self._init_from_metadata() + def _init_from_metadata(self): self._mgr.set_metadata(self._metadata) # We now have enough information to create the (empty) plots @@ -275,17 +282,6 @@ def close(self): plt.close(self._fig) def show_animated(self): - # NB: if using the wait_for_metadata() approach - # supported by MessageDispatcher, do it here like - # this (assume the wait function is stored as an - # attribute): - # - # assert_(self._wait_for_metadata is not None) - # metadata = self._wait_for_metadata() - # if metadata is not None: - # self.set_metadata(metadata) - # else: - # return assert_(self._request_update is not None) if self._animation is not None: @@ -356,15 +352,26 @@ def __init__( self._figures: list[FigurePlotter] = [] self._interface_to_figure_index: dict[str, int] = {} - self._is_animated: bool = False - self._pending_metadata: bool = False self._is_transient: bool | None = None - # Empty if not transient: + # Will remain empty if not transient: self._times: list[float] = [] # Time value at each time step self._time_indexes: list[int] = [] # Iteration to take value at time i from + self._init_figures() + + def _init_figures(self): + for ifig, intf_name in enumerate(self._mgr.interface_names): + self._interface_to_figure_index[intf_name] = ifig + self._figures.append( + FigurePlotter( + ifig + 1, + self._mgr.subplot_mgr(intf_name), + request_update=self._request_update, + ) + ) + def set_metadata(self, metadata: InterfaceInfo): if self._is_transient is None: self._is_transient = metadata.is_transient @@ -373,19 +380,8 @@ def set_metadata(self, metadata: InterfaceInfo): "Attempt to set metadata with inconsistent transient setting." ) - ifig = len(self._figures) - self._interface_to_figure_index[metadata.name] = ifig - self._figures.append( - FigurePlotter( - ifig + 1, - self._mgr.subplot_mgr(metadata.name), - metadata, - self._request_update, - ) - ) - if self._is_animated: - self._pending_metadata = False - self._figures[-1].show_animated() + ifig = self._fig_index(metadata.name) + self._figures[ifig].set_metadata(metadata) def set_timestep_data(self, timestep_data: TimestepData): @@ -416,23 +412,7 @@ def show_plot(self, noblock=False): plt.show() def show_animated(self): - # NB: if using the wait_for_metadata() approach - # supported by MessageDispatcher, do it here like - # this (assume the wait function is stored as an - # attribute): - # - # assert_(self._wait_for_metadata is not None) - # metadata = self._wait_for_metadata() - # if metadata is not None: - # self.set_metadata(metadata) - # else: - # return assert_(self._request_update is not None) - self._is_animated = True - self._pending_metadata = True - while self._pending_metadata: - self._request_update() - time.sleep(0.1) for fig in self._figures: fig.show_animated() @@ -445,7 +425,3 @@ def _fig_index(self, interface_name: str) -> int: f"'{interface_name}'." ) return self._interface_to_figure_index[interface_name] - - def _update_animation(self, frame: int): - # print("calling update animation") - return self._request_update() From 2a43b4955bd578c5399c97810a4328677b1582da Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Tue, 6 Jan 2026 17:18:53 +0000 Subject: [PATCH 08/14] clean up/isolate CSV-specific disambiguation --- .../core/adaptor/impl/injected_commands.py | 10 ++-- .../core/charts/chart_datatypes.py | 15 +++-- .../core/charts/csv_chartdata.py | 8 +-- .../core/charts/plot_functions.py | 12 ++-- .../core/charts/plotdefinition_manager.py | 60 ++++++++++--------- .../systemcoupling/core/charts/plotter.py | 9 +-- 6 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py index d8e90ac1e..472d63cf9 100644 --- a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py +++ b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py @@ -228,14 +228,12 @@ def _create_plot_spec( continue intf_spec = InterfaceSpec(interface_name, interface_disp_name) spec.interfaces.append(intf_spec) - transfer_disp_names = [ - interface_object.data_transfer[trans_name].display_name - for trans_name in transfer_names - ] - for transfer in transfer_disp_names: + for trans_name in transfer_names: + disp_name = interface_object.data_transfer[trans_name].display_name intf_spec.transfers.append( DataTransferSpec( - display_name=transfer, + name=trans_name, + display_name=disp_name, show_convergence=show_convergence, show_transfer_values=show_transfer_values, ) diff --git a/src/ansys/systemcoupling/core/charts/chart_datatypes.py b/src/ansys/systemcoupling/core/charts/chart_datatypes.py index dc75451e9..e3caae793 100644 --- a/src/ansys/systemcoupling/core/charts/chart_datatypes.py +++ b/src/ansys/systemcoupling/core/charts/chart_datatypes.py @@ -22,7 +22,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional """Common data types for the storage of metadata and data for chart series. @@ -51,10 +50,10 @@ class TransferSeriesInfo: The display name of the data transfer. This is a primary identifier for data transfers because CSV data sources do not currently include information about the underlying data model names of data transfers. - disambiguation_index: int - This should be set to 0, unless there is more than one data transfer with the - same display name. A contiguous range of indexes starting at 0 should be assigned - to the list of data transfers with the same display name. + transfer_id : str + The internal unique identifier of the data transfer. This could be the internal + datamodel name. In the CSV case, the internal name cannot be determined from the + data, so an ID based on the display name and an integer suffix is used. participant_display_name : str, optional The display name of the participant. This is required for transfer value series but not for convergence series. @@ -68,10 +67,10 @@ class TransferSeriesInfo: series_type: SeriesType transfer_display_name: str - disambiguation_index: int + transfer_id: str # Remainder for non-CONVERGENCE series only - participant_display_name: Optional[str] = None - component_suffix: Optional[str] = None + participant_display_name: str | None = None + component_suffix: str | None = None @dataclass diff --git a/src/ansys/systemcoupling/core/charts/csv_chartdata.py b/src/ansys/systemcoupling/core/charts/csv_chartdata.py index 3070987c5..800319b67 100644 --- a/src/ansys/systemcoupling/core/charts/csv_chartdata.py +++ b/src/ansys/systemcoupling/core/charts/csv_chartdata.py @@ -56,7 +56,7 @@ def read_data(self) -> bool: return True # File exists - haven't necessarily read anything yet except FileNotFoundError: # It is expected that the file is not necessarily immediately available - print(f"Failed to open {self._file_or_filename}") + # print(f"Failed to open {self._file_or_filename}") return False except Exception as e: # Temporary - see if anything else goes wrong @@ -234,7 +234,6 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo intf_info.is_transient = headers[2] == "Time" start_index = 3 if intf_info.is_transient else 2 - prev_part_name = "" transfer_disambig: dict[str, int] = {} for i in range(start_index, len(headers)): @@ -265,8 +264,7 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo series_info = TransferSeriesInfo( series_type=series_type, transfer_display_name=trans_disp_name, - # get(..., 0) for case where transfer_disambig empty (see note above) - disambiguation_index=transfer_disambig.get(trans_disp_name, 0), + transfer_id=f"{trans_disp_name}:{transfer_disambig.get(trans_disp_name, 0)}", ) intf_info.transfer_info.append(series_info) else: @@ -275,7 +273,7 @@ def parse_csv_metadata(interface_name: str, headers: list[str]) -> InterfaceInfo TransferSeriesInfo( series_type=series_type, transfer_display_name=trans_disp_name, - disambiguation_index=transfer_disambig.get(trans_disp_name, 0), + transfer_id=f"{trans_disp_name}:{transfer_disambig.get(trans_disp_name, 0)}", participant_display_name=part_disp_name, component_suffix=_parse_suffix(header, part_disp_name) or None, ) diff --git a/src/ansys/systemcoupling/core/charts/plot_functions.py b/src/ansys/systemcoupling/core/charts/plot_functions.py index 2ae551c46..37c6c20da 100644 --- a/src/ansys/systemcoupling/core/charts/plot_functions.py +++ b/src/ansys/systemcoupling/core/charts/plot_functions.py @@ -54,8 +54,10 @@ def cancel(self) -> None: ... def read_data(self) -> None: ... -def _create_and_show_impl(spec: PlotSpec, readers: list[ChartDataReader]) -> Plotter: - manager = PlotDefinitionManager(spec) +def _create_and_show_impl( + spec: PlotSpec, readers: list[ChartDataReader], is_csv_source: bool = False +) -> Plotter: + manager = PlotDefinitionManager(spec, is_csv_source=is_csv_source) plotter = Plotter(manager) for ireader, reader in enumerate(readers): @@ -89,15 +91,16 @@ def create_and_show_plot_csv(spec: PlotSpec, csv_list: list[str]) -> Plotter: CsvChartDataReader(intf.name, csvfile) for intf, csvfile in zip(spec.interfaces, csv_list) ] - return _create_and_show_impl(spec, readers) + return _create_and_show_impl(spec, readers, is_csv_source=True) def _solve_with_live_plot_impl( spec, make_live_data_source: Callable[[str, Callable], LiveDataSource], solve_func: Callable[[], None], + is_csv_source: bool = False, ): - manager = PlotDefinitionManager(spec) + manager = PlotDefinitionManager(spec, is_csv_source=is_csv_source) dispatcher = MessageDispatcher() plotter = Plotter(manager, request_update=dispatcher.dispatch_messages) dispatcher.set_plotter(plotter) @@ -138,6 +141,7 @@ def solve_with_live_plot_csv( interface_names, csv_list, put_msg ), solve_func, + is_csv_source=True, ) # Show a non-blocking static plot diff --git a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py index 18ebac8ed..3cac082e9 100644 --- a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py +++ b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py @@ -21,7 +21,6 @@ # SOFTWARE. from dataclasses import dataclass, field -from typing import Optional from ansys.systemcoupling.core.charts.chart_datatypes import InterfaceInfo, SeriesType @@ -31,6 +30,7 @@ class DataTransferSpec: # It's not ideal, but we have to work in terms of display names for transfers, # as that is all we have in the data (the CSV data, at least). # TODO: add optional internal name field which we will use if provided. + name: str display_name: str show_convergence: bool = True show_transfer_values: bool = True @@ -100,12 +100,15 @@ class SubplotDefinition: class SubplotManager: - def __init__(self, is_transient: bool, intf_spec: InterfaceSpec): + def __init__( + self, is_transient: bool, intf_spec: InterfaceSpec, is_csv_source: bool = False + ): self._is_time: bool = is_transient self._intf_spec = intf_spec + self._is_csv_source = is_csv_source self._conv_subplot: SubplotDefinition | None = None - self._transfer_subplots: dict[tuple[str, int], SubplotDefinition] = {} + self._transfer_subplots: dict[str, SubplotDefinition] = {} self._subplots: list[SubplotDefinition] = [] self._data_index_map: dict[int, tuple[SubplotDefinition, int]] = {} self._allocate_subplots() @@ -116,7 +119,7 @@ def subplots(self) -> list[SubplotDefinition]: def subplot_for_data_index( self, data_index: int - ) -> tuple[Optional[SubplotDefinition], int]: + ) -> tuple[SubplotDefinition | None, int]: """Return the subplot definition, and the line index within the subplot, corresponding to a given ``data_index``. @@ -166,10 +169,16 @@ def _allocate_subplots(self): keep_conv = False transfer_disambig: dict[str, int] = {} for transfer in self._intf_spec.transfers: - if transfer.display_name in transfer_disambig: - transfer_disambig[transfer.display_name] += 1 - else: - transfer_disambig[transfer.display_name] = 0 + if self._is_csv_source: + # In the CSV case, we have to create a unique ID for each transfer + # based on display name and an integer suffix, because we don't + # have internal names in the data. 'transfer_disambig' keeps track of + # how many times we've seen a given display name so far and we use that + # to create the unique ID. + if transfer.display_name in transfer_disambig: + transfer_disambig[transfer.display_name] += 1 + else: + transfer_disambig[transfer.display_name] = 0 if transfer.show_convergence: keep_conv = True if transfer.show_transfer_values: @@ -180,12 +189,12 @@ def _allocate_subplots(self): x_axis_label="Time" if self._is_time else "Iteration", y_axis_label="", ) - transfer_subplots[ - ( - transfer.display_name, - transfer_disambig[transfer.display_name], - ) - ] = transfer_value + if self._is_csv_source: + disambig = transfer_disambig.get(transfer.display_name, 0) + transfer_id = f"{transfer.display_name}:{disambig}" + else: + transfer_id = transfer.name + transfer_subplots[transfer_id] = transfer_value subplots.append(transfer_value) if keep_conv: self._conv_subplot = conv @@ -194,9 +203,7 @@ def _allocate_subplots(self): self._subplots = [subplot for subplot in subplots if subplot is not None] for i, subplot in enumerate(self._subplots): subplot.index = i - self._transfer_subplots: dict[tuple[str, int], SubplotDefinition] = ( - transfer_subplots - ) + self._transfer_subplots: dict[str, SubplotDefinition] = transfer_subplots def set_metadata(self, metadata: InterfaceInfo): """Reconcile the metadata for a single interface with the pre-allocated @@ -231,10 +238,7 @@ def set_metadata(self, metadata: InterfaceInfo): transfer_value_line_count: dict[tuple[str, int], int] = {} for data_index, transfer in enumerate(metadata.transfer_info): - transfer_key = ( - transfer.transfer_display_name, - transfer.disambiguation_index, - ) + transfer_key = transfer.transfer_id if transfer.series_type == SeriesType.CONVERGENCE: if transfer.transfer_display_name not in active_transfers: # We don't want this transfer on the convergence plot @@ -252,9 +256,7 @@ def set_metadata(self, metadata: InterfaceInfo): self._conv_subplot.series_labels.append(transfer.transfer_display_name) iconv += 1 else: - transfer_value_subplot = self._transfer_subplots.get( - (transfer_key[0], transfer_key[1]) - ) + transfer_value_subplot = self._transfer_subplots.get(transfer_key) if transfer_value_subplot: value_type = ( "Sum" @@ -282,14 +284,16 @@ def set_metadata(self, metadata: InterfaceInfo): class PlotDefinitionManager: - def __init__(self, spec: PlotSpec): + def __init__(self, spec: PlotSpec, is_csv_source: bool = False): self._subplot_mgrs: dict[str, SubplotManager] = {} - self._init(spec) + self._init(spec, is_csv_source) - def _init(self, spec: PlotSpec): + def _init(self, spec: PlotSpec, is_csv_source: bool): is_time = spec.plot_time for interface in spec.interfaces: - self._subplot_mgrs[interface.name] = SubplotManager(is_time, interface) + self._subplot_mgrs[interface.name] = SubplotManager( + is_time, interface, is_csv_source + ) @property def interface_names(self) -> list[str]: diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index 78526341a..61da40503 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -39,7 +39,8 @@ SubplotManager, ) from ansys.systemcoupling.core.util.assertion import assert_ -from ansys.systemcoupling.core.util.logging import LOG + +# from ansys.systemcoupling.core.util.logging import LOG def _process_timestep_data( @@ -290,18 +291,14 @@ def show_animated(self): self._animation = FuncAnimation( self._fig, self._update_animation, - # frames=x_axis_pts, save_count=sys.maxsize, - # init_func=self._init_plots, blit=False, interval=200, repeat=False, ) - # plt.show() def _update_animation(self, frame: int): - # print("calling update animation") - LOG.debug("FigurePlotter updating animation frame: %s", frame) + # LOG.debug("FigurePlotter updating animation frame: %s", frame) return self._request_update() def _init_plots(self): From be0e9607abd5418fc53380e0d6103bec90c75472 Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Wed, 7 Jan 2026 12:26:33 +0000 Subject: [PATCH 09/14] fix unit tests --- tests/charts/test_plotdefinition_manager.py | 34 ++++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tests/charts/test_plotdefinition_manager.py b/tests/charts/test_plotdefinition_manager.py index 7bf7134c0..762f2f247 100644 --- a/tests/charts/test_plotdefinition_manager.py +++ b/tests/charts/test_plotdefinition_manager.py @@ -35,10 +35,16 @@ def spec(): transfers = [ DataTransferSpec( - display_name="trans1", show_convergence=True, show_transfer_values=True + name="TRANS-1", + display_name="trans1", + show_convergence=True, + show_transfer_values=True, ), DataTransferSpec( - display_name="trans2", show_convergence=True, show_transfer_values=True + name="TRANS-2", + display_name="trans2", + show_convergence=True, + show_transfer_values=True, ), ] @@ -50,18 +56,30 @@ def spec(): def spec2(): transfers1 = [ DataTransferSpec( - display_name="trans1", show_convergence=True, show_transfer_values=True + name="TRANS-1", + display_name="trans1", + show_convergence=True, + show_transfer_values=True, ), DataTransferSpec( - display_name="trans2", show_convergence=True, show_transfer_values=True + name="TRANS-2", + display_name="trans2", + show_convergence=True, + show_transfer_values=True, ), ] transfers2 = [ DataTransferSpec( - display_name="Trans1", show_convergence=True, show_transfer_values=False + name="TRANS-1", + display_name="Trans1", + show_convergence=True, + show_transfer_values=False, ), DataTransferSpec( - display_name="Trans2", show_convergence=False, show_transfer_values=True + name="TRANS-2", + display_name="Trans2", + show_convergence=False, + show_transfer_values=True, ), ] @@ -134,7 +152,7 @@ def test_init_from_spec(spec): def test_set_metadata(spec, metadata): - pdm = PlotDefinitionManager(spec) + pdm = PlotDefinitionManager(spec, is_csv_source=True) mgr = pdm.subplot_mgr(metadata.name) mgr.set_metadata(metadata) @@ -149,7 +167,7 @@ def test_set_metadata(spec, metadata): def test_set_metadata_two_interfaces(spec2, metadata, metadata2): - pdm = PlotDefinitionManager(spec2) + pdm = PlotDefinitionManager(spec2, is_csv_source=True) mgr1 = pdm.subplot_mgr(metadata.name) mgr2 = pdm.subplot_mgr(metadata2.name) From bf2c4f71086a8588f0c6880111460868a2db2df1 Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Wed, 7 Jan 2026 16:09:32 +0000 Subject: [PATCH 10/14] fix set_timestep_data call --- src/ansys/systemcoupling/core/charts/plotter.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index 61da40503..3b5ca61aa 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -352,10 +352,6 @@ def __init__( self._is_transient: bool | None = None - # Will remain empty if not transient: - self._times: list[float] = [] # Time value at each time step - self._time_indexes: list[int] = [] # Iteration to take value at time i from - self._init_figures() def _init_figures(self): @@ -385,7 +381,8 @@ def set_timestep_data(self, timestep_data: TimestepData): if timestep_data.timestep and not self._is_transient: raise RuntimeError("Attempt to set timestep data on non-transient case") - self._time_indexes, self._times = _process_timestep_data(timestep_data) + for fig in self._figures: + fig.set_timestep_data(timestep_data) def update_line_series(self, series_data: SeriesData): """Update the line series determined by the provided ``series_data`` with the From f0c60ce70224f23d1d8fd99e905a4b064ef8b66a Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Wed, 14 Jan 2026 16:16:55 +0000 Subject: [PATCH 11/14] fix logic issue --- .../systemcoupling/core/adaptor/impl/injected_commands.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py index 472d63cf9..6b0c53604 100644 --- a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py +++ b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py @@ -318,8 +318,9 @@ def _get_interface_and_transfer_names( interface_names = setup.coupling_interface.get_object_names() if transfer_names is not None: - # There must be a single interface in this case or interface_name must be specified - if len(interface_names) != 1 or interface_name is not None: + # There must be a single interface in this case. + # Either interface_names has length 1 or interface_name must be specified + if len(interface_names) != 1 and interface_name is None: raise RuntimeError( "'transfer_names' cannot be used when there is more than " "one interface and 'interface_name' is not specified." From 84cf6ab44119b3715735fd404ae4c775df0807f1 Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Wed, 14 Jan 2026 16:23:18 +0000 Subject: [PATCH 12/14] remove unused function --- .../core/adaptor/impl/injected_commands.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py index 6b0c53604..7c8dbe9e8 100644 --- a/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py +++ b/src/ansys/systemcoupling/core/adaptor/impl/injected_commands.py @@ -242,20 +242,6 @@ def _create_plot_spec( return spec -def _get_interface_name( - session: SessionProtocol, interface_name: str | None = None -) -> str: - if interface_name is None: - setup = session.setup - interfaces = setup.coupling_interface.get_object_names() - if len(interfaces) == 0: - return - if len(interfaces) > 1: - raise RuntimeError("plots currently only support a single interface.") - interface_name = interfaces[0] - return interface_name - - def _get_interface_and_transfer_names( session: SessionProtocol, arg_dict: Dict[str, Any] ) -> dict[str, list]: From bf9ed2a1a4fbc32f96b63839e34d0f4fe4df0aaf Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Wed, 14 Jan 2026 17:30:28 +0000 Subject: [PATCH 13/14] fix some issues revealed by tests and github doc build --- .../systemcoupling/core/charts/plotdefinition_manager.py | 4 ++++ src/ansys/systemcoupling/core/charts/plotter.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py index 3cac082e9..f5f93ff26 100644 --- a/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py +++ b/src/ansys/systemcoupling/core/charts/plotdefinition_manager.py @@ -243,6 +243,10 @@ def set_metadata(self, metadata: InterfaceInfo): if transfer.transfer_display_name not in active_transfers: # We don't want this transfer on the convergence plot continue + if self._conv_subplot is None: + # This will be the case if the plot spec had `show_convergence=False` + continue + # Clear the entry in case transfer names are not unique. (If another transfer # with the same name needs plotting, then it should appear as a second entry # in active_transfers.) diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index 3b5ca61aa..4ca40c029 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -396,7 +396,7 @@ def update_line_series(self, series_data: SeriesData): def close(self): for fig in self._figures: - plt.close(fig) + fig.close() def show_plot(self, noblock=False): if noblock: From 31be11fb1e83c238a0c5eb856542cb092d49b42c Mon Sep 17 00:00:00 2001 From: Ian Boyd Date: Tue, 20 Jan 2026 14:22:26 +0000 Subject: [PATCH 14/14] improve y-axis range update algorithm; streamline time data handling --- .../systemcoupling/core/charts/plotter.py | 55 +++++++++++-------- tests/charts/test_plotter.py | 11 +++- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/ansys/systemcoupling/core/charts/plotter.py b/src/ansys/systemcoupling/core/charts/plotter.py index 4ca40c029..a9c08fc4c 100644 --- a/src/ansys/systemcoupling/core/charts/plotter.py +++ b/src/ansys/systemcoupling/core/charts/plotter.py @@ -70,31 +70,45 @@ def _process_timestep_data( def _calc_new_ylimits_linear( ynew: list[float], old_lim: Optional[tuple[float, float]] ) -> tuple[float, float]: - resize_tol = 0.02 - resize_delta = 0.1 + + resize_factor = 0.1 + resize_tol = 0.01 min_y = min(ynew) max_y = max(ynew) - # Try to do something reasonable while we still don't have much data. - # Try to account for case where we don't have much data and range & value both zero. - data_range = min_y if abs(max_y - min_y) < 1.0e-7 else max_y - min_y - data_range = abs(data_range) - if data_range < 1.0e-7: - delta_limits = 1.0e-7 + data_range = abs(max_y - min_y) + if data_range == 0: + # NB: min and max are equal - use the value to define the range + if abs(min_y) > 0: + delta_limits = abs(min_y) * resize_factor + else: + # Arbitrary value for now (will need to make sure it doesn't "stick") + delta_limits = 1e-7 else: - delta_limits = resize_delta * data_range + delta_limits = data_range * resize_factor + delta_tol = resize_tol * data_range + force_tol = 2 * delta_tol + 1 if old_lim is None: - # First update - force calculation - old_l = min_y + 1 - old_u = max_y - 1 + # Force calculation on first update + old_l = min_y + force_tol + old_u = max_y - force_tol else: new_l, new_u = old_l, old_u = old_lim - - if min_y < old_l + resize_tol * data_range: + # In the case where we guessed limits for zero data and now have + # some non-zero data, need to adjust limits downwards if the actual + # data range is significantly smaller than current limits range. + old_delta = old_u - old_l + if data_range < old_delta * resize_factor: + # Force recalculation + old_l = min_y + force_tol + old_u = max_y - force_tol + + # Only extend the limits if we are getting close to the old ones + if min_y < old_l + delta_tol: new_l = min_y - delta_limits - if max_y > old_u - resize_tol * data_range: + if max_y > old_u - delta_tol: new_u = max_y + delta_limits return new_l, new_u @@ -201,12 +215,8 @@ def _init_from_metadata(self): self._init_plots() self._fig.suptitle(f"Interface: {self._metadata.name}", fontsize=10) - def set_timestep_data(self, timestep_data: TimestepData): - - if timestep_data.timestep and not self._metadata.is_transient: - raise RuntimeError("Attempt to set timestep data on non-transient case") - - self._time_indexes, self._times = _process_timestep_data(timestep_data) + def set_timestep_data(self, timestep_data: tuple[list[int], list[float]]): + self._time_indexes, self._times = timestep_data def update_line_series(self, series_data: SeriesData): """Update the line series determined by the provided ``series_data`` with the @@ -381,8 +391,9 @@ def set_timestep_data(self, timestep_data: TimestepData): if timestep_data.timestep and not self._is_transient: raise RuntimeError("Attempt to set timestep data on non-transient case") + processed_timestep_data = _process_timestep_data(timestep_data) for fig in self._figures: - fig.set_timestep_data(timestep_data) + fig.set_timestep_data(processed_timestep_data) def update_line_series(self, series_data: SeriesData): """Update the line series determined by the provided ``series_data`` with the diff --git a/tests/charts/test_plotter.py b/tests/charts/test_plotter.py index 12dcfe2e3..4844d9ab2 100644 --- a/tests/charts/test_plotter.py +++ b/tests/charts/test_plotter.py @@ -43,10 +43,19 @@ (0.45, 0.55), ), ([0.0, 0.0], (-1e-7, 1e-7)), + ([0.0, 1e-8], (-1e-9, 1.1e-8)), ([0.5, 0.5], (0.45, 0.55)), ([-0.1, 10.0], (-1.11, 11.01)), ([0.1, 0.5, 0.92], (0.018, 1.002)), ([0.0, 1.0], (-0.1, 1.1)), + ( + [0.0, 1.0, 0.9], + (-0.1, 1.1), + ), + ( + [0.0, 1.0, 0.9], + (-0.1, 1.1), + ), ], ) def test_new_linear_limits_uninitialised(ynew, expected): @@ -86,7 +95,7 @@ def test_new_linear_limits_uninitialised(ynew, expected): ( (0.0, 0.9), [1.0], - (0.0, 1.1), + (0.9, 1.1), ), ], )