From fcc77d6d6c7e3cc79d0081601a0c5f6272dbd618 Mon Sep 17 00:00:00 2001 From: ses Date: Wed, 4 Jun 2025 18:20:33 +0200 Subject: [PATCH 01/26] first shot on mas dashboard --- agentlib/core/module.py | 42 +++- agentlib/modules/simulation/simulator.py | 64 ++++++ agentlib/modules/utils/agent_logger.py | 103 +++++++++ agentlib/utils/multi_agent_system.py | 29 +++ agentlib/utils/plotting/mas_dashboard.py | 199 ++++++++++++++++++ .../room_mas/configs/Room1.json | 1 + .../multi-agent-systems/room_mas/room_mas.py | 7 +- 7 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 agentlib/utils/plotting/mas_dashboard.py diff --git a/agentlib/core/module.py b/agentlib/core/module.py index 10583f83..5f0304d1 100644 --- a/agentlib/core/module.py +++ b/agentlib/core/module.py @@ -32,7 +32,7 @@ AttrsToPydanticAdaptor, ) from agentlib.core.environment import CustomSimpyEnvironment -from agentlib.core.errors import ConfigurationError +from agentlib.core.errors import ConfigurationError, OptionalDependencyError from agentlib.utils.fuzzy_matching import fuzzy_match, RAPIDFUZZ_IS_INSTALLED from agentlib.utils.validators import ( include_defaults_in_root, @@ -44,6 +44,7 @@ if TYPE_CHECKING: # this avoids circular import from agentlib.core import Agent + from dash import html # For type hinting logger = logging.getLogger(__name__) @@ -657,6 +658,45 @@ def _copy_list_to_dict(ls: List[AgentVariable]): # pylint: disable=invalid-name return {var.name: var for var in ls.copy()} + @classmethod + def visualize_results( + cls, results_data: Any, module_id: str, agent_id: str + ) -> Optional["html.Div"]: + """ + Generate a visualization for the module's results. + This method should be overridden by subclasses to provide + custom visualizations. + + Args: + results_data: The data returned by the module's get_results() method. + module_id: The ID of the module instance. + agent_id: The ID of the agent this module belongs to. + + Returns: + A Dash HTML component (e.g., dash.html.Div) containing the visualization, + or None if not implemented or Dash is unavailable. + """ + try: + # Import here to avoid circular dependency issues at module load time + # and to only require Dash when this method is actually called. + from dash import html as dash_html + except ImportError: + raise OptionalDependencyError( + used_object=f"{cls.__name__}.visualize_results", + dependency_install="agentlib[interactive]", + dependency_name="interactive", + ) + # Base implementation returns None, indicating no visualization is provided. + # Subclasses should override this method. + logger.debug( + "Visualization not implemented for module type %s (module_id: '%s', agent_id: '%s'). " + "Returning None.", + cls.__name__, + module_id, + agent_id, + ) + return None + def get_results(self): """ Returns results of this modules run. diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index dae366e1..87496184 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -26,6 +26,8 @@ from agentlib.core.errors import OptionalDependencyError from agentlib.models import get_model_type, UNINSTALLED_MODEL_TYPES from agentlib.utils import custom_injection +import pandas as pd +from typing import Optional @dataclass @@ -500,6 +502,68 @@ def cleanup_results(self): os.remove(self.config.result_filename) + @classmethod + def visualize_results( + cls, results_data: pd.DataFrame, module_id: str, agent_id: str + ) -> "Optional[html.Div]": + try: + from dash import dcc, html + import plotly.graph_objs as go + except ImportError: + raise OptionalDependencyError( + used_object=f"{cls.__name__}.visualize_results", + dependency_install="agentlib[interactive]", + dependency_name="interactive", + ) + + if results_data is None or results_data.empty: + cls.logger.debug(f"No results data for Simulator '{module_id}' in agent '{agent_id}'.") + return None + + if not isinstance(results_data, pd.DataFrame): + cls.logger.error( + f"Expected pandas DataFrame for Simulator results for '{module_id}', got {type(results_data)}." + ) + return None + + plots = [] + try: + for column in results_data.columns: + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=results_data.index, + y=results_data[column], + mode="lines", + name=str(column), + ) + ) + fig.update_layout( + title=str(column), # Use str(column) in case column name is not a string + xaxis_title="Time", + yaxis_title="Value", + margin=dict(l=40, r=20, t=40, b=30), + height=300, + ) + plots.append(dcc.Graph(figure=fig)) + except Exception as e: + cls.logger.error(f"Error creating plots for Simulator '{module_id}': {e}") + return None # Return None on plotting error + + if not plots: + cls.logger.debug(f"No plottable data generated for Simulator '{module_id}'.") + return None + + return html.Div( + children=[ + html.H4( + f"Simulator Results: {module_id} (Agent: {agent_id})" + ) + ] + + plots, + style={"padding": "10px"}, + ) + def _update_results(self): """ Adds model variables to the SimulationResult object diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index 671d3de3..23d6748d 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -16,6 +16,9 @@ from agentlib import AgentVariable from agentlib.core import BaseModule, Agent, BaseModuleConfig +from agentlib.core.errors import OptionalDependencyError +from typing import Optional +import pandas as pd logger = logging.getLogger(__name__) @@ -216,3 +219,103 @@ def terminate(self): # when terminating, we log one last time, since otherwise the data since the # last log interval is lost self._log() + + @classmethod + def visualize_results( + cls, results_data: pd.DataFrame, module_id: str, agent_id: str + ) -> "Optional[html.Div]": + try: + from dash import dcc, html + import plotly.graph_objs as go + except ImportError: + raise OptionalDependencyError( + used_object=f"{cls.__name__}.visualize_results", + dependency_install="agentlib[interactive]", + dependency_name="interactive", + ) + + if results_data is None or results_data.empty: + cls.logger.debug(f"No results data for AgentLogger '{module_id}' in agent '{agent_id}'.") + return None + + if not isinstance(results_data, pd.DataFrame): + cls.logger.error( + f"Expected pandas DataFrame for AgentLogger results for '{module_id}', got {type(results_data)}." + ) + return None + + # AgentLogger results often have MultiIndex columns: (alias, source_info_string) + # or just alias if merge_sources=True was used in load_from_file. + # We'll try to plot each series. + + plots = [] + try: + for col_name in results_data.columns: + series = results_data[col_name].dropna() + if series.empty: + continue + + # Attempt to convert to numeric if possible, otherwise plot as categorical/occurrence + try: + numeric_series = pd.to_numeric(series) + is_numeric = True + except (ValueError, TypeError): + is_numeric = False + numeric_series = series # Keep original for hover text + + fig = go.Figure() + if is_numeric: + fig.add_trace( + go.Scatter( + x=series.index, # Timestamps + y=numeric_series, + mode="lines+markers", + name=str(col_name), + ) + ) + y_axis_title = "Value" + else: + # For non-numeric, plot occurrences or categorical data + # A simple way is to plot markers at y=1 and use hover text + fig.add_trace( + go.Scatter( + x=series.index, + y=[1] * len(series), # Plot all at y=1 + mode="markers", + name=str(col_name), + hovertext=[str(v) for v in series.values], + hoverinfo="x+text" + ) + ) + y_axis_title = "Occurrence" + + title = str(col_name) + if isinstance(col_name, tuple): # Handle MultiIndex columns + title = f"{col_name[0]} (Source: {col_name[1]})" + + + fig.update_layout( + title=title, + xaxis_title="Time", + yaxis_title=y_axis_title, + margin=dict(l=40, r=20, t=60, b=30), # Increased top margin for title + height=300, + ) + plots.append(dcc.Graph(figure=fig)) + except Exception as e: + cls.logger.error(f"Error creating plots for AgentLogger '{module_id}': {e}") + return None + + if not plots: + cls.logger.debug(f"No plottable data generated for AgentLogger '{module_id}'.") + return None + + return html.Div( + children=[ + html.H4( + f"AgentLogger Results: {module_id} (Agent: {agent_id})" + ) + ] + + plots, + style={"padding": "10px"}, + ) diff --git a/agentlib/utils/multi_agent_system.py b/agentlib/utils/multi_agent_system.py index da7e6690..602c46e2 100644 --- a/agentlib/utils/multi_agent_system.py +++ b/agentlib/utils/multi_agent_system.py @@ -166,6 +166,35 @@ def terminate_agents(self): for agent in self._agents.values(): agent.terminate() + def show_results_dashboard(self, cleanup_results: bool = False): + """ + Launches an interactive dashboard to visualize the results of the MAS. + + Args: + cleanup_results (bool): If True, result files might be cleaned up + by modules after being read for the dashboard. + Defaults to False to preserve results. + """ + logger.info("Launching MAS Results Dashboard...") + try: + from agentlib.utils.plotting.mas_dashboard import launch_mas_dashboard + except ImportError: + logger.error( + "Failed to import launch_mas_dashboard. Ensure Dash and its dependencies are installed: " + "pip install agentlib[interactive]" + ) + return + + results = self.get_results(cleanup=cleanup_results) + if not results: + logger.warning("No results found to display in the dashboard.") + return + + try: + launch_mas_dashboard(mas=self, mas_results=results) + except Exception as e: + logger.error(f"Error launching MAS dashboard: {e}", exc_info=True) + def setup_agent(self, id: str) -> Agent: """Setup the agent matching the given id""" # pylint: disable=redefined-builtin diff --git a/agentlib/utils/plotting/mas_dashboard.py b/agentlib/utils/plotting/mas_dashboard.py new file mode 100644 index 00000000..1b1bd37a --- /dev/null +++ b/agentlib/utils/plotting/mas_dashboard.py @@ -0,0 +1,199 @@ +""" +Dashboard for visualizing results from a Multi-Agent System (MAS). +Allows selecting an agent and then a module within that agent to display +its specific results visualization. +""" + +import logging +import socket +import webbrowser + +try: + import dash + from dash import dcc, html + from dash.dependencies import Input, Output + import dash_bootstrap_components as dbc +except ImportError: + raise ImportError( + "Dash is not installed. Please install it to use the MAS dashboard: " + "pip install agentlib[interactive]" + ) + +from agentlib.core.errors import OptionalDependencyError +from agentlib.utils.multi_agent_system import LocalMASAgency + +logger = logging.getLogger(__name__) + + +def get_free_port(): + """Get an available port by trying to bind to a socket.""" + port = 8050 + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("localhost", port)) + return port + except OSError: + port += 1 + + +def launch_mas_dashboard(mas: LocalMASAgency, mas_results: dict): + """ + Launches the MAS results dashboard. + + Args: + mas: The LocalMASAgency instance. + mas_results: A dictionary of results, typically from mas.get_results(). + Expected structure: {agent_id: {module_id: results_data}} + """ + app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) + app.title = "MAS Results Dashboard" + + agent_ids = list(mas_results.keys()) + + app.layout = dbc.Container( + [ + html.H1("Multi-Agent System Results Dashboard", className="my-4"), + dbc.Row( + [ + dbc.Col( + [ + html.Label("Select Agent:"), + dcc.Dropdown( + id="agent-selector", + options=[ + {"label": agent_id, "value": agent_id} + for agent_id in agent_ids + ], + value=agent_ids[0] if agent_ids else None, + clearable=False, + ), + ], + width=6, + ), + dbc.Col( + [ + html.Label("Select Module:"), + dcc.Dropdown( + id="module-selector", + options=[], # Populated by callback + clearable=False, + ), + ], + width=6, + ), + ], + className="mb-4", + ), + dbc.Row( + dbc.Col( + html.Div(id="module-visualization-content"), + ) + ), + ], + fluid=True, + ) + + @app.callback( + [Output("module-selector", "options"), Output("module-selector", "value")], + [Input("agent-selector", "value")], + ) + def update_module_dropdown(selected_agent_id): + if not selected_agent_id or not mas_results: + return [], None + + agent_modules_data = mas_results.get(selected_agent_id, {}) + module_options = [] + first_module_id = None + + agent_instance = mas._agents.get(selected_agent_id) + if not agent_instance: + return [], None + + for module_instance in agent_instance.modules: + module_id = module_instance.id + if module_id in agent_modules_data: + module_options.append({"label": module_id, "value": module_id}) + if first_module_id is None: + first_module_id = module_id + + return module_options, first_module_id + + @app.callback( + Output("module-visualization-content", "children"), + [Input("agent-selector", "value"), Input("module-selector", "value")], + ) + def display_module_visualization(selected_agent_id, selected_module_id): + if not selected_agent_id or not selected_module_id or not mas_results: + return html.P("Select an agent and a module to view visualization.") + + agent_instance = mas._agents.get(selected_agent_id) + if not agent_instance: + return html.P(f"Agent '{selected_agent_id}' not found in MAS instance.") + + module_instance = next( + (m for m in agent_instance.modules if m.id == selected_module_id), None + ) + if not module_instance: + return html.P( + f"Module '{selected_module_id}' not found in agent '{selected_agent_id}'." + ) + + results_data = mas_results.get(selected_agent_id, {}).get( + selected_module_id, "__no_key__" + ) + + if isinstance(results_data, str) and results_data == "__no_key__": + return html.P( + f"No results data key found for module '{selected_module_id}' in agent '{selected_agent_id}'." + ) + + ModuleType = type(module_instance) + try: + # results_data here can be the actual data (DataFrame, None, etc.) + visualization_layout = ModuleType.visualize_results( + results_data=results_data, # This will be passed to the module's visualize_results + module_id=selected_module_id, + agent_id=selected_agent_id, + ) + if visualization_layout is None: + return html.P( + f"Visualization not implemented or not available for module " + f"'{selected_module_id}' (type: {ModuleType.__name__}) in agent '{selected_agent_id}'." + ) + return visualization_layout + except OptionalDependencyError as e: + logger.error(f"Optional dependency error for {ModuleType.__name__}: {e}") + return html.Div( + [ + html.H4("Visualization Error"), + html.P( + f"Could not render visualization for {ModuleType.__name__}." + ), + html.P(str(e)), + html.P("Please ensure all necessary libraries are installed."), + ] + ) + except Exception as e: + logger.error( + f"Error generating visualization for module '{selected_module_id}' " + f"(type: {ModuleType.__name__}) in agent '{selected_agent_id}': {e}", + exc_info=True, + ) + return html.P( + f"An error occurred while generating the visualization for " + f"module '{selected_module_id}': {str(e)}" + ) + + port = get_free_port() + webbrowser.open_new_tab(f"http://localhost:{port}") + logger.info(f"MAS Dashboard is running on http://localhost:{port}") + app.run_server(debug=False, port=port, host="0.0.0.0") + + +if __name__ == "__main__": + print( + "To run this dashboard, import and call launch_mas_dashboard " + "with a LocalMASAgency instance and its results." + ) + pass diff --git a/examples/multi-agent-systems/room_mas/configs/Room1.json b/examples/multi-agent-systems/room_mas/configs/Room1.json index 28efa38d..e9c0250b 100644 --- a/examples/multi-agent-systems/room_mas/configs/Room1.json +++ b/examples/multi-agent-systems/room_mas/configs/Room1.json @@ -12,6 +12,7 @@ "t_sample": 50, "save_results": true, "result_filename": "res_room1.csv", + "overwrite_result_file": true, "inputs": [ { "name": "Q_flow_heat", diff --git a/examples/multi-agent-systems/room_mas/room_mas.py b/examples/multi-agent-systems/room_mas/room_mas.py index be21e20c..57194e9d 100644 --- a/examples/multi-agent-systems/room_mas/room_mas.py +++ b/examples/multi-agent-systems/room_mas/room_mas.py @@ -31,6 +31,10 @@ def run_example(until, with_plots=True, log_level=logging.INFO): # Simulate mas.run(until=until) # Load results: + try: + mas.show_results_dashboard(cleanup_results=False) # Keep results for dashboard + except Exception as e: + logger.error(f"Could not launch MAS dashboard: {e}") results = mas.get_results(cleanup=True) if not with_plots: @@ -96,8 +100,9 @@ def run_example(until, with_plots=True, log_level=logging.INFO): ) plt.show() + return results if __name__ == "__main__": - run_example(until=86400 / 10, with_plots=True) + run_example(until=86400 / 10, with_plots=False) From c6e33584c8d65b24ed21f16bc120f8b1eb41445f Mon Sep 17 00:00:00 2001 From: ses Date: Thu, 5 Jun 2025 14:33:47 +0200 Subject: [PATCH 02/26] add changelog and make dashboard more compact --- CHANGELOG.md | 3 + agentlib/__init__.py | 2 +- agentlib/modules/simulation/simulator.py | 30 +++++--- agentlib/modules/utils/agent_logger.py | 95 +++++++++++++++--------- 4 files changed, 82 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 892d0542..4b789066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.9.0 +- Add an optional dashboard function to the mas utility. + ## 0.8.8 - Percentage display in logging now only appears in simulation speed, not realtime. Fixed percentage when environment has an offset. diff --git a/agentlib/__init__.py b/agentlib/__init__.py index 2de445ef..0f39bf55 100644 --- a/agentlib/__init__.py +++ b/agentlib/__init__.py @@ -12,7 +12,7 @@ LocalCloneMAPAgency, ) -__version__ = "0.8.8" +__version__ = "0.9.0" __all__ = [ "core", diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 87496184..ca485ab3 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -509,6 +509,7 @@ def visualize_results( try: from dash import dcc, html import plotly.graph_objs as go + import dash_bootstrap_components as dbc except ImportError: raise OptionalDependencyError( used_object=f"{cls.__name__}.visualize_results", @@ -526,9 +527,10 @@ def visualize_results( ) return None - plots = [] + rows = [] + current_row_children = [] try: - for column in results_data.columns: + for i, column in enumerate(results_data.columns): fig = go.Figure() fig.add_trace( go.Scatter( @@ -539,28 +541,36 @@ def visualize_results( ) ) fig.update_layout( - title=str(column), # Use str(column) in case column name is not a string + title=str(column), xaxis_title="Time", yaxis_title="Value", - margin=dict(l=40, r=20, t=40, b=30), - height=300, + margin=dict(l=40, r=20, t=40, b=30), # Compact margins + height=250, # Reduced height for compactness ) - plots.append(dcc.Graph(figure=fig)) + # Add plot to a column, aim for 2 plots per row on medium screens + current_row_children.append(dbc.Col(dcc.Graph(figure=fig), md=6)) + + # If two plots are in the current row, or it's the last plot + if len(current_row_children) == 2 or i == len(results_data.columns) - 1: + rows.append(dbc.Row(current_row_children, className="mb-3")) + current_row_children = [] + except Exception as e: cls.logger.error(f"Error creating plots for Simulator '{module_id}': {e}") - return None # Return None on plotting error + return None - if not plots: + if not rows: cls.logger.debug(f"No plottable data generated for Simulator '{module_id}'.") return None return html.Div( children=[ html.H4( - f"Simulator Results: {module_id} (Agent: {agent_id})" + f"Simulator Results: {module_id} (Agent: {agent_id})", + className="mb-3" # Add some margin below the title ) ] - + plots, + + rows, # Add the list of dbc.Row components style={"padding": "10px"}, ) diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index 23d6748d..9384ce2e 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -17,8 +17,6 @@ from agentlib import AgentVariable from agentlib.core import BaseModule, Agent, BaseModuleConfig from agentlib.core.errors import OptionalDependencyError -from typing import Optional -import pandas as pd logger = logging.getLogger(__name__) @@ -50,7 +48,12 @@ class AgentLoggerConfig(BaseModuleConfig): filename: Optional[str] = Field( title="filename", default=None, - description="The filename where the log is stored. If None, will use 'agent_logs/{agent_id}_log.json'", + description="The filename where the log is stored. If None, will use 'agent_logs/{agent_id}_log.jsonl'", + ) + merge_sources: bool = Field( + title="Merge Sources", + default=True, + description="When loading the results file, automatically merges variables by sources, leaving only alias in the columns.", ) @field_validator("filename") @@ -201,7 +204,9 @@ def _load_agent_variable(var): def get_results(self) -> pd.DataFrame: """Load the own filename""" return self.load_from_file( - filename=self.filename, values_only=self.config.values_only + filename=self.filename, + values_only=self.config.values_only, + merge_sources=self.config.merge_sources, ) def cleanup_results(self): @@ -227,6 +232,7 @@ def visualize_results( try: from dash import dcc, html import plotly.graph_objs as go + import dash_bootstrap_components as dbc # Added for responsive layout except ImportError: raise OptionalDependencyError( used_object=f"{cls.__name__}.visualize_results", @@ -235,7 +241,9 @@ def visualize_results( ) if results_data is None or results_data.empty: - cls.logger.debug(f"No results data for AgentLogger '{module_id}' in agent '{agent_id}'.") + cls.logger.debug( + f"No results data for AgentLogger '{module_id}' in agent '{agent_id}'." + ) return None if not isinstance(results_data, pd.DataFrame): @@ -243,79 +251,92 @@ def visualize_results( f"Expected pandas DataFrame for AgentLogger results for '{module_id}', got {type(results_data)}." ) return None - - # AgentLogger results often have MultiIndex columns: (alias, source_info_string) - # or just alias if merge_sources=True was used in load_from_file. - # We'll try to plot each series. - plots = [] + rows = [] + current_row_children = [] try: - for col_name in results_data.columns: + for i, col_name in enumerate(results_data.columns): series = results_data[col_name].dropna() if series.empty: continue - # Attempt to convert to numeric if possible, otherwise plot as categorical/occurrence try: - numeric_series = pd.to_numeric(series) - is_numeric = True + numeric_series = pd.to_numeric(series, errors="coerce") + if numeric_series.isnull().all() and not series.isnull().all(): + is_numeric = False + else: + is_numeric = True + series_to_plot = numeric_series except (ValueError, TypeError): is_numeric = False - numeric_series = series # Keep original for hover text + + if not is_numeric: + series_to_plot = series fig = go.Figure() + y_axis_title = "Value" + if is_numeric: fig.add_trace( go.Scatter( - x=series.index, # Timestamps - y=numeric_series, + x=series_to_plot.index, + y=series_to_plot, mode="lines+markers", name=str(col_name), ) ) - y_axis_title = "Value" else: - # For non-numeric, plot occurrences or categorical data - # A simple way is to plot markers at y=1 and use hover text fig.add_trace( go.Scatter( - x=series.index, - y=[1] * len(series), # Plot all at y=1 + x=series_to_plot.index, + y=[1] * len(series_to_plot), mode="markers", name=str(col_name), - hovertext=[str(v) for v in series.values], - hoverinfo="x+text" + hovertext=[str(v) for v in series_to_plot.values], + hoverinfo="x+text", ) ) y_axis_title = "Occurrence" - - title = str(col_name) - if isinstance(col_name, tuple): # Handle MultiIndex columns - title = f"{col_name[0]} (Source: {col_name[1]})" + title_str = str(col_name) + if isinstance(col_name, tuple) and len(col_name) > 0: + title_str = f"{col_name[0]}" + if len(col_name) > 1 and col_name[1]: + title_str += f" (Source: {col_name[1]})" fig.update_layout( - title=title, + title=title_str, xaxis_title="Time", yaxis_title=y_axis_title, - margin=dict(l=40, r=20, t=60, b=30), # Increased top margin for title - height=300, + margin=dict(l=40, r=20, t=40, b=30), + height=250, ) - plots.append(dcc.Graph(figure=fig)) + current_row_children.append(dbc.Col(dcc.Graph(figure=fig), md=6)) + + if len(current_row_children) == 2 or i == len(results_data.columns) - 1: + rows.append(dbc.Row(current_row_children, className="mb-3")) + current_row_children = [] + except Exception as e: - cls.logger.error(f"Error creating plots for AgentLogger '{module_id}': {e}") + cls.logger.error( + f"Error creating plots for AgentLogger '{module_id}': {e}", + exc_info=True, + ) return None - if not plots: - cls.logger.debug(f"No plottable data generated for AgentLogger '{module_id}'.") + if not rows: + cls.logger.debug( + f"No plottable data generated for AgentLogger '{module_id}'." + ) return None return html.Div( children=[ html.H4( - f"AgentLogger Results: {module_id} (Agent: {agent_id})" + f"AgentLogger Results: {module_id} (Agent: {agent_id})", + className="mb-3", ) ] - + plots, + + rows, style={"padding": "10px"}, ) From abad57237efde6a2500f3bf013d313c33426823e Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 6 Jun 2025 13:38:03 +0200 Subject: [PATCH 03/26] make dashboard run in separate process and start with communicator results and vis --- agentlib/modules/communicator/communicator.py | 497 ++++++++++++++++-- agentlib/utils/multi_agent_system.py | 11 +- agentlib/utils/plotting/mas_dashboard.py | 200 +++++-- .../multi-agent-systems/room_mas/room_mas.py | 6 +- 4 files changed, 623 insertions(+), 91 deletions(-) diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index d17ba881..99f14517 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -3,10 +3,13 @@ """ import abc +import collections import json +import os import queue import threading -from typing import Union, List, TypedDict, Any +from pathlib import Path +from typing import Union, List, TypedDict, Any, Optional, Literal import pandas as pd from pydantic import Field, field_validator @@ -33,6 +36,27 @@ class CommunicatorConfig(BaseModuleConfig): description="If true, the faster orjson library will be used for serialization " "deserialization. Requires the optional dependency.", ) + communication_log_level: Literal["none", "basic", "detail"] = Field( + default="none", + title="Communication Log Level", + description="Level of communication logging: 'none', 'basic', 'detail'.", + ) + communication_log_file: Optional[str] = Field( + default=None, + title="Communication Log File", + description="Filename for 'detail' logging. Defaults to 'communicator_logs/{agent_id}_{module_id}.jsonl'.", + ) + communication_log_overwrite: bool = Field( + default=False, + title="Overwrite Communication Log", + description="If true, existing log file will be overwritten at the start.", + ) + communication_log_t_sample: Union[float, int] = Field( + default=300, + title="Communication Log Sampling Time", + description="Interval in seconds for batch writing 'detail' logs to file. Only for non-realtime.", + ge=0, + ) class SubscriptionCommunicatorConfig(CommunicatorConfig): @@ -50,10 +74,23 @@ class Communicator(BaseModule): """ config: CommunicatorConfig + parse_json = True def __init__(self, *, config: dict, agent: Agent): super().__init__(config=config, agent=agent) + # Initialize logging attributes based on the validated config + self._sent_alias_counts: Optional[collections.Counter] = None + self._received_source_alias_counts: Optional[collections.Counter] = None + self._communication_log_filename: Optional[str] = None + self._communication_log_batch: Optional[List[dict]] = None + + if self.config.communication_log_level == "basic": + self._sent_alias_counts = collections.Counter() + self._received_source_alias_counts = collections.Counter() + elif self.config.communication_log_level == "detail": + self._init_detail_logging() + if self.config.use_orjson: try: import orjson @@ -75,6 +112,72 @@ def _to_json_builtin(payload: CommunicationDict) -> str: self.to_json = _to_json_builtin + def _init_detail_logging(self): + """Helper to initialize attributes for 'detail' logging.""" + self._communication_log_batch = [] + if self.config.communication_log_file is None: + logs_dir = Path("communicator_logs") + logs_dir.mkdir(parents=True, exist_ok=True) + self._communication_log_filename = str( + logs_dir / f"{self.agent.id}_{self.id}.jsonl" + ) + else: + self._communication_log_filename = self.config.communication_log_file + # Ensure parent directory exists if a custom path is given + Path(self._communication_log_filename).parent.mkdir( + parents=True, exist_ok=True + ) + + if ( + Path(self._communication_log_filename).exists() + and self.config.communication_log_overwrite + ): + try: + os.remove(self._communication_log_filename) + self.logger.info( + f"Overwriting existing communication log: {self._communication_log_filename}" + ) + except OSError as e: + self.logger.error( + f"Could not remove communication log file {self._communication_log_filename} for overwrite: {e}" + ) + + # For non-realtime, schedule periodic flushing + if not self.env.config.rt and self.config.communication_log_t_sample > 0: + self.env.process(self._log_detail_process()) + elif self.config.communication_log_t_sample <= 0 and not self.env.config.rt: + self.logger.info( + "communication_log_t_sample <= 0, detail logs will only be written on terminate." + ) + + def _log_detail_process(self): + """SimPy process to periodically flush detail logs.""" + while True: + yield self.env.timeout(self.config.communication_log_t_sample) + self._flush_detail_log() + + def _flush_detail_log(self): + """Writes the current batch of detail logs to file.""" + if ( + not self._communication_log_batch + or self._communication_log_filename is None + ): + return + + try: + with open(self._communication_log_filename, "a", encoding="utf-8") as f: + for entry in self._communication_log_batch: + json.dump(entry, f) + f.write("\n") + self.logger.debug( + f"Flushed {len(self._communication_log_batch)} entries to {self._communication_log_filename}" + ) + self._communication_log_batch = [] # Clear batch + except IOError as e: + self.logger.error( + f"Error writing communication detail log to {self._communication_log_filename}: {e}" + ) + def register_callbacks(self): """Register all outputs to the callback function""" self.agent.data_broker.register_callback( @@ -91,6 +194,20 @@ def _send_only_shared_variables(self, variable: AgentVariable): payload = self.short_dict(variable) self.logger.debug("Sending variable %s=%s", variable.alias, variable.value) + + # Logging for 'sent' messages + if self.config.communication_log_level == "basic": + self._sent_alias_counts[payload["alias"]] += 1 + elif self.config.communication_log_level == "detail": + log_entry = { + "timestamp": self.env.time, + "direction": "sent", + "own_agent_id": self.agent.id, + "remote_agent_id": None, # Cannot be known for sent messages generally + "alias": payload["alias"], + } + self._communication_log_batch.append(log_entry) + self._send(payload=payload) def _variable_can_be_send(self, variable): @@ -105,13 +222,13 @@ def _send(self, payload: CommunicationDict): "This method needs to be implemented " "individually for each communicator" ) - def short_dict(self, variable: AgentVariable, parse_json: bool = True) -> CommunicationDict: + def short_dict(self, variable: AgentVariable) -> CommunicationDict: """Creates a short dict serialization of the Variable. Only contains attributes of the AgentVariable, that are relevant for other modules or agents. For performance and privacy reasons, this function should be called for communicators.""" - if isinstance(variable.value, pd.Series) and parse_json: + if isinstance(variable.value, pd.Series) and self.parse_json: value = variable.value.to_json() else: value = variable.value @@ -133,6 +250,290 @@ def to_json(self, payload: CommunicationDict) -> Union[bytes, str]: # implemented on init pass + def terminate(self): + if self.config.communication_log_level == "detail": + self.logger.debug( + f"Terminating communicator {self.id}, flushing any remaining detail logs." + ) + self._flush_detail_log() + super().terminate() + + def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: + """Returns logged communication data based on the log level.""" + log_level = self.config.communication_log_level + if log_level == "none": + return None + elif log_level == "basic": + return { + "sent_counts": dict(self._sent_alias_counts or {}), + "received_counts": { + str(k): v + for k, v in (self._received_source_alias_counts or {}).items() + }, + } + elif log_level == "detail": + self._flush_detail_log() # Ensure all data is written before reading + if ( + self._communication_log_filename + and Path(self._communication_log_filename).exists() + ): + try: + log_entries = [] + with open( + self._communication_log_filename, "r", encoding="utf-8" + ) as f: + for line in f: + log_entries.append(json.loads(line)) + df = pd.DataFrame(log_entries) + return df + except Exception as e: + self.logger.error( + f"Error loading communication detail log from {self._communication_log_filename}: {e}" + ) + return pd.DataFrame() # Return empty DataFrame on error + return pd.DataFrame() # Return empty DataFrame if file doesn't exist + return None + + def cleanup_results(self): + """Deletes the communication log file if 'detail' logging was active and cleanup is configured.""" + try: + os.remove(self._communication_log_filename) + self.logger.info( + f"Cleaned up communication log file: {self._communication_log_filename}" + ) + except OSError as e: + self.logger.error( + f"Error cleaning up communication log file {self._communication_log_filename}: {e}" + ) + + @classmethod + def visualize_results( + cls, + results_data: Optional[Union[dict, pd.DataFrame]], + module_id: str, + agent_id: str, + ) -> "Optional[html.Div]": + try: + from dash import dcc, html + import plotly.graph_objects as go + import dash_bootstrap_components as dbc + except ImportError: + raise OptionalDependencyError( + used_object="Dashboard", + dependency_install="dash", + dependency_name="interactive", + ) + if results_data is None: + return html.Div( + f"No communication data to visualize for {module_id} (Agent: {agent_id}). Log level might be 'none'." + ) + + main_container_children = [ + html.H4( + f"Communicator Activity: {module_id} (Agent: {agent_id})", + className="mb-3", + ) + ] + + if isinstance(results_data, dict): # Basic logging + log_type_info = html.P("Log Level: basic (Counters)") + sent_counts = results_data.get("sent_counts", {}) + received_counts_str_keys = results_data.get("received_counts", {}) + + received_counts_display = { + k: v for k, v in received_counts_str_keys.items() + } + + if sent_counts: + sent_fig = go.Figure( + data=[ + go.Bar(x=list(sent_counts.keys()), y=list(sent_counts.values())) + ] + ) + sent_fig.update_layout( + title_text="Sent Message Counts by Alias", + xaxis_title="Alias", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=sent_fig)) + else: + main_container_children.append( + html.P( + "No messages sent or 'basic' logging did not capture sent messages." + ) + ) + + if received_counts_display: + received_fig = go.Figure( + data=[ + go.Bar( + x=list(received_counts_display.keys()), + y=list(received_counts_display.values()), + ) + ] + ) + received_fig.update_layout( + title_text="Received Message Counts by (Source Agent, Alias)", + xaxis_title="(Source Agent, Alias)", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=received_fig)) + else: + main_container_children.append( + html.P( + "No messages received or 'basic' logging did not capture received messages." + ) + ) + + elif isinstance(results_data, pd.DataFrame): + log_type_info = html.P("Log Level: detail (Timeline & Aggregates)") + if results_data.empty: + main_container_children.append(html.P("Detail log is empty.")) + else: + # Sent Messages Summary (from DataFrame) + sent_df = results_data[results_data["direction"] == "sent"] + if not sent_df.empty: + sent_counts_df = ( + sent_df.groupby("alias").size().reset_index(name="count") + ) + sent_fig_df = go.Figure( + data=[ + go.Bar(x=sent_counts_df["alias"], y=sent_counts_df["count"]) + ] + ) + sent_fig_df.update_layout( + title_text="Sent Message Counts by Alias (from Detail Log)", + xaxis_title="Alias", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=sent_fig_df)) + else: + main_container_children.append( + html.P("No 'sent' messages in detail log.") + ) + + # Received Messages Summary (from DataFrame) + received_df = results_data[results_data["direction"] == "received"] + if not received_df.empty: + # Create a combined key for grouping, e.g., "agent_id | alias" + # Ensure remote_agent_id is not None before concatenating + received_df_copy = ( + received_df.copy() + ) # Avoid SettingWithCopyWarning + received_df_copy["source_alias_key"] = ( + received_df_copy["remote_agent_id"].fillna("Unknown") + + " | " + + received_df_copy["alias"] + ) + received_counts_df = ( + received_df_copy.groupby("source_alias_key") + .size() + .reset_index(name="count") + ) + received_counts_df = received_counts_df.sort_values( + by="source_alias_key" + ) + + received_fig_df = go.Figure( + data=[ + go.Bar( + x=received_counts_df["source_alias_key"], + y=received_counts_df["count"], + ) + ] + ) + received_fig_df.update_layout( + title_text="Received Message Counts by (Source Agent | Alias) (from Detail Log)", + xaxis_title="(Source Agent | Alias)", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=received_fig_df)) + else: + main_container_children.append( + html.P("No 'received' messages in detail log.") + ) + + # Timelines (at the bottom) + timeline_children = [] + if not sent_df.empty: + sent_timeline_fig = go.Figure( + data=[ + go.Scatter( + x=sent_df["timestamp"], + y=sent_df["alias"], + mode="markers", + name="Sent", + ) + ] + ) + sent_timeline_fig.update_layout( + title_text="Sent Messages Timeline", + xaxis_title="Time", + yaxis_title="Alias", + height=350, + margin=dict(l=40, r=20, t=40, b=30), + ) + timeline_children.append( + dbc.Col(dcc.Graph(figure=sent_timeline_fig), md=6) + ) + + if not received_df.empty: + received_timeline_fig = go.Figure() + # Group by remote_agent_id, handling potential None values + for r_agent_id, group in received_df.fillna( + {"remote_agent_id": "Unknown"} + ).groupby("remote_agent_id"): + received_timeline_fig.add_trace( + go.Scatter( + x=group["timestamp"], + y=group["alias"], + mode="markers", + name=f"From {r_agent_id}", + hovertext=[ + f"From: {ra} Alias: {al}" + for ra, al in zip( + group["remote_agent_id"], group["alias"] + ) + ], + ) + ) + received_timeline_fig.update_layout( + title_text="Received Messages Timeline", + xaxis_title="Time", + yaxis_title="Alias", + height=350, + margin=dict(l=40, r=20, t=40, b=30), + ) + timeline_children.append( + dbc.Col(dcc.Graph(figure=received_timeline_fig), md=6) + ) + + if timeline_children: + main_container_children.append(html.Hr()) + main_container_children.append( + html.H5("Message Timelines (Detail Log)", className="mt-4 mb-3") + ) + main_container_children.append(dbc.Row(timeline_children)) + + else: + return html.Div( + f"Unknown results data type for {module_id} (Agent: {agent_id}): {type(results_data)}" + ) + + main_container_children.insert( + 1, log_type_info + ) # Insert log type info after H4 + + return html.Div(main_container_children, style={"padding": "10px"}) + class LocalCommunicatorConfig(CommunicatorConfig): parse_json: bool = Field( @@ -141,10 +542,7 @@ class LocalCommunicatorConfig(CommunicatorConfig): "which use MQTT or similar.", default=False, ) - queue_size: int = Field( - title="Size of the queue", - default=10000 - ) + queue_size: int = Field(title="Size of the queue", default=10000) class LocalCommunicator(Communicator): @@ -169,6 +567,7 @@ def __init__(self, config: dict, agent: Agent): super().__init__(config=config, agent=agent) self.broker = self.setup_broker() + self.parse_json = self.config.parse_json self._msg_q_in = queue.Queue(self.config.queue_size) self.broker.register_client(client=self) @@ -191,15 +590,6 @@ def setup_broker(self): "This method needs to be implemented " "individually for each communicator" ) - def _send_only_shared_variables(self, variable: AgentVariable): - """Send only variables with field ``shared=True``""" - if not self._variable_can_be_send(variable): - return - - payload = self.short_dict(variable, parse_json=self.config.parse_json) - self.logger.debug("Sending variable %s=%s", variable.alias, variable.value) - self._send(payload=payload) - def _process(self): """Waits for new messages, sends them to the broker.""" yield self.env.event() @@ -217,39 +607,78 @@ def _process_realtime(self): def _send_simpy(self, ignored): """Sends new messages to the broker when receiving them, adhering to the simpy event queue. To be appended to a simpy event callback.""" - variable = self._msg_q_in.get_nowait() + variable = self._msg_q_in.get_nowait() # This is an AgentVariable + + # Logging for 'received' messages (SimPy path) + if ( + self.config.communication_log_level == "basic" + and self._received_source_alias_counts is not None + ): + self._received_source_alias_counts[ + (variable.source.agent_id, variable.alias) + ] += 1 + elif ( + self.config.communication_log_level == "detail" + and self._communication_log_batch is not None + ): + log_entry = { + "timestamp": self.env.time, # Log at time of processing from queue + "direction": "received", + "own_agent_id": self.agent.id, + "remote_agent_id": variable.source.agent_id, + "alias": variable.alias, + } + self._communication_log_batch.append(log_entry) + self.agent.data_broker.send_variable(variable) - def _receive(self, msg_obj): + def _receive(self, msg_obj): # msg_obj is raw from broker """Receive a given message and put it in the queue and set the corresponding simpy event.""" - if self.config.parse_json: - variable = AgentVariable.from_json(msg_obj) - else: - variable = msg_obj - self._msg_q_in.put(variable, block=False) + variable_to_queue = ( + AgentVariable.from_json(msg_obj) if self.config.parse_json else msg_obj + ) + self._msg_q_in.put(variable_to_queue, block=False) self._received_variable.callbacks.append(self._send_simpy) self._received_variable.succeed() self._received_variable = self.env.event() - def _receive_realtime(self, msg_obj): + def _receive_realtime(self, msg_obj): # msg_obj is raw from broker """Receive a given message and put it in the queue. No event setting is required for realtime.""" - if self.config.parse_json: - variable = AgentVariable.from_json(msg_obj) - else: - variable = msg_obj - self._msg_q_in.put(variable) + variable_to_queue = ( + AgentVariable.from_json(msg_obj) if self.config.parse_json else msg_obj + ) + self._msg_q_in.put(variable_to_queue) - def _message_handler(self): + def _message_handler(self): # Realtime message handler """Reads messages that were put in the message queue.""" while True: - variable = self._msg_q_in.get() + variable = self._msg_q_in.get() # This is an AgentVariable + log_time = self.env.time + + if ( + self.config.communication_log_level == "basic" + and self._received_source_alias_counts is not None + ): + self._received_source_alias_counts[ + (variable.source.agent_id, variable.alias) + ] += 1 + elif ( + self.config.communication_log_level == "detail" + and self._communication_log_batch is not None + ): + log_entry = { + "timestamp": log_time, + "direction": "received", + "own_agent_id": self.agent.id, + "remote_agent_id": variable.source.agent_id, + "alias": variable.alias, + } + self._communication_log_batch.append(log_entry) + self.agent.data_broker.send_variable(variable) def terminate(self): - # Terminating is important when running multiple - # simulations/environments, otherwise the broker will keep spamming all - # agents from the previous simulation, potentially filling their queues. self.broker.delete_client(self) super().terminate() diff --git a/agentlib/utils/multi_agent_system.py b/agentlib/utils/multi_agent_system.py index 602c46e2..1000f8f2 100644 --- a/agentlib/utils/multi_agent_system.py +++ b/agentlib/utils/multi_agent_system.py @@ -166,7 +166,7 @@ def terminate_agents(self): for agent in self._agents.values(): agent.terminate() - def show_results_dashboard(self, cleanup_results: bool = False): + def show_results_dashboard(self, cleanup_results: bool = False, block_main=True): """ Launches an interactive dashboard to visualize the results of the MAS. @@ -188,12 +188,17 @@ def show_results_dashboard(self, cleanup_results: bool = False): results = self.get_results(cleanup=cleanup_results) if not results: logger.warning("No results found to display in the dashboard.") - return + return None # Return None if no dashboard is launched try: - launch_mas_dashboard(mas=self, mas_results=results) + # launch_mas_dashboard now returns the process + dashboard_process = launch_mas_dashboard( + mas=self, mas_results=results, block_main=block_main + ) + return dashboard_process except Exception as e: logger.error(f"Error launching MAS dashboard: {e}", exc_info=True) + return None # Return None on error def setup_agent(self, id: str) -> Agent: """Setup the agent matching the given id""" diff --git a/agentlib/utils/plotting/mas_dashboard.py b/agentlib/utils/plotting/mas_dashboard.py index 1b1bd37a..883b25c3 100644 --- a/agentlib/utils/plotting/mas_dashboard.py +++ b/agentlib/utils/plotting/mas_dashboard.py @@ -2,9 +2,12 @@ Dashboard for visualizing results from a Multi-Agent System (MAS). Allows selecting an agent and then a module within that agent to display its specific results visualization. +Runs in a separate process to avoid blocking the main thread. """ +import importlib import logging +import multiprocessing import socket import webbrowser @@ -25,31 +28,12 @@ logger = logging.getLogger(__name__) -def get_free_port(): - """Get an available port by trying to bind to a socket.""" - port = 8050 - while True: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - try: - s.bind(("localhost", port)) - return port - except OSError: - port += 1 - - -def launch_mas_dashboard(mas: LocalMASAgency, mas_results: dict): - """ - Launches the MAS results dashboard. - - Args: - mas: The LocalMASAgency instance. - mas_results: A dictionary of results, typically from mas.get_results(). - Expected structure: {agent_id: {module_id: results_data}} - """ +# Moved create_dash_app to top level to ensure picklability for multiprocessing +def create_dash_app(processed_agent_module_info, processed_mas_results): app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) app.title = "MAS Results Dashboard" - agent_ids = list(mas_results.keys()) + agent_ids = list(processed_mas_results.keys()) app.layout = dbc.Container( [ @@ -99,19 +83,21 @@ def launch_mas_dashboard(mas: LocalMASAgency, mas_results: dict): [Input("agent-selector", "value")], ) def update_module_dropdown(selected_agent_id): - if not selected_agent_id or not mas_results: + if not selected_agent_id or not processed_mas_results: return [], None - agent_modules_data = mas_results.get(selected_agent_id, {}) + # Use preprocessed agent_module_info + modules_for_agent = processed_agent_module_info.get(selected_agent_id, []) + agent_modules_data = processed_mas_results.get( + selected_agent_id, {} + ) # Results data + module_options = [] first_module_id = None - agent_instance = mas._agents.get(selected_agent_id) - if not agent_instance: - return [], None - - for module_instance in agent_instance.modules: - module_id = module_instance.id + for module_info in modules_for_agent: + module_id = module_info["id"] + # Check if results exist for this module to decide if it's selectable if module_id in agent_modules_data: module_options.append({"label": module_id, "value": module_id}) if first_module_id is None: @@ -124,22 +110,21 @@ def update_module_dropdown(selected_agent_id): [Input("agent-selector", "value"), Input("module-selector", "value")], ) def display_module_visualization(selected_agent_id, selected_module_id): - if not selected_agent_id or not selected_module_id or not mas_results: + if not selected_agent_id or not selected_module_id or not processed_mas_results: return html.P("Select an agent and a module to view visualization.") - agent_instance = mas._agents.get(selected_agent_id) - if not agent_instance: - return html.P(f"Agent '{selected_agent_id}' not found in MAS instance.") - - module_instance = next( - (m for m in agent_instance.modules if m.id == selected_module_id), None + # Get module class_path from preprocessed info + agent_modules = processed_agent_module_info.get(selected_agent_id, []) + module_info_dict = next( + (m for m in agent_modules if m["id"] == selected_module_id), None ) - if not module_instance: + + if not module_info_dict: return html.P( - f"Module '{selected_module_id}' not found in agent '{selected_agent_id}'." + f"Module '{selected_module_id}' (metadata) not found in agent '{selected_agent_id}'." ) - results_data = mas_results.get(selected_agent_id, {}).get( + results_data = processed_mas_results.get(selected_agent_id, {}).get( selected_module_id, "__no_key__" ) @@ -148,11 +133,15 @@ def display_module_visualization(selected_agent_id, selected_module_id): f"No results data key found for module '{selected_module_id}' in agent '{selected_agent_id}'." ) - ModuleType = type(module_instance) try: - # results_data here can be the actual data (DataFrame, None, etc.) + # Dynamically import the ModuleType + class_path = module_info_dict["class_path"] + module_path_str, class_name_str = class_path.rsplit(".", 1) + imported_module_obj = importlib.import_module(module_path_str) + ModuleType = getattr(imported_module_obj, class_name_str) + visualization_layout = ModuleType.visualize_results( - results_data=results_data, # This will be passed to the module's visualize_results + results_data=results_data, module_id=selected_module_id, agent_id=selected_agent_id, ) @@ -163,21 +152,22 @@ def display_module_visualization(selected_agent_id, selected_module_id): ) return visualization_layout except OptionalDependencyError as e: - logger.error(f"Optional dependency error for {ModuleType.__name__}: {e}") + # Use logger from the current process (Dash app process) + logging.getLogger(__name__).error( + f"Optional dependency error for {class_path}: {e}" + ) return html.Div( [ html.H4("Visualization Error"), - html.P( - f"Could not render visualization for {ModuleType.__name__}." - ), + html.P(f"Could not render visualization for {class_path}."), html.P(str(e)), html.P("Please ensure all necessary libraries are installed."), ] ) except Exception as e: - logger.error( + logging.getLogger(__name__).error( f"Error generating visualization for module '{selected_module_id}' " - f"(type: {ModuleType.__name__}) in agent '{selected_agent_id}': {e}", + f"(path: {class_path}) in agent '{selected_agent_id}': {e}", exc_info=True, ) return html.P( @@ -185,15 +175,121 @@ def display_module_visualization(selected_agent_id, selected_module_id): f"module '{selected_module_id}': {str(e)}" ) + return app + + +def _run_dash_server(app_factory_func, app_factory_args, port, host): + """ + Helper function to create and run the Dash app in a separate process. + app_factory_func is now the actual create_dash_app function. + """ + # Ensure that the current process is not a daemon if it needs to spawn children + # (though Dash app itself usually doesn't spawn more processes) + # multiprocessing.current_process().daemon = False + app = app_factory_func(*app_factory_args) + logger.info(f"Dash app server starting on http://{host}:{port}") + try: + app.run_server(debug=False, port=port, host=host) + except Exception as e: + logger.error(f"Error running Dash server: {e}", exc_info=True) + + +def get_free_port(): + """Get an available port by trying to bind to a socket.""" + port = 8050 + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("localhost", port)) + return port + except OSError: + port += 1 + + +def launch_mas_dashboard( + mas: LocalMASAgency, mas_results: dict, block_main: bool +) -> multiprocessing.Process | None: + """ + Launches the MAS results dashboard in a separate process. + + Args: + mas: The LocalMASAgency instance. + mas_results: A dictionary of results, typically from mas.get_results(). + Expected structure: {agent_id: {module_id: results_data}} + Returns: + multiprocessing.Process: The process running the Dash dashboard. + """ + # Preprocess MAS structure for pickling + agent_module_info_for_app = {} + if mas and hasattr(mas, "_agents"): + for agent_id, agent_instance in mas._agents.items(): + agent_module_info_for_app[agent_id] = [] + if agent_instance and hasattr(agent_instance, "modules"): + for module_instance in agent_instance.modules: + module_class = module_instance.__class__ + class_path = f"{module_class.__module__}.{module_class.__name__}" + agent_module_info_for_app[agent_id].append( + {"id": module_instance.id, "class_path": class_path} + ) + port = get_free_port() + host = "0.0.0.0" # Standard host for Dash to be accessible + + # Arguments for the app factory function + app_factory_args = (agent_module_info_for_app, mas_results) + + # open tab webbrowser.open_new_tab(f"http://localhost:{port}") - logger.info(f"MAS Dashboard is running on http://localhost:{port}") - app.run_server(debug=False, port=port, host="0.0.0.0") + + if not block_main: + # Create and start the process + # Note: On Windows, make sure the script calling this is guarded by if __name__ == '__main__' + # if it also creates processes. + ctx = multiprocessing.get_context( + "spawn" + ) # Using 'spawn' context for better cross-platform compatibility + dashboard_process = ctx.Process( + target=_run_dash_server, # Target is the helper + args=( + create_dash_app, + app_factory_args, + port, + host, + ), # Pass the top-level create_dash_app and its args + ) + dashboard_process.daemon = ( + False # Main program will wait for this process unless terminated + ) + dashboard_process.start() + logger.info( + f"MAS Dashboard is launching in a separate process on http://localhost:{port}" + ) + return dashboard_process + else: + # run in main process + _run_dash_server(create_dash_app, app_factory_args, port, host) if __name__ == "__main__": + # This example won't run directly as it needs a LocalMASAgency instance and results. + # It's intended to be called from another script. print( "To run this dashboard, import and call launch_mas_dashboard " - "with a LocalMASAgency instance and its results." + "from your script with a LocalMASAgency instance and its results. \n" + "Example: \n" + "import multiprocessing \n" + "if __name__ == '__main__': \n" + " multiprocessing.freeze_support() # For Windows executable freezing \n" + " # ... your MAS setup ... \n" + " dashboard_proc = launch_mas_dashboard(my_mas, my_results) \n" + " # ... your main script continues ... \n" + " print('Dashboard process started. Press Ctrl+C in the console running the script, or implement other logic to stop it.') \n" + " try: \n" + " dashboard_proc.join() # Wait for dashboard process to finish (e.g., if user closes browser tab and server stops) \n" + " except KeyboardInterrupt: \n" + " print('Terminating dashboard process...') \n" + " dashboard_proc.terminate() \n" + " dashboard_proc.join() \n" + " print('Dashboard process finished.')" ) pass diff --git a/examples/multi-agent-systems/room_mas/room_mas.py b/examples/multi-agent-systems/room_mas/room_mas.py index 57194e9d..0c0ebe5c 100644 --- a/examples/multi-agent-systems/room_mas/room_mas.py +++ b/examples/multi-agent-systems/room_mas/room_mas.py @@ -32,7 +32,9 @@ def run_example(until, with_plots=True, log_level=logging.INFO): mas.run(until=until) # Load results: try: - mas.show_results_dashboard(cleanup_results=False) # Keep results for dashboard + mas.show_results_dashboard( + cleanup_results=False, block_main=False + ) # Keep results for dashboard except Exception as e: logger.error(f"Could not launch MAS dashboard: {e}") results = mas.get_results(cleanup=True) @@ -105,4 +107,4 @@ def run_example(until, with_plots=True, log_level=logging.INFO): if __name__ == "__main__": - run_example(until=86400 / 10, with_plots=False) + run_example(until=86400 / 10, with_plots=True) From 0906e0d7021a49f9a18eb26f4c0e231414127356 Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 6 Jun 2025 14:07:46 +0200 Subject: [PATCH 04/26] make communicator results logging and add to dashboard --- CHANGELOG.md | 1 + agentlib/core/logging_.py | 2 +- agentlib/modules/communicator/communicator.py | 2 +- agentlib/modules/simulation/simulator.py | 2 -- .../room_mas/configs/PIDAgent.json | 1 + .../multi-agent-systems/room_mas/configs/Room1.json | 12 ++++++++---- examples/multi-agent-systems/room_mas/room_mas.py | 13 +++++-------- 7 files changed, 17 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b789066..41f381b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.9.0 - Add an optional dashboard function to the mas utility. +- Add optional results logging for communicators. ## 0.8.8 - Percentage display in logging now only appears in simulation speed, not realtime. Fixed percentage when environment has an offset. diff --git a/agentlib/core/logging_.py b/agentlib/core/logging_.py index 73c5b34a..16ecd35e 100644 --- a/agentlib/core/logging_.py +++ b/agentlib/core/logging_.py @@ -35,7 +35,7 @@ def makeRecord( _time = self.env.pretty_time() if _until is environment.UNTIL_UNSET: # Add "INIT" prefix to clearly indicate initialization phase - record.env_time = f"" + record.env_time = "" elif _until is None: record.env_time = _time else: diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index 99f14517..8e8ae912 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -47,7 +47,7 @@ class CommunicatorConfig(BaseModuleConfig): description="Filename for 'detail' logging. Defaults to 'communicator_logs/{agent_id}_{module_id}.jsonl'.", ) communication_log_overwrite: bool = Field( - default=False, + default=True, title="Overwrite Communication Log", description="If true, existing log file will be overwritten at the start.", ) diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index ca485ab3..2575e9a0 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -26,8 +26,6 @@ from agentlib.core.errors import OptionalDependencyError from agentlib.models import get_model_type, UNINSTALLED_MODEL_TYPES from agentlib.utils import custom_injection -import pandas as pd -from typing import Optional @dataclass diff --git a/examples/multi-agent-systems/room_mas/configs/PIDAgent.json b/examples/multi-agent-systems/room_mas/configs/PIDAgent.json index 212de599..9430ffd8 100644 --- a/examples/multi-agent-systems/room_mas/configs/PIDAgent.json +++ b/examples/multi-agent-systems/room_mas/configs/PIDAgent.json @@ -25,6 +25,7 @@ "module_id": "ComLocal", "type": "local", "subscriptions": ["Room1Agent"], + "communication_log_level": "basic", "parse_json": true } ] diff --git a/examples/multi-agent-systems/room_mas/configs/Room1.json b/examples/multi-agent-systems/room_mas/configs/Room1.json index 48bd66ed..62c05741 100644 --- a/examples/multi-agent-systems/room_mas/configs/Room1.json +++ b/examples/multi-agent-systems/room_mas/configs/Room1.json @@ -13,7 +13,6 @@ "save_results": true, "overwrite_result_file": true, "result_filename": "res_room1.csv", - "overwrite_result_file": true, "inputs": [ { "name": "Q_flow_heat", @@ -22,7 +21,8 @@ { "name": "T_oda", "value": 273.15 - }], + } + ], "outputs": [ { "name": "T_air" @@ -32,8 +32,12 @@ { "module_id": "ComLocal", "type": "local", - "subscriptions": ["PIDAgent", "TRYSensorAgent"], - "parse_json": true + "subscriptions": [ + "PIDAgent", + "TRYSensorAgent" + ], + "parse_json": true, + "communication_log_level": "detail" } ] } \ No newline at end of file diff --git a/examples/multi-agent-systems/room_mas/room_mas.py b/examples/multi-agent-systems/room_mas/room_mas.py index 0c0ebe5c..c3cdcf6d 100644 --- a/examples/multi-agent-systems/room_mas/room_mas.py +++ b/examples/multi-agent-systems/room_mas/room_mas.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -def run_example(until, with_plots=True, log_level=logging.INFO): +def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=False): # Start by setting the log-level logging.basicConfig(level=log_level) @@ -31,12 +31,9 @@ def run_example(until, with_plots=True, log_level=logging.INFO): # Simulate mas.run(until=until) # Load results: - try: - mas.show_results_dashboard( - cleanup_results=False, block_main=False - ) # Keep results for dashboard - except Exception as e: - logger.error(f"Could not launch MAS dashboard: {e}") + if with_dashboard: + mas.show_results_dashboard(cleanup_results=False, block_main=False) + results = mas.get_results(cleanup=True) if not with_plots: @@ -107,4 +104,4 @@ def run_example(until, with_plots=True, log_level=logging.INFO): if __name__ == "__main__": - run_example(until=86400 / 10, with_plots=True) + run_example(until=86400 / 10, with_plots=True, with_dashboard=True) From 3cffc2df014c1b0798f5ae49d341246a8778cf1e Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 6 Jun 2025 14:37:16 +0200 Subject: [PATCH 05/26] Make results logging common to all communicators and add subscription multiprocessing --- agentlib/modules/communicator/__init__.py | 4 + agentlib/modules/communicator/communicator.py | 96 ++++++++------- .../communicator/local_multiprocessing.py | 10 +- .../local_multiprocessing_subscription.py | 113 ++++++++++++++++++ agentlib/modules/communicator/mqtt.py | 12 +- agentlib/utils/multi_processing_broker.py | 83 ++++++++++++- .../pingpong_multiprocessing_subscription.py | 58 +++++++++ tests/test_n_pingpong.py | 28 ++++- 8 files changed, 334 insertions(+), 70 deletions(-) create mode 100644 agentlib/modules/communicator/local_multiprocessing_subscription.py create mode 100644 examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py diff --git a/agentlib/modules/communicator/__init__.py b/agentlib/modules/communicator/__init__.py index 77d65f5e..8c30bdf8 100644 --- a/agentlib/modules/communicator/__init__.py +++ b/agentlib/modules/communicator/__init__.py @@ -24,4 +24,8 @@ import_path="agentlib.modules.communicator.local_multiprocessing", class_name="MultiProcessingBroadcastClient", ), + "multiprocessing_subscription": ModuleImport( + import_path="agentlib.modules.communicator.local_multiprocessing_subscription", + class_name="MultiProcessingSubscriptionClient", + ), } diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index 8e8ae912..cc792412 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -240,6 +240,48 @@ def short_dict(self, variable: AgentVariable) -> CommunicationDict: source=self.agent.id, ) + def _handle_received_variable( + self, variable: AgentVariable, remote_agent_id: Optional[str] = None + ): + """ + Centralized handler for received variables that manages logging and forwarding. + + Args: + variable: The received AgentVariable + remote_agent_id: ID of the sending agent (if known and different from variable.source.agent_id) + """ + # Use remote_agent_id if provided, otherwise fall back to variable's source + source_agent_id = remote_agent_id or variable.source.agent_id + + # Handle logging for received messages + if ( + self.config.communication_log_level == "basic" + and self._received_source_alias_counts is not None + ): + self._received_source_alias_counts[(source_agent_id, variable.alias)] += 1 + elif ( + self.config.communication_log_level == "detail" + and self._communication_log_batch is not None + ): + log_entry = { + "timestamp": self.env.time, + "direction": "received", + "own_agent_id": self.agent.id, + "remote_agent_id": source_agent_id, + "alias": variable.alias, + } + self._communication_log_batch.append(log_entry) + + # Forward to data broker + self.agent.data_broker.send_variable(variable) + + self.logger.debug( + "Received and processed variable %s=%s from source %s", + variable.alias, + variable.value, + source_agent_id, + ) + def to_json(self, payload: CommunicationDict) -> Union[bytes, str]: """Transforms the payload into json serialized form. Dynamically uses orjson if it is installed, and the builtin json otherwise. @@ -587,7 +629,7 @@ def setup_broker(self): """Function to set up the broker object. Needs to return a valid broker option.""" raise NotImplementedError( - "This method needs to be implemented " "individually for each communicator" + "This method needs to be implemented individually for each communicator" ) def _process(self): @@ -607,30 +649,8 @@ def _process_realtime(self): def _send_simpy(self, ignored): """Sends new messages to the broker when receiving them, adhering to the simpy event queue. To be appended to a simpy event callback.""" - variable = self._msg_q_in.get_nowait() # This is an AgentVariable - - # Logging for 'received' messages (SimPy path) - if ( - self.config.communication_log_level == "basic" - and self._received_source_alias_counts is not None - ): - self._received_source_alias_counts[ - (variable.source.agent_id, variable.alias) - ] += 1 - elif ( - self.config.communication_log_level == "detail" - and self._communication_log_batch is not None - ): - log_entry = { - "timestamp": self.env.time, # Log at time of processing from queue - "direction": "received", - "own_agent_id": self.agent.id, - "remote_agent_id": variable.source.agent_id, - "alias": variable.alias, - } - self._communication_log_batch.append(log_entry) - - self.agent.data_broker.send_variable(variable) + variable = self._msg_q_in.get_nowait() + self._handle_received_variable(variable) def _receive(self, msg_obj): # msg_obj is raw from broker """Receive a given message and put it in the queue and set the @@ -654,30 +674,8 @@ def _receive_realtime(self, msg_obj): # msg_obj is raw from broker def _message_handler(self): # Realtime message handler """Reads messages that were put in the message queue.""" while True: - variable = self._msg_q_in.get() # This is an AgentVariable - log_time = self.env.time - - if ( - self.config.communication_log_level == "basic" - and self._received_source_alias_counts is not None - ): - self._received_source_alias_counts[ - (variable.source.agent_id, variable.alias) - ] += 1 - elif ( - self.config.communication_log_level == "detail" - and self._communication_log_batch is not None - ): - log_entry = { - "timestamp": log_time, - "direction": "received", - "own_agent_id": self.agent.id, - "remote_agent_id": variable.source.agent_id, - "alias": variable.alias, - } - self._communication_log_batch.append(log_entry) - - self.agent.data_broker.send_variable(variable) + variable = self._msg_q_in.get() + self._handle_received_variable(variable) def terminate(self): self.broker.delete_client(self) diff --git a/agentlib/modules/communicator/local_multiprocessing.py b/agentlib/modules/communicator/local_multiprocessing.py index 370a4a41..81aa5731 100644 --- a/agentlib/modules/communicator/local_multiprocessing.py +++ b/agentlib/modules/communicator/local_multiprocessing.py @@ -1,9 +1,9 @@ -import time import multiprocessing import threading +import time +from ipaddress import IPv4Address from pydantic import Field -from ipaddress import IPv4Address from agentlib.core import Agent from agentlib.core.datamodels import AgentVariable @@ -88,8 +88,10 @@ def _message_handler(self): except EOFError: break variable = AgentVariable.from_json(msg) - self.logger.debug(f"Received variable {variable.name}.") - self.agent.data_broker.send_variable(variable) + self.logger.debug( + f"Received multiprocessing message for variable {variable.alias}." + ) + self._handle_received_variable(variable) def terminate(self): """Closes all of the connections.""" diff --git a/agentlib/modules/communicator/local_multiprocessing_subscription.py b/agentlib/modules/communicator/local_multiprocessing_subscription.py new file mode 100644 index 00000000..225d5724 --- /dev/null +++ b/agentlib/modules/communicator/local_multiprocessing_subscription.py @@ -0,0 +1,113 @@ +import multiprocessing +import threading +import time +from ipaddress import IPv4Address + +from pydantic import Field + +from agentlib.core import Agent +from agentlib.core.datamodels import AgentVariable +from agentlib.modules.communicator.communicator import ( + Communicator, + CommunicationDict, + SubscriptionCommunicatorConfig, +) +from agentlib.utils import multi_processing_broker + + +class MultiProcessingSubscriptionClientConfig(SubscriptionCommunicatorConfig): + ipv4: IPv4Address = Field( + default="127.0.0.1", + description="IP Address for the communication server. Defaults to localhost.", + ) + port: int = Field( + default=50_001, + description="Port for setting up the connection with the broker.", + ) + authkey: bytes = Field( + default=b"useTheAgentlib", + description="Authorization key for the connection with the broker.", + ) + + +class MultiProcessingSubscriptionClient(Communicator): + """ + This communicator implements subscription-based communication between agents + via a central process broker. + """ + + config: MultiProcessingSubscriptionClientConfig + + def __init__(self, config: dict, agent: Agent): + super().__init__(config=config, agent=agent) + manager = multi_processing_broker.BrokerManager( + address=(self.config.ipv4, self.config.port), authkey=self.config.authkey + ) + started_wait = time.time() + + while True: + try: + manager.connect() + break + except ConnectionRefusedError: + time.sleep(0.01) + if time.time() - started_wait > 10: + raise RuntimeError("Could not connect to the server.") + + signup_queue = manager.get_queue() + client_read, broker_write = multiprocessing.Pipe(duplex=False) + broker_read, client_write = multiprocessing.Pipe(duplex=False) + + # Create subscription client with subscription information + signup = multi_processing_broker.MPSubscriptionClient( + agent_id=self.agent.id, + read=broker_read, + write=broker_write, + subscriptions=tuple(self.config.subscriptions), + ) + + signup_queue.put(signup) + + self._client_write = client_write + self._broker_write = broker_write + self._client_read = client_read + self._broker_read = broker_read + + # ensure the broker has set up the connection and sends its ack + self._client_read.recv() + + def process(self): + """Only start the loop once the env is running.""" + _thread = threading.Thread( + target=self._message_handler, name=str(self.source), daemon=True + ) + _thread.start() + self.agent.register_thread(thread=_thread) + yield self.env.event() + + def _message_handler(self): + """Reads messages that were put in the message queue.""" + while True: + try: + msg = self._client_read.recv() + except EOFError: + break + variable = AgentVariable.from_json(msg) + self.logger.debug(f"Received variable {variable.alias}.") + self._handle_received_variable(variable) + + def terminate(self): + """Closes all of the connections.""" + self._client_write.close() + self._client_read.close() + self._broker_write.close() + self._broker_read.close() + super().terminate() + + def _send(self, payload: CommunicationDict): + """Sends a variable to the Broker.""" + agent_id = payload["source"] + msg = multi_processing_broker.Message( + agent_id=agent_id, payload=self.to_json(payload) + ) + self._client_write.send(msg) diff --git a/agentlib/modules/communicator/mqtt.py b/agentlib/modules/communicator/mqtt.py index 677d1c9c..d9f98f8f 100644 --- a/agentlib/modules/communicator/mqtt.py +++ b/agentlib/modules/communicator/mqtt.py @@ -5,15 +5,14 @@ from pydantic import AnyUrl, Field, ValidationError, field_validator +from agentlib.core import Agent +from agentlib.core.datamodels import AgentVariable +from agentlib.core.errors import InitializationError, OptionalDependencyError from agentlib.modules.communicator.communicator import ( Communicator, SubscriptionCommunicatorConfig, ) -from agentlib.core import Agent -from agentlib.core.datamodels import AgentVariable -from agentlib.core.errors import InitializationError from agentlib.utils.validators import convert_to_list -from agentlib.core.errors import OptionalDependencyError try: from paho.mqtt.client import ( @@ -206,12 +205,11 @@ def _message_callback(self, client, userdata, msg): """ agent_inp = AgentVariable.from_json(msg.payload) self.logger.debug( - "Received variable %s = %s from source %s", + "Received MQTT message for variable %s from source %s", agent_inp.alias, - agent_inp.value, agent_inp.source, ) - self.agent.data_broker.send_variable(agent_inp) + self._handle_received_variable(agent_inp) def _subscribe_callback(self, client, userdata, mid, reasonCodes, properties): """Log if the subscription was successful""" diff --git a/agentlib/utils/multi_processing_broker.py b/agentlib/utils/multi_processing_broker.py index fb91c8ad..4ceb3967 100644 --- a/agentlib/utils/multi_processing_broker.py +++ b/agentlib/utils/multi_processing_broker.py @@ -4,16 +4,16 @@ """ import json +import logging import multiprocessing -from ipaddress import IPv4Address -from multiprocessing.managers import SyncManager import threading import time from collections import namedtuple +from ipaddress import IPv4Address +from multiprocessing.managers import SyncManager +from pathlib import Path from typing import Union -import logging -from pathlib import Path from pydantic import BaseModel, Field, FilePath from .broker import Broker @@ -23,6 +23,9 @@ MPClient = namedtuple("MPClient", ["agent_id", "read", "write"]) Message = namedtuple("Message", ["agent_id", "payload"]) +MPSubscriptionClient = namedtuple( + "MPSubscriptionClient", ["agent_id", "read", "write", "subscriptions"] +) class BrokerManager(SyncManager): @@ -67,6 +70,10 @@ def __init__(self, config: ConfigTypes = None): self.config = MultiProcessingBrokerConfig() else: self.config = config + + logger.info( + f"Starting Multiprocessing Broker on {(self.config.ipv4, self.config.port)}" + ) server = multiprocessing.Process( target=self._server, name="Broker_Server", args=(self.config,), daemon=True ) @@ -185,3 +192,71 @@ def send(self, source, message): client.write.send(message) except BrokenPipeError: pass + + +class MultiProcessingSubscriptionBroker(MultiProcessingBroker): + """ + Subscription-based multiprocessing broker that only sends messages to + clients subscribed to the sender's agent_id. + """ + + def _signup_handler(self): + """Connects to the manager queue and processes the signup requests. Starts a + child thread listening to messages from each client.""" + from multiprocessing.managers import BaseManager + + class QueueManager(BaseManager): + pass + + QueueManager.register("get_queue") + m = QueueManager( + address=(self.config.ipv4, self.config.port), authkey=self.config.authkey + ) + started_wait = time.time() + while True: + try: + m.connect() + break + except ConnectionRefusedError: + time.sleep(0.01) + if time.time() - started_wait > 10: + raise RuntimeError("Could not connect to server.") + + signup_queue = m.get_queue() + + while True: + try: + client = signup_queue.get() + except ConnectionResetError: + logger.info("Multiprocessing Subscription Broker disconnected.") + break + + with self.lock: + self._clients.add(client) + + # send the client an ack its messages are now being received + client.write.send(1) + threading.Thread( + target=self._client_loop, + args=(client,), + name=f"MPSubBroker_{client.agent_id}", + daemon=True, + ).start() + + def send(self, source, message): + """ + Send the given message to subscribed clients only. + Args: + source: Source agent_id to match against subscriptions + message: The message to send + """ + # lock is required so the clients loop does not change size during + # iteration if clients are added or removed + with self.lock: + for client in list(self._clients): + # Check if this client is subscribed to the source agent + if source in client.subscriptions: + try: + client.write.send(message) + except BrokenPipeError: + pass diff --git a/examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py b/examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py new file mode 100644 index 00000000..6b6762ff --- /dev/null +++ b/examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py @@ -0,0 +1,58 @@ +""" +Example of a local multi-agent-system consisting of two agents playing ping +pong. To set up a multi-agent-system by hand, the following steps are required: + 1. Specify the environment configuration + 2. Specify the config of all agents + 3. Create the environment and the agents + 4. Run the simulation by calling the run() method of the environment. +""" + +import logging + +import pingpong_module +from agentlib.core import Environment, Agent +from agentlib.utils.multi_processing_broker import MultiProcessingSubscriptionBroker + +logging.basicConfig(level="DEBUG") +logger = logging.getLogger(__name__) + + +env_config = {"rt": True, "factor": 1} +agent_config1 = { + "id": "FirstAgent", + "modules": [ + { + "module_id": "Ag1Com", + "type": "multiprocessing_subscription", + "subscriptions": ["SecondAgent"], + }, + { + "module_id": "Ping", + "type": {"file": pingpong_module.__file__, "class_name": "PingPong"}, + "start": True, + "initial_wait": 2, + }, + ], +} +agent_config2 = { + "id": "SecondAgent", + "modules": [ + { + "module_id": "Ag2Com", + "type": "multiprocessing_subscription", + "subscriptions": ["FirstAgent"], + }, + { + "module_id": "Pong", + "type": {"file": pingpong_module.__file__, "class_name": "PingPong"}, + }, + ], +} + + +if __name__ == "__main__": + broker = MultiProcessingSubscriptionBroker(config={"port": 50001}) + env = Environment(config=env_config) + agent1 = Agent(config=agent_config1, env=env) + agent2 = Agent(config=agent_config2, env=env) + env.run(until=None) diff --git a/tests/test_n_pingpong.py b/tests/test_n_pingpong.py index e69085a5..91205913 100644 --- a/tests/test_n_pingpong.py +++ b/tests/test_n_pingpong.py @@ -3,17 +3,21 @@ """ import itertools -import unittest import time -from pydantic import Field +import unittest + import numpy as np +from pydantic import Field + from agentlib.core import BaseModule, Agent, BaseModuleConfig from agentlib.core.datamodels import AgentVariable from agentlib.utils import MultiProcessingBroker, LocalBroadcastBroker, LocalBroker from agentlib.utils.multi_agent_system import LocalMASAgency +from agentlib.utils.multi_processing_broker import MultiProcessingSubscriptionBroker # pylint: disable=missing-module-docstring,missing-class-docstring PORT = 39920 +SUBSCRIPTION_PORT = 39921 # Different port for subscription broker class NPingPongConfig(BaseModuleConfig): @@ -64,7 +68,7 @@ def register_callbacks(self): if self.id == f"Pong_{self.n}": alias = f"Ping_{self.n}" elif self.id == f"Ping_{self.n}": - alias = f"Pong_{self.n-1}" + alias = f"Pong_{self.n - 1}" self.agent.data_broker.register_callback( alias=alias, source=None, callback=self._callback ) @@ -120,6 +124,14 @@ def test_multiprocessing_broadcast(self): ) as mas: mas.run(until=1) + def test_multiprocessing_subscription(self): + """Test the NPingPong system using multiprocessing subscription as communicator""" + broker = MultiProcessingSubscriptionBroker(config={"port": SUBSCRIPTION_PORT}) + with self._create_2n_pingpong_agents( + com_type="multiprocessing_subscription" + ) as mas: + mas.run(until=1) + @unittest.skip("MQTT refuses connection in ci") def test_mqtt(self): """Test the NPingPong system using local_broadcast as communicator""" @@ -142,24 +154,28 @@ def _create_2n_pingpong_agents( "type": com_type, } + # Add subscriptions for subscription-based communicators if com_type not in ["local_broadcast", "multiprocessing_broadcast"]: _com_ping["subscriptions"] = [f"AgPong_{_n - 1 if _n > 1 else self.n}"] _com_pong["subscriptions"] = [f"AgPing_{_n}"] pong_module = {"module_id": f"Pong_{_n}", "type": _pingpong_module, "n": _n} ping_module = {"module_id": f"Ping_{_n}", "type": _pingpong_module, "n": _n} + if com_type == "mqtt": _com_ping.update({"url": "mqtt://test.mosquitto.org"}) _com_pong.update({"url": "mqtt://test.mosquitto.org"}) - if com_type == "multiprocessing_broadcast": + elif com_type == "multiprocessing_broadcast": _com_ping.update({"port": PORT}) _com_pong.update({"port": PORT}) ping_module["initial_wait"] = 3 + elif com_type == "multiprocessing_subscription": + _com_ping.update({"port": SUBSCRIPTION_PORT}) + _com_pong.update({"port": SUBSCRIPTION_PORT}) + ping_module["initial_wait"] = 3 elif com_type in ["local_broadcast", "local"]: _com_ping.update({"parse_json": parse_json}) _com_pong.update({"parse_json": parse_json}) - elif com_type in ["multiprocessing_broadcast"]: - pass # No subs needed else: raise TypeError(f"Com_type '{com_type}' not supported") From da4f000f3a332cc3e175657fe924812c79fa152a Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 6 Jun 2025 14:37:49 +0200 Subject: [PATCH 06/26] update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f381b7..7e369adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.9.0 - Add an optional dashboard function to the mas utility. - Add optional results logging for communicators. +- Add subcription based multiprocessing. ## 0.8.8 - Percentage display in logging now only appears in simulation speed, not realtime. Fixed percentage when environment has an offset. From 9f5843cf0355dd76caed3eb466cc76d5039367cd Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 6 Jun 2025 16:40:09 +0200 Subject: [PATCH 07/26] add incremental and live dashboard. Currently incremental load function for simulator seems broken --- agentlib/core/module.py | 37 ++ agentlib/modules/communicator/communicator.py | 149 +++++- agentlib/modules/simulation/simulator.py | 76 ++- agentlib/modules/utils/agent_logger.py | 108 +++++ agentlib/utils/multi_agent_system.py | 6 +- agentlib/utils/plotting/mas_dashboard.py | 458 +++++++++++++----- .../multi-agent-systems/room_mas/room_mas.py | 6 +- 7 files changed, 701 insertions(+), 139 deletions(-) diff --git a/agentlib/core/module.py b/agentlib/core/module.py index 5f0304d1..1cd66f57 100644 --- a/agentlib/core/module.py +++ b/agentlib/core/module.py @@ -708,6 +708,43 @@ def get_results(self): Some form of results data, often in the form of a pandas DataFrame. """ + def get_results_incremental(self, update_token: Optional[Any] = None) -> tuple[Any, Optional[Any]]: + """ + Fetches results suitable for incremental updates in a live dashboard. + + This method is intended to be overridden by subclasses that support + incremental data fetching for live visualization. The `update_token` + allows the module to return only new or changed data since the last + call. + + Args: + update_token: An optional token from the previous call. If None, + the module should typically return its full current data. + The nature of the token is module-specific. + + Returns: + A tuple (results_chunk, next_update_token): + - results_chunk: The data to be sent to the dashboard (e.g., + a pandas DataFrame, a list of new entries). + - next_update_token: A token for the dashboard to use in the + next call to this method. Can be None if no + further incremental updates are supported by + this specific call or if the module doesn't + support incremental updates beyond the initial fetch. + """ + if update_token is None: + # Default behavior for the first call or for modules not fully + # implementing incremental logic: return all results from the + # existing get_results() method. The None token indicates that + # this is the complete data for now, or that no further + # *incremental* steps are defined by this default implementation. + return self.get_results(), None + # If an update_token is provided but this base method is called + # (i.e., not overridden with more specific incremental logic), + # it implies no further *new* data based on this token according + # to the default implementation. + return None, None + def cleanup_results(self): """ Deletes all files this module created. diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index cc792412..0fb66309 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -5,6 +5,7 @@ import abc import collections import json +import logging import os import queue import threading @@ -20,6 +21,8 @@ from agentlib.utils.broker import Broker from agentlib.utils.validators import convert_to_list +logger = logging.getLogger(__name__) # Added logger initialization + class CommunicationDict(TypedDict): alias: str @@ -129,7 +132,8 @@ def _init_detail_logging(self): ) if ( - Path(self._communication_log_filename).exists() + self._communication_log_filename is not None # Ensure filename is not None + and Path(self._communication_log_filename).exists() and self.config.communication_log_overwrite ): try: @@ -196,9 +200,15 @@ def _send_only_shared_variables(self, variable: AgentVariable): self.logger.debug("Sending variable %s=%s", variable.alias, variable.value) # Logging for 'sent' messages - if self.config.communication_log_level == "basic": + if ( + self.config.communication_log_level == "basic" + and self._sent_alias_counts is not None + ): self._sent_alias_counts[payload["alias"]] += 1 - elif self.config.communication_log_level == "detail": + elif ( + self.config.communication_log_level == "detail" + and self._communication_log_batch is not None + ): log_entry = { "timestamp": self.env.time, "direction": "sent", @@ -326,6 +336,8 @@ def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: ) as f: for line in f: log_entries.append(json.loads(line)) + if not log_entries: # Handle empty log file + return pd.DataFrame() df = pd.DataFrame(log_entries) return df except Exception as e: @@ -336,17 +348,132 @@ def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: return pd.DataFrame() # Return empty DataFrame if file doesn't exist return None - def cleanup_results(self): - """Deletes the communication log file if 'detail' logging was active and cleanup is configured.""" + @classmethod + def _load_detail_log_incremental( + cls, filename: str, start_line_index: int + ) -> tuple[Optional[pd.DataFrame], int]: + """ + Loads detail log file from a specific line index. + Returns a DataFrame chunk and the number of lines read from that point. + """ + log_entries = [] + lines_read_count = 0 try: - os.remove(self._communication_log_filename) - self.logger.info( - f"Cleaned up communication log file: {self._communication_log_filename}" + with open(filename, "r", encoding="utf-8") as f: + for i, line in enumerate(f): + if i < start_line_index: + continue + log_entries.append(json.loads(line)) + lines_read_count += 1 + except FileNotFoundError: + logger.warning( + f"Detail log file {filename} not found for incremental load." ) - except OSError as e: - self.logger.error( - f"Error cleaning up communication log file {self._communication_log_filename}: {e}" + return None, 0 + + if not log_entries: + return None, 0 + + try: + df = pd.DataFrame(log_entries) + return df, lines_read_count + except Exception as e: # pragma: no cover + logger.error( + f"Error creating DataFrame from incremental detail log {filename}: {e}" ) + return None, lines_read_count + + def get_results_incremental( + self, update_token: Optional[Any] = None + ) -> tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: + """Returns logged communication data incrementally.""" + log_level = self.config.communication_log_level + + if log_level == "none": + return None, None + + elif log_level == "basic": + current_counts = { + "sent_counts": dict(self._sent_alias_counts or {}), + "received_counts": { + str( + k + ): v # Ensure keys are strings for JSON compatibility if ever needed + for k, v in (self._received_source_alias_counts or {}).items() + }, + } + # For basic, token could be a hash of the dict, or just the dict itself for comparison. + # If no token, or token is different from current, send current. + if update_token is None or update_token != current_counts: + return current_counts, current_counts # Next token is the current state + return None, update_token # No change + + elif log_level == "detail": + self._flush_detail_log() # Ensure all data is written + if ( + not self._communication_log_filename + or not Path(self._communication_log_filename).exists() + ): + return None, update_token if update_token is not None else 0 + + if update_token is None: # Initial call, load all + try: + # Use existing get_results which handles loading full detail log + df = self.get_results() + lines_in_file = 0 + if df is not None and not df.empty: + # Count lines in the file for the next token + with open( + self._communication_log_filename, "r", encoding="utf-8" + ) as f: + lines_in_file = sum(1 for _ in f) + elif ( + df is None + ): # get_results might return None if file is empty or error + df = pd.DataFrame() # Ensure we return a DataFrame + return df, lines_in_file + except Exception as e: # pragma: no cover + self.logger.error( + f"Error in initial detail log load for get_results_incremental: {e}" + ) + return pd.DataFrame(), 0 + else: # Incremental call, update_token is previous line count + try: + start_line_index = int(update_token) + except ValueError: # pragma: no cover + self.logger.error( + f"Invalid update_token for detail log: {update_token}. Expected int." + ) + return ( + pd.DataFrame(), + 0, + ) # Fallback to sending empty and resetting token + + df_chunk, lines_read = self._load_detail_log_incremental( + filename=self._communication_log_filename, + start_line_index=start_line_index, + ) + if df_chunk is None or df_chunk.empty: + return None, start_line_index # No new data, token unchanged + return df_chunk, start_line_index + lines_read + return None, None + + def cleanup_results(self): + """Deletes the communication log file if 'detail' logging was active.""" + if ( + self.config.communication_log_level == "detail" + and self._communication_log_filename + ): + try: + if Path(self._communication_log_filename).exists(): + os.remove(self._communication_log_filename) + self.logger.info( + f"Cleaned up communication log file: {self._communication_log_filename}" + ) + except OSError as e: + self.logger.error( + f"Error cleaning up communication log file {self._communication_log_filename}: {e}" + ) @classmethod def visualize_results( diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 2575e9a0..00831a11 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -497,8 +497,82 @@ def get_results(self) -> Optional[pd.DataFrame]: def cleanup_results(self): if not self.config.save_results or not self.config.result_filename: return - os.remove(self.config.result_filename) + if self.config.result_filename and Path(self.config.result_filename).exists(): + os.remove(self.config.result_filename) + def get_results_incremental(self, update_token: Optional[int] = None) -> tuple[Optional[pd.DataFrame], Optional[int]]: + """ + Fetches simulation results incrementally. + If a result_filename is configured, it reads new data from the CSV. + Otherwise, it uses the in-memory SimulatorResults. + The update_token typically represents the number of data rows previously sent. + """ + if not self.config.save_results: + return None, None + + if self.config.result_filename: + # Results are being written to a CSV file + if self._result is not None and hasattr(self._result, 'write_results'): + # Ensure latest in-memory portion is flushed to CSV + self._result.write_results(self.config.result_filename) + + file_path = Path(self.config.result_filename) + if not file_path.exists(): + return None, 0 # Or update_token if not None? Consistent with others: 0 for no file. + + if update_token is None: # Initial call + try: + df = read_simulator_results(str(file_path)) + return df, len(df) if df is not None else 0 + except Exception as e: + self.logger.error(f"Error reading full result file {file_path} for incremental: {e}") + return None, 0 + else: # Incremental call, update_token is previous row count + try: + # skiprows needs to account for header (1 line) + previous data rows + df_chunk = pd.read_csv(file_path, header=[0, 1, 2], index_col=0, skiprows=range(1, update_token + 1)) + if df_chunk.empty: + return None, update_token + return df_chunk, update_token + len(df_chunk) + except Exception as e: + self.logger.error(f"Error reading incremental result file {file_path}: {e}") + return None, update_token # Return old token on error + else: + # Results are in memory (self._result) + if self._result is None: # Should not happen if save_results is True + return None, None + + current_total_rows = len(self._result.index) -1 # -1 because last row is often incomplete + if current_total_rows <= 0: + return None, 0 + + if update_token is None: # Initial call + df = self._result.df() # Gets all valid rows + return df, len(df) if df is not None else 0 + else: # Incremental call + prev_rows_sent = update_token + if prev_rows_sent >= current_total_rows: + return None, prev_rows_sent # No new rows + + # Slice the new data. self._result.data and .index include the potentially incomplete last row. + # self._result.df() correctly excludes it. + # We need to be careful with indices. + # Let's re-construct a DF from the slice of valid data. + + # Valid data is up to current_total_rows (exclusive of the last item in self.data/index) + new_data_slice = self._result.data[prev_rows_sent:current_total_rows] + new_index_slice = self._result.index[prev_rows_sent:current_total_rows] + + if not new_data_slice: + return None, prev_rows_sent + + df_chunk = pd.DataFrame(new_data_slice, index=new_index_slice, columns=self._result.columns) + + # df_chunk might need droplevel like in get_results() + df_chunk_processed = df_chunk.droplevel(level=2, axis=1).droplevel(level=0, axis=1) + + return df_chunk_processed, current_total_rows + return None, None # Fallback @classmethod def visualize_results( diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index 9384ce2e..ef4f4278 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -201,8 +201,69 @@ def _load_agent_variable(var): df = df.loc[:, ~df.columns.duplicated(keep="first")] return df.sort_index() + @classmethod + def load_from_file_incremental( + cls, + filename: str, + start_line_index: int, + values_only: bool = True, + merge_sources: bool = True, + ) -> tuple[Optional[pd.DataFrame], int]: + """ + Loads log file from a specific line index and consolidates it as a pandas DataFrame. + + Args: + filename: The file to load. + start_line_index: The 0-based index of the line to start reading from. + values_only: If true, loads a file that only has values saved. + merge_sources: Merges variables by sources if True. + + Returns: + A tuple (DataFrame_chunk, lines_read). DataFrame_chunk is None if no new lines. + """ + chunks = [] + lines_read_count = 0 + try: + with open(filename, "r") as file: + for i, data_line in enumerate(file): + if i < start_line_index: + continue + chunks.append(json.loads(data_line)) + lines_read_count += 1 + except FileNotFoundError: + logger.warning(f"Log file {filename} not found for incremental load.") + return None, 0 + + if not chunks: + return None, 0 + + full_dict = collections.ChainMap(*chunks) + df = pd.DataFrame.from_dict(full_dict, orient="index") + if df.empty: + return None, lines_read_count + + df.index = df.index.astype(float) + columns = (literal_eval(column) for column in df.columns) + df.columns = pd.MultiIndex.from_tuples(columns) + + if not values_only: + def _load_agent_variable(var): + try: + return AgentVariable.validate_data(var) + except TypeError: + pass # pragma: no cover + df = df.applymap(_load_agent_variable) + + if merge_sources: + df = df.droplevel(1, axis=1) + df = df.loc[:, ~df.columns.duplicated(keep="first")] + + return df.sort_index(), lines_read_count + def get_results(self) -> pd.DataFrame: """Load the own filename""" + # Ensure current in-memory logs are flushed before reading for static results + self._log() return self.load_from_file( filename=self.filename, values_only=self.config.values_only, @@ -225,6 +286,53 @@ def terminate(self): # last log interval is lost self._log() + def get_results_incremental(self, update_token: Optional[int] = None) -> tuple[Optional[pd.DataFrame], Optional[int]]: + """ + Fetches results incrementally for live dashboard. + The update_token is the number of lines previously read. + """ + self._log() # Ensure all current in-memory logs are written to file + + if update_token is None: # Initial call + try: + df = self.load_from_file( + filename=self.filename, + values_only=self.config.values_only, + merge_sources=self.config.merge_sources, + ) + # Count lines in the file for the next token + lines_in_file = 0 + try: + with open(self.filename, "r") as f: + lines_in_file = sum(1 for _ in f) + except FileNotFoundError: + pass # File might have been cleaned up if empty initially + return df, lines_in_file + except FileNotFoundError: + logger.warning(f"Log file {self.filename} not found for initial load.") + return None, 0 # No data, next token is 0 lines + except Exception as e: + logger.error(f"Error during initial load_from_file for {self.filename}: {e}", exc_info=True) + return None, 0 + + else: # Subsequent calls, update_token is the start_line_index + start_line_index = update_token + df_chunk, lines_read = self.load_from_file_incremental( + filename=self.filename, + start_line_index=start_line_index, + values_only=self.config.values_only, + merge_sources=self.config.merge_sources, + ) + if df_chunk is None or df_chunk.empty: + return None, start_line_index # No new data, token remains same + + # The current implementation of load_from_file_incremental returns a full df + # of new lines. For dashboard, we might want to append or replace. + # For now, returning the chunk of new data. + # The dashboard will need to handle merging if it maintains a global state. + # However, our mas_dashboard currently replaces data. + return df_chunk, start_line_index + lines_read + @classmethod def visualize_results( cls, results_data: pd.DataFrame, module_id: str, agent_id: str diff --git a/agentlib/utils/multi_agent_system.py b/agentlib/utils/multi_agent_system.py index 1000f8f2..06f81f82 100644 --- a/agentlib/utils/multi_agent_system.py +++ b/agentlib/utils/multi_agent_system.py @@ -166,7 +166,9 @@ def terminate_agents(self): for agent in self._agents.values(): agent.terminate() - def show_results_dashboard(self, cleanup_results: bool = False, block_main=True): + def show_results_dashboard( + self, cleanup_results: bool = False, block_main=True, live=False + ): """ Launches an interactive dashboard to visualize the results of the MAS. @@ -193,7 +195,7 @@ def show_results_dashboard(self, cleanup_results: bool = False, block_main=True) try: # launch_mas_dashboard now returns the process dashboard_process = launch_mas_dashboard( - mas=self, mas_results=results, block_main=block_main + mas=self, mas_results=results, block_main=block_main, live_update=live ) return dashboard_process except Exception as e: diff --git a/agentlib/utils/plotting/mas_dashboard.py b/agentlib/utils/plotting/mas_dashboard.py index 883b25c3..fc8f81d8 100644 --- a/agentlib/utils/plotting/mas_dashboard.py +++ b/agentlib/utils/plotting/mas_dashboard.py @@ -2,6 +2,7 @@ Dashboard for visualizing results from a Multi-Agent System (MAS). Allows selecting an agent and then a module within that agent to display its specific results visualization. +Supports both static (post-run) and live (during run) modes. Runs in a separate process to avoid blocking the main thread. """ @@ -10,11 +11,14 @@ import multiprocessing import socket import webbrowser +import threading +import queue # For queue.Empty +from typing import Dict, Optional, Tuple, Any, List try: import dash from dash import dcc, html - from dash.dependencies import Input, Output + from dash.dependencies import Input, Output, State import dash_bootstrap_components as dbc except ImportError: raise ImportError( @@ -27,121 +31,259 @@ logger = logging.getLogger(__name__) +# Sentinel value to signal the IPC listener thread to stop +_STOP_IPC_LISTENER = object() -# Moved create_dash_app to top level to ensure picklability for multiprocessing -def create_dash_app(processed_agent_module_info, processed_mas_results): + +def _ipc_listener_loop( + mas_instance: LocalMASAgency, + request_q: multiprocessing.Queue, + response_q: multiprocessing.Queue, + stop_event: threading.Event, +): + """Listens for data requests from the Dash app and calls the appropriate module.""" + logger.info("IPC listener thread started for MAS dashboard.") + while not stop_event.is_set(): + try: + # Timeout to allow checking stop_event periodically + agent_id, module_id, update_token = request_q.get(timeout=0.1) + if agent_id is _STOP_IPC_LISTENER: + logger.info("IPC listener received stop signal.") + break + + logger.debug( + f"IPC listener: Request for {agent_id}/{module_id}, token: {update_token}" + ) + data_chunk, next_token = None, None + try: + agent = mas_instance.get_agent(agent_id) + if agent: + module = agent.get_module(module_id) + if module: + if hasattr(module, "get_results_incremental"): + data_chunk, next_token = module.get_results_incremental( + update_token=update_token + ) + else: + logger.warning( + f"Module {module_id} in agent {agent_id} does not have " + f"'get_results_incremental' method. Falling back to 'get_results'." + ) + if update_token is None: # Only call get_results for initial load + data_chunk = module.get_results() + # next_token remains None, implying no further incremental updates from this fallback + else: + logger.error(f"IPC listener: Module {module_id} not found in agent {agent_id}") + else: + logger.error(f"IPC listener: Agent {agent_id} not found in MAS") + except Exception as e: + logger.error( + f"IPC listener: Error calling get_results_incremental for {agent_id}/{module_id}: {e}", + exc_info=True, + ) + response_q.put((data_chunk, next_token)) + except queue.Empty: + continue # Timeout, check stop_event again + except Exception as e: + logger.error(f"IPC listener: Unexpected error in loop: {e}", exc_info=True) + import time + time.sleep(0.1) # Avoid busy-looping + logger.info("IPC listener thread stopped for MAS dashboard.") + + +def create_dash_app( + agent_module_info: Dict, + live_update: bool, + static_results: Optional[Dict] = None, + ipc_queues: Optional[Tuple[multiprocessing.Queue, multiprocessing.Queue]] = None, + update_interval_sec: Optional[float] = None, +): app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) app.title = "MAS Results Dashboard" - agent_ids = list(processed_mas_results.keys()) + agent_ids = list(agent_module_info.keys()) - app.layout = dbc.Container( - [ - html.H1("Multi-Agent System Results Dashboard", className="my-4"), - dbc.Row( - [ - dbc.Col( - [ - html.Label("Select Agent:"), - dcc.Dropdown( - id="agent-selector", - options=[ - {"label": agent_id, "value": agent_id} - for agent_id in agent_ids - ], - value=agent_ids[0] if agent_ids else None, - clearable=False, - ), - ], - width=6, - ), - dbc.Col( - [ - html.Label("Select Module:"), - dcc.Dropdown( - id="module-selector", - options=[], # Populated by callback - clearable=False, - ), - ], - width=6, - ), - ], - className="mb-4", - ), - dbc.Row( + # In-memory cache for DataFrames within the Dash app process + # This dict is accessible to callbacks defined within create_dash_app + app_data_cache = {} + + layout_components = [ + html.H1("Multi-Agent System Results Dashboard", className="my-4"), + dbc.Row( + [ dbc.Col( - html.Div(id="module-visualization-content"), - ) - ), - ], - fluid=True, - ) + [ + html.Label("Select Agent:"), + dcc.Dropdown( + id="agent-selector", + options=[ + {"label": agent_id, "value": agent_id} + for agent_id in agent_ids + ], + value=agent_ids[0] if agent_ids else None, + clearable=False, + ), + ], + width=6, + ), + dbc.Col( + [ + html.Label("Select Module:"), + dcc.Dropdown( + id="module-selector", + options=[], # Populated by callback + clearable=False, + ), + ], + width=6, + ), + ], + className="mb-4", + ), + dbc.Row(dbc.Col(html.Div(id="module-visualization-content"))), + ] + + if live_update: + layout_components.extend([ + # dcc.Store(id='data-cache', data={}), # Removed: DataFrames not JSON serializable for Store + dcc.Store(id='token-cache', data={}), # Stores { "agent/module": next_token } + dcc.Store(id='data-update-trigger', data=0), # Dummy store to trigger visualization updates + dcc.Interval( + id='live-update-interval', + interval=int(update_interval_sec * 1000) if update_interval_sec else 5000, + n_intervals=0 + ) + ]) + + app.layout = dbc.Container(layout_components, fluid=True) @app.callback( [Output("module-selector", "options"), Output("module-selector", "value")], [Input("agent-selector", "value")], ) def update_module_dropdown(selected_agent_id): - if not selected_agent_id or not processed_mas_results: + if not selected_agent_id: return [], None - # Use preprocessed agent_module_info - modules_for_agent = processed_agent_module_info.get(selected_agent_id, []) - agent_modules_data = processed_mas_results.get( - selected_agent_id, {} - ) # Results data - + modules_for_agent = agent_module_info.get(selected_agent_id, []) module_options = [] first_module_id = None - for module_info in modules_for_agent: - module_id = module_info["id"] - # Check if results exist for this module to decide if it's selectable - if module_id in agent_modules_data: + for module_info_dict in modules_for_agent: + module_id = module_info_dict["id"] + if live_update: module_options.append({"label": module_id, "value": module_id}) if first_module_id is None: first_module_id = module_id - + elif static_results: + agent_modules_data = static_results.get(selected_agent_id, {}) + if module_id in agent_modules_data: # Check if results exist for this module + module_options.append({"label": module_id, "value": module_id}) + if first_module_id is None: + first_module_id = module_id return module_options, first_module_id + # Callback for fetching data in live mode + if live_update: + @app.callback( + [Output('token-cache', 'data'), Output('data-update-trigger', 'data')], + [Input('live-update-interval', 'n_intervals'), + Input("agent-selector", "value"), + Input("module-selector", "value")], + [State('token-cache', 'data'), State('data-update-trigger', 'data')] + ) + def fetch_live_data(n_intervals, selected_agent_id, selected_module_id, + current_token_cache, current_trigger_val): + ctx = dash.callback_context + triggered_prop_id = ctx.triggered[0]['prop_id'] if ctx.triggered else "initial_load" + + if not selected_agent_id or not selected_module_id or not ipc_queues: + return current_token_cache, dash.no_update # No change to trigger if no selection + + request_q, response_q = ipc_queues + module_key = f"{selected_agent_id}/{selected_module_id}" + + update_token_to_send = None + if 'live-update-interval.n_intervals' in triggered_prop_id: + update_token_to_send = current_token_cache.get(module_key) + + logger.debug(f"Dash app: Requesting data for {module_key} with token: {update_token_to_send}") + try: + request_q.put((selected_agent_id, selected_module_id, update_token_to_send)) + data_chunk, next_token = response_q.get(timeout=(update_interval_sec * 0.9) if update_interval_sec else 4.5) + logger.debug(f"Dash app: Received data for {module_key}. Next token: {next_token}") + + # Update in-memory Python dictionary for DataFrames + app_data_cache[module_key] = data_chunk + + new_token_cache = current_token_cache.copy() + new_token_cache[module_key] = next_token + + # Increment trigger to signal data update for visualization callback + new_trigger_val = (current_trigger_val + 1) if current_trigger_val is not None else 0 + return new_token_cache, new_trigger_val + + except queue.Empty: + logger.warning(f"Dash app: Timeout waiting for data from IPC for {module_key}") + return current_token_cache, dash.no_update + except Exception as e: + logger.error(f"Dash app: Error in fetch_live_data for {module_key}: {e}", exc_info=True) + return current_token_cache, dash.no_update + + # Callback for displaying module visualization @app.callback( Output("module-visualization-content", "children"), - [Input("agent-selector", "value"), Input("module-selector", "value")], + [Input("agent-selector", "value"), + Input("module-selector", "value"), + Input('data-update-trigger', 'data') if live_update else Input("module-selector", "value")], + # The dummy Input for static mode (using module-selector again) is just to make Dash happy + # with the conditional Input list. The value of this dummy input isn't used in static mode for this logic. ) - def display_module_visualization(selected_agent_id, selected_module_id): - if not selected_agent_id or not selected_module_id or not processed_mas_results: + def display_module_visualization(selected_agent_id, selected_module_id, data_update_trigger_val_or_dummy): + if not selected_agent_id or not selected_module_id: return html.P("Select an agent and a module to view visualization.") - # Get module class_path from preprocessed info - agent_modules = processed_agent_module_info.get(selected_agent_id, []) + current_results_data = None + if live_update: + module_key = f"{selected_agent_id}/{selected_module_id}" + # Read from the in-memory Python dictionary + current_results_data = app_data_cache.get(module_key) + + if current_results_data is None: + ctx = dash.callback_context + # Check if triggered by agent/module selection, if so, data is being fetched. + if ctx.triggered and any(trigger['prop_id'] in ['agent-selector.value', 'module-selector.value'] for trigger in ctx.triggered): + return html.P(f"Fetching live data for {selected_module_id}...") + return html.P(f"No live data currently available for module '{selected_module_id}'.") + else: # Static mode + if not static_results: + return html.P("Static results not available.") + current_results_data = static_results.get(selected_agent_id, {}).get( + selected_module_id, "__no_key__" + ) + if isinstance(current_results_data, str) and current_results_data == "__no_key__": + return html.P( + f"No results data key found for module '{selected_module_id}' in agent '{selected_agent_id}' (static mode)." + ) + + agent_modules_meta = agent_module_info.get(selected_agent_id, []) module_info_dict = next( - (m for m in agent_modules if m["id"] == selected_module_id), None + (m for m in agent_modules_meta if m["id"] == selected_module_id), None ) if not module_info_dict: return html.P( - f"Module '{selected_module_id}' (metadata) not found in agent '{selected_agent_id}'." + f"Module metadata for '{selected_module_id}' not found in agent '{selected_agent_id}'." ) - - results_data = processed_mas_results.get(selected_agent_id, {}).get( - selected_module_id, "__no_key__" - ) - - if isinstance(results_data, str) and results_data == "__no_key__": - return html.P( - f"No results data key found for module '{selected_module_id}' in agent '{selected_agent_id}'." - ) - + try: - # Dynamically import the ModuleType class_path = module_info_dict["class_path"] module_path_str, class_name_str = class_path.rsplit(".", 1) imported_module_obj = importlib.import_module(module_path_str) ModuleType = getattr(imported_module_obj, class_name_str) visualization_layout = ModuleType.visualize_results( - results_data=results_data, + results_data=current_results_data, module_id=selected_module_id, agent_id=selected_agent_id, ) @@ -152,7 +294,6 @@ def display_module_visualization(selected_agent_id, selected_module_id): ) return visualization_layout except OptionalDependencyError as e: - # Use logger from the current process (Dash app process) logging.getLogger(__name__).error( f"Optional dependency error for {class_path}: {e}" ) @@ -174,22 +315,35 @@ def display_module_visualization(selected_agent_id, selected_module_id): f"An error occurred while generating the visualization for " f"module '{selected_module_id}': {str(e)}" ) - return app -def _run_dash_server(app_factory_func, app_factory_args, port, host): +def _run_dash_server( + app_factory_func, # This is create_dash_app + # Args for create_dash_app: + agent_module_info_arg: Dict, + live_update_arg: bool, + static_results_arg: Optional[Dict], + ipc_queues_arg: Optional[Tuple[multiprocessing.Queue, multiprocessing.Queue]], + update_interval_sec_arg: Optional[float], + # Args for app.run_server: + port: int, + host: str, +): """ Helper function to create and run the Dash app in a separate process. - app_factory_func is now the actual create_dash_app function. """ - # Ensure that the current process is not a daemon if it needs to spawn children - # (though Dash app itself usually doesn't spawn more processes) - # multiprocessing.current_process().daemon = False - app = app_factory_func(*app_factory_args) + app = app_factory_func( + agent_module_info=agent_module_info_arg, + live_update=live_update_arg, + static_results=static_results_arg, + ipc_queues=ipc_queues_arg, + update_interval_sec=update_interval_sec_arg, + ) logger.info(f"Dash app server starting on http://{host}:{port}") try: - app.run_server(debug=False, port=port, host=host) + # Set use_reloader=False to prevent issues with multiprocessing and threads + app.run_server(debug=False, port=port, host=host, use_reloader=False) except Exception as e: logger.error(f"Error running Dash server: {e}", exc_info=True) @@ -204,70 +358,127 @@ def get_free_port(): return port except OSError: port += 1 + if port > 9000: # Avoid infinite loop in rare cases + raise OSError("Could not find a free port between 8050 and 9000.") def launch_mas_dashboard( - mas: LocalMASAgency, mas_results: dict, block_main: bool + mas: LocalMASAgency, + mas_results: Optional[dict] = None, + block_main: bool = False, + live_update: bool = False, + update_interval_sec: float = 5.0, ) -> multiprocessing.Process | None: """ - Launches the MAS results dashboard in a separate process. + Launches the MAS results dashboard. Args: - mas: The LocalMASAgency instance. - mas_results: A dictionary of results, typically from mas.get_results(). - Expected structure: {agent_id: {module_id: results_data}} + mas: The LocalMASAgency instance. Required for agent_module_info, + and for live_update mode. + mas_results: A dictionary of results for static mode. + Expected: {agent_id: {module_id: results_data}}. + Ignored if live_update is True. + block_main: If True, runs Dash server in the main process, blocking it. + If False, runs in a separate process. + live_update: If True, dashboard attempts to fetch live data from modules. + update_interval_sec: Interval for polling data in live_update mode. Returns: - multiprocessing.Process: The process running the Dash dashboard. + multiprocessing.Process: The process running the Dash dashboard (if not block_main). + None otherwise or if an error occurs. """ - # Preprocess MAS structure for pickling + if mas is None: + logger.error("LocalMASAgency instance ('mas') must be provided.") + return None + if not live_update and mas_results is None: + logger.error("mas_results must be provided if not in live_update mode.") + return None + agent_module_info_for_app = {} - if mas and hasattr(mas, "_agents"): + if hasattr(mas, "_agents"): for agent_id, agent_instance in mas._agents.items(): agent_module_info_for_app[agent_id] = [] - if agent_instance and hasattr(agent_instance, "modules"): + if hasattr(agent_instance, "modules"): for module_instance in agent_instance.modules: module_class = module_instance.__class__ class_path = f"{module_class.__module__}.{module_class.__name__}" agent_module_info_for_app[agent_id].append( {"id": module_instance.id, "class_path": class_path} ) + if not agent_module_info_for_app: + logger.warning("No agents or modules found in the MAS instance. Dashboard might be empty.") port = get_free_port() - host = "0.0.0.0" # Standard host for Dash to be accessible - - # Arguments for the app factory function - app_factory_args = (agent_module_info_for_app, mas_results) - - # open tab + host = "0.0.0.0" + + factory_args_for_create_dash_app = { + "agent_module_info": agent_module_info_for_app, + "live_update": live_update, + "static_results": None if live_update else mas_results, + "ipc_queues": None, + "update_interval_sec": update_interval_sec if live_update else None, + } + + ipc_listener_thread_obj = None # To store the thread object + stop_ipc_event_obj = None # To store the event object + + if live_update: + request_q = multiprocessing.Queue() + response_q = multiprocessing.Queue() + factory_args_for_create_dash_app["ipc_queues"] = (request_q, response_q) + + stop_ipc_event_obj = threading.Event() + ipc_listener_thread_obj = threading.Thread( + target=_ipc_listener_loop, + args=(mas, request_q, response_q, stop_ipc_event_obj), + daemon=True + ) + ipc_listener_thread_obj.start() + webbrowser.open_new_tab(f"http://localhost:{port}") + process_args_for_run_server = ( + create_dash_app, # app_factory_func + factory_args_for_create_dash_app["agent_module_info"], + factory_args_for_create_dash_app["live_update"], + factory_args_for_create_dash_app["static_results"], + factory_args_for_create_dash_app["ipc_queues"], + factory_args_for_create_dash_app["update_interval_sec"], + port, + host, + ) + + dashboard_process = None if not block_main: - # Create and start the process - # Note: On Windows, make sure the script calling this is guarded by if __name__ == '__main__' - # if it also creates processes. - ctx = multiprocessing.get_context( - "spawn" - ) # Using 'spawn' context for better cross-platform compatibility + ctx = multiprocessing.get_context("spawn") dashboard_process = ctx.Process( - target=_run_dash_server, # Target is the helper - args=( - create_dash_app, - app_factory_args, - port, - host, - ), # Pass the top-level create_dash_app and its args - ) - dashboard_process.daemon = ( - False # Main program will wait for this process unless terminated + target=_run_dash_server, + args=process_args_for_run_server ) + dashboard_process.daemon = False # So main program can wait for it if needed dashboard_process.start() logger.info( f"MAS Dashboard is launching in a separate process on http://localhost:{port}" ) + # The caller might need to manage stop_ipc_event_obj and ipc_listener_thread_obj + # if they want to explicitly stop the IPC listener when the dashboard_process is terminated. + # However, as a daemon thread, it will exit with the main process. return dashboard_process else: - # run in main process - _run_dash_server(create_dash_app, app_factory_args, port, host) + # Run in main process + _run_dash_server(*process_args_for_run_server) + if live_update and stop_ipc_event_obj and ipc_listener_thread_obj: + logger.info("Main process dashboard finished. Attempting to stop IPC listener.") + stop_ipc_event_obj.set() + # Send a stop signal through the queue as well to break out of q.get() + if factory_args_for_create_dash_app["ipc_queues"]: + try: + factory_args_for_create_dash_app["ipc_queues"][0].put((_STOP_IPC_LISTENER, None, None), timeout=0.1) + except Exception: # pragma: no cover + pass + ipc_listener_thread_obj.join(timeout=1.0) + if ipc_listener_thread_obj.is_alive(): + logger.warning("IPC listener thread did not stop in time.") + return None if __name__ == "__main__": @@ -281,15 +492,18 @@ def launch_mas_dashboard( "if __name__ == '__main__': \n" " multiprocessing.freeze_support() # For Windows executable freezing \n" " # ... your MAS setup ... \n" - " dashboard_proc = launch_mas_dashboard(my_mas, my_results) \n" + " # For static dashboard: \n" + " # results = my_mas.get_results() \n" + " # dashboard_proc = launch_mas_dashboard(my_mas, mas_results=results) \n" + " # For live dashboard: \n" + " # dashboard_proc = launch_mas_dashboard(my_mas, live_update=True) \n" " # ... your main script continues ... \n" " print('Dashboard process started. Press Ctrl+C in the console running the script, or implement other logic to stop it.') \n" " try: \n" - " dashboard_proc.join() # Wait for dashboard process to finish (e.g., if user closes browser tab and server stops) \n" + " if dashboard_proc: dashboard_proc.join() \n" " except KeyboardInterrupt: \n" " print('Terminating dashboard process...') \n" - " dashboard_proc.terminate() \n" - " dashboard_proc.join() \n" + " if dashboard_proc: dashboard_proc.terminate(); dashboard_proc.join() \n" " print('Dashboard process finished.')" ) pass diff --git a/examples/multi-agent-systems/room_mas/room_mas.py b/examples/multi-agent-systems/room_mas/room_mas.py index c3cdcf6d..7aadc89a 100644 --- a/examples/multi-agent-systems/room_mas/room_mas.py +++ b/examples/multi-agent-systems/room_mas/room_mas.py @@ -29,10 +29,10 @@ def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=F variable_logging=True, ) # Simulate + if with_dashboard: + mas.show_results_dashboard(cleanup_results=False, block_main=False, live=True) mas.run(until=until) # Load results: - if with_dashboard: - mas.show_results_dashboard(cleanup_results=False, block_main=False) results = mas.get_results(cleanup=True) @@ -104,4 +104,4 @@ def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=F if __name__ == "__main__": - run_example(until=86400 / 10, with_plots=True, with_dashboard=True) + run_example(until=86400 / 0.0001, with_plots=True, with_dashboard=True) From dc668cad59f02caf7b72ff14c8e32f3cd43b7ff6 Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 6 Jun 2025 17:12:47 +0200 Subject: [PATCH 08/26] fixes in dashboard and improve code --- agentlib/modules/communicator/communicator.py | 122 +++------ agentlib/modules/simulation/simulator.py | 128 +++++----- agentlib/modules/utils/agent_logger.py | 154 +++++------- agentlib/utils/plotting/mas_dashboard.py | 238 ++++++++++++------ 4 files changed, 331 insertions(+), 311 deletions(-) diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index 0fb66309..6c3baeb0 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -348,41 +348,6 @@ def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: return pd.DataFrame() # Return empty DataFrame if file doesn't exist return None - @classmethod - def _load_detail_log_incremental( - cls, filename: str, start_line_index: int - ) -> tuple[Optional[pd.DataFrame], int]: - """ - Loads detail log file from a specific line index. - Returns a DataFrame chunk and the number of lines read from that point. - """ - log_entries = [] - lines_read_count = 0 - try: - with open(filename, "r", encoding="utf-8") as f: - for i, line in enumerate(f): - if i < start_line_index: - continue - log_entries.append(json.loads(line)) - lines_read_count += 1 - except FileNotFoundError: - logger.warning( - f"Detail log file {filename} not found for incremental load." - ) - return None, 0 - - if not log_entries: - return None, 0 - - try: - df = pd.DataFrame(log_entries) - return df, lines_read_count - except Exception as e: # pragma: no cover - logger.error( - f"Error creating DataFrame from incremental detail log {filename}: {e}" - ) - return None, lines_read_count - def get_results_incremental( self, update_token: Optional[Any] = None ) -> tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: @@ -391,72 +356,65 @@ def get_results_incremental( if log_level == "none": return None, None - elif log_level == "basic": current_counts = { "sent_counts": dict(self._sent_alias_counts or {}), "received_counts": { - str( - k - ): v # Ensure keys are strings for JSON compatibility if ever needed + str(k): v for k, v in (self._received_source_alias_counts or {}).items() }, } - # For basic, token could be a hash of the dict, or just the dict itself for comparison. - # If no token, or token is different from current, send current. if update_token is None or update_token != current_counts: - return current_counts, current_counts # Next token is the current state - return None, update_token # No change - + return current_counts, current_counts + return None, update_token elif log_level == "detail": - self._flush_detail_log() # Ensure all data is written + self._flush_detail_log() + if ( not self._communication_log_filename or not Path(self._communication_log_filename).exists() ): - return None, update_token if update_token is not None else 0 + return None, 0 - if update_token is None: # Initial call, load all - try: - # Use existing get_results which handles loading full detail log - df = self.get_results() - lines_in_file = 0 - if df is not None and not df.empty: - # Count lines in the file for the next token - with open( - self._communication_log_filename, "r", encoding="utf-8" - ) as f: - lines_in_file = sum(1 for _ in f) - elif ( - df is None - ): # get_results might return None if file is empty or error - df = pd.DataFrame() # Ensure we return a DataFrame - return df, lines_in_file - except Exception as e: # pragma: no cover - self.logger.error( - f"Error in initial detail log load for get_results_incremental: {e}" - ) + if update_token is None: # Initial call + df = self.get_results() + if df is None or df.empty: return pd.DataFrame(), 0 - else: # Incremental call, update_token is previous line count - try: - start_line_index = int(update_token) - except ValueError: # pragma: no cover - self.logger.error( - f"Invalid update_token for detail log: {update_token}. Expected int." - ) - return ( - pd.DataFrame(), - 0, - ) # Fallback to sending empty and resetting token - + with open(self._communication_log_filename, "r", encoding="utf-8") as f: + lines_in_file = sum(1 for _ in f) + return df, lines_in_file + else: # Incremental call df_chunk, lines_read = self._load_detail_log_incremental( filename=self._communication_log_filename, - start_line_index=start_line_index, + start_line_index=update_token, ) if df_chunk is None or df_chunk.empty: - return None, start_line_index # No new data, token unchanged - return df_chunk, start_line_index + lines_read - return None, None + return None, update_token + return df_chunk, update_token + lines_read + + @classmethod + def _load_detail_log_incremental( + cls, filename: str, start_line_index: int + ) -> tuple[Optional[pd.DataFrame], int]: + """Loads detail log file from a specific line index.""" + log_entries = [] + lines_read_count = 0 + + try: + with open(filename, "r", encoding="utf-8") as f: + for i, line in enumerate(f): + if i < start_line_index: + continue + log_entries.append(json.loads(line)) + lines_read_count += 1 + except FileNotFoundError: + return None, 0 + + if not log_entries: + return None, 0 + + df = pd.DataFrame(log_entries) + return df, lines_read_count def cleanup_results(self): """Deletes the communication log file if 'detail' logging was active.""" diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 00831a11..1b5da9e4 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -187,7 +187,7 @@ def check_nonexisting_csv(cls, result_filename, info: FieldValidationInfo): if not result_filename.endswith(".csv"): raise TypeError( f"Given result_filename ends with " - f'{result_filename.split(".")[-1]} ' + f"{result_filename.split('.')[-1]} " f"but should be a .csv file" ) if os.path.isfile(result_filename): @@ -251,7 +251,7 @@ def check_model(cls, model, info: FieldValidationInfo): states = info.data.get("states") if "type" not in model: raise KeyError( - "Given model config does not " "contain key 'type' (type of the model)." + "Given model config does not contain key 'type' (type of the model)." ) _type = model.pop("type") if isinstance(_type, dict): @@ -500,79 +500,73 @@ def cleanup_results(self): if self.config.result_filename and Path(self.config.result_filename).exists(): os.remove(self.config.result_filename) - def get_results_incremental(self, update_token: Optional[int] = None) -> tuple[Optional[pd.DataFrame], Optional[int]]: - """ - Fetches simulation results incrementally. - If a result_filename is configured, it reads new data from the CSV. - Otherwise, it uses the in-memory SimulatorResults. - The update_token typically represents the number of data rows previously sent. - """ + def get_results_incremental( + self, update_token: Optional[int] = None + ) -> tuple[Optional[pd.DataFrame], Optional[int]]: + """Fetches simulation results incrementally.""" if not self.config.save_results: return None, None if self.config.result_filename: - # Results are being written to a CSV file - if self._result is not None and hasattr(self._result, 'write_results'): - # Ensure latest in-memory portion is flushed to CSV - self._result.write_results(self.config.result_filename) - + # Results are in CSV file + self._result.write_results(self.config.result_filename) file_path = Path(self.config.result_filename) + if not file_path.exists(): - return None, 0 # Or update_token if not None? Consistent with others: 0 for no file. + return None, 0 - if update_token is None: # Initial call + if update_token is None: # Initial call + df = read_simulator_results(str(file_path)) + # Apply same processing as get_results() + df = df.droplevel(level=2, axis=1).droplevel(level=0, axis=1) + return df, len(df) + else: # Incremental call try: - df = read_simulator_results(str(file_path)) - return df, len(df) if df is not None else 0 - except Exception as e: - self.logger.error(f"Error reading full result file {file_path} for incremental: {e}") - return None, 0 - else: # Incremental call, update_token is previous row count - try: - # skiprows needs to account for header (1 line) + previous data rows - df_chunk = pd.read_csv(file_path, header=[0, 1, 2], index_col=0, skiprows=range(1, update_token + 1)) + # Skip header (3 lines) + previous data rows + df_chunk = pd.read_csv( + file_path, + header=[0, 1, 2], + index_col=0, + skiprows=range(1, update_token + 1), + ) if df_chunk.empty: return None, update_token + # Apply same processing as get_results() + df_chunk = df_chunk.droplevel(level=2, axis=1).droplevel( + level=0, axis=1 + ) return df_chunk, update_token + len(df_chunk) - except Exception as e: - self.logger.error(f"Error reading incremental result file {file_path}: {e}") - return None, update_token # Return old token on error + except pd.errors.EmptyDataError: + return None, update_token else: - # Results are in memory (self._result) - if self._result is None: # Should not happen if save_results is True - return None, None - - current_total_rows = len(self._result.index) -1 # -1 because last row is often incomplete + # Results are in memory + current_total_rows = ( + len(self._result.index) - 1 + ) # Exclude incomplete last row if current_total_rows <= 0: return None, 0 - if update_token is None: # Initial call - df = self._result.df() # Gets all valid rows - return df, len(df) if df is not None else 0 - else: # Incremental call - prev_rows_sent = update_token - if prev_rows_sent >= current_total_rows: - return None, prev_rows_sent # No new rows - - # Slice the new data. self._result.data and .index include the potentially incomplete last row. - # self._result.df() correctly excludes it. - # We need to be careful with indices. - # Let's re-construct a DF from the slice of valid data. - - # Valid data is up to current_total_rows (exclusive of the last item in self.data/index) - new_data_slice = self._result.data[prev_rows_sent:current_total_rows] - new_index_slice = self._result.index[prev_rows_sent:current_total_rows] + if update_token is None: # Initial call + df = self._result.df() + df = df.droplevel(level=2, axis=1).droplevel(level=0, axis=1) + return df, len(df) + else: # Incremental call + if update_token >= current_total_rows: + return None, update_token + + new_data_slice = self._result.data[update_token:current_total_rows] + new_index_slice = self._result.index[update_token:current_total_rows] if not new_data_slice: - return None, prev_rows_sent + return None, update_token - df_chunk = pd.DataFrame(new_data_slice, index=new_index_slice, columns=self._result.columns) - - # df_chunk might need droplevel like in get_results() - df_chunk_processed = df_chunk.droplevel(level=2, axis=1).droplevel(level=0, axis=1) - - return df_chunk_processed, current_total_rows - return None, None # Fallback + df_chunk = pd.DataFrame( + new_data_slice, index=new_index_slice, columns=self._result.columns + ) + df_chunk = df_chunk.droplevel(level=2, axis=1).droplevel( + level=0, axis=1 + ) + return df_chunk, current_total_rows @classmethod def visualize_results( @@ -590,9 +584,11 @@ def visualize_results( ) if results_data is None or results_data.empty: - cls.logger.debug(f"No results data for Simulator '{module_id}' in agent '{agent_id}'.") + cls.logger.debug( + f"No results data for Simulator '{module_id}' in agent '{agent_id}'." + ) return None - + if not isinstance(results_data, pd.DataFrame): cls.logger.error( f"Expected pandas DataFrame for Simulator results for '{module_id}', got {type(results_data)}." @@ -616,7 +612,7 @@ def visualize_results( title=str(column), xaxis_title="Time", yaxis_title="Value", - margin=dict(l=40, r=20, t=40, b=30), # Compact margins + margin=dict(l=40, r=20, t=40, b=30), # Compact margins height=250, # Reduced height for compactness ) # Add plot to a column, aim for 2 plots per row on medium screens @@ -626,23 +622,25 @@ def visualize_results( if len(current_row_children) == 2 or i == len(results_data.columns) - 1: rows.append(dbc.Row(current_row_children, className="mb-3")) current_row_children = [] - + except Exception as e: cls.logger.error(f"Error creating plots for Simulator '{module_id}': {e}") - return None + return None if not rows: - cls.logger.debug(f"No plottable data generated for Simulator '{module_id}'.") + cls.logger.debug( + f"No plottable data generated for Simulator '{module_id}'." + ) return None return html.Div( children=[ html.H4( f"Simulator Results: {module_id} (Agent: {agent_id})", - className="mb-3" # Add some margin below the title + className="mb-3", # Add some margin below the title ) ] - + rows, # Add the list of dbc.Row components + + rows, # Add the list of dbc.Row components style={"padding": "10px"}, ) diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index ef4f4278..7261ac35 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -201,6 +201,61 @@ def _load_agent_variable(var): df = df.loc[:, ~df.columns.duplicated(keep="first")] return df.sort_index() + def get_results(self) -> pd.DataFrame: + """Load the own filename""" + # Ensure current in-memory logs are flushed before reading for static results + self._log() + return self.load_from_file( + filename=self.filename, + values_only=self.config.values_only, + merge_sources=self.config.merge_sources, + ) + + def cleanup_results(self): + """Deletes the log if wanted.""" + if self.config.clean_up: + try: + os.remove(self.filename) + except OSError: + self.logger.error( + "Could not delete filename %s. Please delete it yourself.", + self.filename, + ) + + def terminate(self): + # when terminating, we log one last time, since otherwise the data since the + # last log interval is lost + self._log() + + def get_results_incremental( + self, update_token: Optional[int] = None + ) -> tuple[Optional[pd.DataFrame], Optional[int]]: + """Fetches results incrementally for live dashboard.""" + self._log() # Ensure current logs are written + + if update_token is None: # Initial call + df = self.load_from_file( + filename=self.filename, + values_only=self.config.values_only, + merge_sources=self.config.merge_sources, + ) + try: + with open(self.filename, "r") as f: + lines_in_file = sum(1 for _ in f) + except FileNotFoundError: + lines_in_file = 0 + return df, lines_in_file + else: # Incremental call + df_chunk, lines_read = self.load_from_file_incremental( + filename=self.filename, + start_line_index=update_token, + values_only=self.config.values_only, + merge_sources=self.config.merge_sources, + ) + if df_chunk is None or df_chunk.empty: + return None, update_token + return df_chunk, update_token + lines_read + @classmethod def load_from_file_incremental( cls, @@ -209,20 +264,10 @@ def load_from_file_incremental( values_only: bool = True, merge_sources: bool = True, ) -> tuple[Optional[pd.DataFrame], int]: - """ - Loads log file from a specific line index and consolidates it as a pandas DataFrame. - - Args: - filename: The file to load. - start_line_index: The 0-based index of the line to start reading from. - values_only: If true, loads a file that only has values saved. - merge_sources: Merges variables by sources if True. - - Returns: - A tuple (DataFrame_chunk, lines_read). DataFrame_chunk is None if no new lines. - """ + """Loads log file from a specific line index.""" chunks = [] lines_read_count = 0 + try: with open(filename, "r") as file: for i, data_line in enumerate(file): @@ -231,107 +276,32 @@ def load_from_file_incremental( chunks.append(json.loads(data_line)) lines_read_count += 1 except FileNotFoundError: - logger.warning(f"Log file {filename} not found for incremental load.") return None, 0 - + if not chunks: return None, 0 full_dict = collections.ChainMap(*chunks) df = pd.DataFrame.from_dict(full_dict, orient="index") - if df.empty: - return None, lines_read_count - df.index = df.index.astype(float) columns = (literal_eval(column) for column in df.columns) df.columns = pd.MultiIndex.from_tuples(columns) if not values_only: + def _load_agent_variable(var): try: return AgentVariable.validate_data(var) except TypeError: - pass # pragma: no cover + pass + df = df.applymap(_load_agent_variable) if merge_sources: df = df.droplevel(1, axis=1) df = df.loc[:, ~df.columns.duplicated(keep="first")] - - return df.sort_index(), lines_read_count - - def get_results(self) -> pd.DataFrame: - """Load the own filename""" - # Ensure current in-memory logs are flushed before reading for static results - self._log() - return self.load_from_file( - filename=self.filename, - values_only=self.config.values_only, - merge_sources=self.config.merge_sources, - ) - def cleanup_results(self): - """Deletes the log if wanted.""" - if self.config.clean_up: - try: - os.remove(self.filename) - except OSError: - self.logger.error( - "Could not delete filename %s. Please delete it yourself.", - self.filename, - ) - - def terminate(self): - # when terminating, we log one last time, since otherwise the data since the - # last log interval is lost - self._log() - - def get_results_incremental(self, update_token: Optional[int] = None) -> tuple[Optional[pd.DataFrame], Optional[int]]: - """ - Fetches results incrementally for live dashboard. - The update_token is the number of lines previously read. - """ - self._log() # Ensure all current in-memory logs are written to file - - if update_token is None: # Initial call - try: - df = self.load_from_file( - filename=self.filename, - values_only=self.config.values_only, - merge_sources=self.config.merge_sources, - ) - # Count lines in the file for the next token - lines_in_file = 0 - try: - with open(self.filename, "r") as f: - lines_in_file = sum(1 for _ in f) - except FileNotFoundError: - pass # File might have been cleaned up if empty initially - return df, lines_in_file - except FileNotFoundError: - logger.warning(f"Log file {self.filename} not found for initial load.") - return None, 0 # No data, next token is 0 lines - except Exception as e: - logger.error(f"Error during initial load_from_file for {self.filename}: {e}", exc_info=True) - return None, 0 - - else: # Subsequent calls, update_token is the start_line_index - start_line_index = update_token - df_chunk, lines_read = self.load_from_file_incremental( - filename=self.filename, - start_line_index=start_line_index, - values_only=self.config.values_only, - merge_sources=self.config.merge_sources, - ) - if df_chunk is None or df_chunk.empty: - return None, start_line_index # No new data, token remains same - - # The current implementation of load_from_file_incremental returns a full df - # of new lines. For dashboard, we might want to append or replace. - # For now, returning the chunk of new data. - # The dashboard will need to handle merging if it maintains a global state. - # However, our mas_dashboard currently replaces data. - return df_chunk, start_line_index + lines_read + return df.sort_index(), lines_read_count @classmethod def visualize_results( diff --git a/agentlib/utils/plotting/mas_dashboard.py b/agentlib/utils/plotting/mas_dashboard.py index fc8f81d8..39d5e110 100644 --- a/agentlib/utils/plotting/mas_dashboard.py +++ b/agentlib/utils/plotting/mas_dashboard.py @@ -9,11 +9,11 @@ import importlib import logging import multiprocessing +import queue # For queue.Empty import socket -import webbrowser import threading -import queue # For queue.Empty -from typing import Dict, Optional, Tuple, Any, List +import webbrowser +from typing import Dict, Optional, Tuple try: import dash @@ -69,11 +69,15 @@ def _ipc_listener_loop( f"Module {module_id} in agent {agent_id} does not have " f"'get_results_incremental' method. Falling back to 'get_results'." ) - if update_token is None: # Only call get_results for initial load + if ( + update_token is None + ): # Only call get_results for initial load data_chunk = module.get_results() # next_token remains None, implying no further incremental updates from this fallback else: - logger.error(f"IPC listener: Module {module_id} not found in agent {agent_id}") + logger.error( + f"IPC listener: Module {module_id} not found in agent {agent_id}" + ) else: logger.error(f"IPC listener: Agent {agent_id} not found in MAS") except Exception as e: @@ -87,7 +91,8 @@ def _ipc_listener_loop( except Exception as e: logger.error(f"IPC listener: Unexpected error in loop: {e}", exc_info=True) import time - time.sleep(0.1) # Avoid busy-looping + + time.sleep(0.1) # Avoid busy-looping logger.info("IPC listener thread stopped for MAS dashboard.") @@ -105,7 +110,7 @@ def create_dash_app( # In-memory cache for DataFrames within the Dash app process # This dict is accessible to callbacks defined within create_dash_app - app_data_cache = {} + app_data_cache = {} layout_components = [ html.H1("Multi-Agent System Results Dashboard", className="my-4"), @@ -144,16 +149,24 @@ def create_dash_app( ] if live_update: - layout_components.extend([ - # dcc.Store(id='data-cache', data={}), # Removed: DataFrames not JSON serializable for Store - dcc.Store(id='token-cache', data={}), # Stores { "agent/module": next_token } - dcc.Store(id='data-update-trigger', data=0), # Dummy store to trigger visualization updates - dcc.Interval( - id='live-update-interval', - interval=int(update_interval_sec * 1000) if update_interval_sec else 5000, - n_intervals=0 - ) - ]) + layout_components.extend( + [ + # dcc.Store(id='data-cache', data={}), # Removed: DataFrames not JSON serializable for Store + dcc.Store( + id="token-cache", data={} + ), # Stores { "agent/module": next_token } + dcc.Store( + id="data-update-trigger", data=0 + ), # Dummy store to trigger visualization updates + dcc.Interval( + id="live-update-interval", + interval=( + int(update_interval_sec * 1000) if update_interval_sec else 5000 + ), + n_intervals=0, + ), + ] + ) app.layout = dbc.Container(layout_components, fluid=True) @@ -177,7 +190,9 @@ def update_module_dropdown(selected_agent_id): first_module_id = module_id elif static_results: agent_modules_data = static_results.get(selected_agent_id, {}) - if module_id in agent_modules_data: # Check if results exist for this module + if ( + module_id in agent_modules_data + ): # Check if results exist for this module module_options.append({"label": module_id, "value": module_id}) if first_module_id is None: first_module_id = module_id @@ -185,61 +200,125 @@ def update_module_dropdown(selected_agent_id): # Callback for fetching data in live mode if live_update: + @app.callback( - [Output('token-cache', 'data'), Output('data-update-trigger', 'data')], - [Input('live-update-interval', 'n_intervals'), - Input("agent-selector", "value"), - Input("module-selector", "value")], - [State('token-cache', 'data'), State('data-update-trigger', 'data')] + [Output("token-cache", "data"), Output("data-update-trigger", "data")], + [ + Input("live-update-interval", "n_intervals"), + Input("agent-selector", "value"), + Input("module-selector", "value"), + ], + [State("token-cache", "data"), State("data-update-trigger", "data")], ) - def fetch_live_data(n_intervals, selected_agent_id, selected_module_id, - current_token_cache, current_trigger_val): + def fetch_live_data( + n_intervals, + selected_agent_id, + selected_module_id, + current_token_cache, + current_trigger_val, + ): ctx = dash.callback_context - triggered_prop_id = ctx.triggered[0]['prop_id'] if ctx.triggered else "initial_load" + triggered_prop_id = ( + ctx.triggered[0]["prop_id"] if ctx.triggered else "initial_load" + ) if not selected_agent_id or not selected_module_id or not ipc_queues: - return current_token_cache, dash.no_update # No change to trigger if no selection + return ( + current_token_cache, + dash.no_update, + ) request_q, response_q = ipc_queues module_key = f"{selected_agent_id}/{selected_module_id}" - - update_token_to_send = None - if 'live-update-interval.n_intervals' in triggered_prop_id: + + # Check if agent/module selection changed - if so, clear cache for this module + if any( + trigger["prop_id"] in ["agent-selector.value", "module-selector.value"] + for trigger in ctx.triggered + if trigger["prop_id"] != "" + ): + app_data_cache[module_key] = None # Clear cache for new selection + update_token_to_send = None # Start fresh + else: update_token_to_send = current_token_cache.get(module_key) - - logger.debug(f"Dash app: Requesting data for {module_key} with token: {update_token_to_send}") + + logger.debug( + f"Dash app: Requesting data for {module_key} with token: {update_token_to_send}" + ) try: - request_q.put((selected_agent_id, selected_module_id, update_token_to_send)) - data_chunk, next_token = response_q.get(timeout=(update_interval_sec * 0.9) if update_interval_sec else 4.5) - logger.debug(f"Dash app: Received data for {module_key}. Next token: {next_token}") + request_q.put( + (selected_agent_id, selected_module_id, update_token_to_send) + ) + data_chunk, next_token = response_q.get( + timeout=(update_interval_sec * 0.9) if update_interval_sec else 4.5 + ) + logger.debug( + f"Dash app: Received data for {module_key}. Next token: {next_token}" + ) + + # Accumulate data in memory + if data_chunk is not None: + if ( + app_data_cache.get(module_key) is None + or update_token_to_send is None + ): + # First load or fresh start + app_data_cache[module_key] = data_chunk + else: + # Append new data to existing dataframe + existing_df = app_data_cache[module_key] + if hasattr(data_chunk, "index") and hasattr( + existing_df, "index" + ): + # Both are DataFrames - concatenate + import pandas as pd + + app_data_cache[module_key] = pd.concat( + [existing_df, data_chunk] + ) + else: + # Handle other data types as needed + app_data_cache[module_key] = data_chunk - # Update in-memory Python dictionary for DataFrames - app_data_cache[module_key] = data_chunk - new_token_cache = current_token_cache.copy() new_token_cache[module_key] = next_token - + # Increment trigger to signal data update for visualization callback - new_trigger_val = (current_trigger_val + 1) if current_trigger_val is not None else 0 + new_trigger_val = ( + (current_trigger_val + 1) if current_trigger_val is not None else 0 + ) return new_token_cache, new_trigger_val except queue.Empty: - logger.warning(f"Dash app: Timeout waiting for data from IPC for {module_key}") - return current_token_cache, dash.no_update + logger.warning( + f"Dash app: Timeout waiting for data from IPC for {module_key}" + ) + return current_token_cache, dash.no_update except Exception as e: - logger.error(f"Dash app: Error in fetch_live_data for {module_key}: {e}", exc_info=True) + logger.error( + f"Dash app: Error in fetch_live_data for {module_key}: {e}", + exc_info=True, + ) return current_token_cache, dash.no_update # Callback for displaying module visualization @app.callback( Output("module-visualization-content", "children"), - [Input("agent-selector", "value"), - Input("module-selector", "value"), - Input('data-update-trigger', 'data') if live_update else Input("module-selector", "value")], + [ + Input("agent-selector", "value"), + Input("module-selector", "value"), + ( + Input("data-update-trigger", "data") + if live_update + else Input("module-selector", "value") + ), + ], # The dummy Input for static mode (using module-selector again) is just to make Dash happy # with the conditional Input list. The value of this dummy input isn't used in static mode for this logic. ) - def display_module_visualization(selected_agent_id, selected_module_id, data_update_trigger_val_or_dummy): + def display_module_visualization( + selected_agent_id, selected_module_id, data_update_trigger_val_or_dummy + ): if not selected_agent_id or not selected_module_id: return html.P("Select an agent and a module to view visualization.") @@ -248,24 +327,33 @@ def display_module_visualization(selected_agent_id, selected_module_id, data_upd module_key = f"{selected_agent_id}/{selected_module_id}" # Read from the in-memory Python dictionary current_results_data = app_data_cache.get(module_key) - + if current_results_data is None: ctx = dash.callback_context # Check if triggered by agent/module selection, if so, data is being fetched. - if ctx.triggered and any(trigger['prop_id'] in ['agent-selector.value', 'module-selector.value'] for trigger in ctx.triggered): + if ctx.triggered and any( + trigger["prop_id"] + in ["agent-selector.value", "module-selector.value"] + for trigger in ctx.triggered + ): return html.P(f"Fetching live data for {selected_module_id}...") - return html.P(f"No live data currently available for module '{selected_module_id}'.") - else: # Static mode + return html.P( + f"No live data currently available for module '{selected_module_id}'." + ) + else: # Static mode if not static_results: - return html.P("Static results not available.") + return html.P("Static results not available.") current_results_data = static_results.get(selected_agent_id, {}).get( selected_module_id, "__no_key__" ) - if isinstance(current_results_data, str) and current_results_data == "__no_key__": + if ( + isinstance(current_results_data, str) + and current_results_data == "__no_key__" + ): return html.P( f"No results data key found for module '{selected_module_id}' in agent '{selected_agent_id}' (static mode)." ) - + agent_modules_meta = agent_module_info.get(selected_agent_id, []) module_info_dict = next( (m for m in agent_modules_meta if m["id"] == selected_module_id), None @@ -275,7 +363,7 @@ def display_module_visualization(selected_agent_id, selected_module_id, data_upd return html.P( f"Module metadata for '{selected_module_id}' not found in agent '{selected_agent_id}'." ) - + try: class_path = module_info_dict["class_path"] module_path_str, class_name_str = class_path.rsplit(".", 1) @@ -315,11 +403,12 @@ def display_module_visualization(selected_agent_id, selected_module_id, data_upd f"An error occurred while generating the visualization for " f"module '{selected_module_id}': {str(e)}" ) + return app def _run_dash_server( - app_factory_func, # This is create_dash_app + app_factory_func, # This is create_dash_app # Args for create_dash_app: agent_module_info_arg: Dict, live_update_arg: bool, @@ -343,7 +432,7 @@ def _run_dash_server( logger.info(f"Dash app server starting on http://{host}:{port}") try: # Set use_reloader=False to prevent issues with multiprocessing and threads - app.run_server(debug=False, port=port, host=host, use_reloader=False) + app.run(debug=False, port=port, host=host, use_reloader=False) except Exception as e: logger.error(f"Error running Dash server: {e}", exc_info=True) @@ -358,7 +447,7 @@ def get_free_port(): return port except OSError: port += 1 - if port > 9000: # Avoid infinite loop in rare cases + if port > 9000: # Avoid infinite loop in rare cases raise OSError("Could not find a free port between 8050 and 9000.") @@ -405,7 +494,9 @@ def launch_mas_dashboard( {"id": module_instance.id, "class_path": class_path} ) if not agent_module_info_for_app: - logger.warning("No agents or modules found in the MAS instance. Dashboard might be empty.") + logger.warning( + "No agents or modules found in the MAS instance. Dashboard might be empty." + ) port = get_free_port() host = "0.0.0.0" @@ -418,26 +509,26 @@ def launch_mas_dashboard( "update_interval_sec": update_interval_sec if live_update else None, } - ipc_listener_thread_obj = None # To store the thread object - stop_ipc_event_obj = None # To store the event object + ipc_listener_thread_obj = None # To store the thread object + stop_ipc_event_obj = None # To store the event object if live_update: request_q = multiprocessing.Queue() response_q = multiprocessing.Queue() factory_args_for_create_dash_app["ipc_queues"] = (request_q, response_q) - + stop_ipc_event_obj = threading.Event() ipc_listener_thread_obj = threading.Thread( target=_ipc_listener_loop, args=(mas, request_q, response_q, stop_ipc_event_obj), - daemon=True + daemon=True, ) ipc_listener_thread_obj.start() - + webbrowser.open_new_tab(f"http://localhost:{port}") process_args_for_run_server = ( - create_dash_app, # app_factory_func + create_dash_app, # app_factory_func factory_args_for_create_dash_app["agent_module_info"], factory_args_for_create_dash_app["live_update"], factory_args_for_create_dash_app["static_results"], @@ -451,10 +542,9 @@ def launch_mas_dashboard( if not block_main: ctx = multiprocessing.get_context("spawn") dashboard_process = ctx.Process( - target=_run_dash_server, - args=process_args_for_run_server + target=_run_dash_server, args=process_args_for_run_server ) - dashboard_process.daemon = False # So main program can wait for it if needed + dashboard_process.daemon = False # So main program can wait for it if needed dashboard_process.start() logger.info( f"MAS Dashboard is launching in a separate process on http://localhost:{port}" @@ -467,14 +557,18 @@ def launch_mas_dashboard( # Run in main process _run_dash_server(*process_args_for_run_server) if live_update and stop_ipc_event_obj and ipc_listener_thread_obj: - logger.info("Main process dashboard finished. Attempting to stop IPC listener.") + logger.info( + "Main process dashboard finished. Attempting to stop IPC listener." + ) stop_ipc_event_obj.set() # Send a stop signal through the queue as well to break out of q.get() if factory_args_for_create_dash_app["ipc_queues"]: try: - factory_args_for_create_dash_app["ipc_queues"][0].put((_STOP_IPC_LISTENER, None, None), timeout=0.1) - except Exception: # pragma: no cover - pass + factory_args_for_create_dash_app["ipc_queues"][0].put( + (_STOP_IPC_LISTENER, None, None), timeout=0.1 + ) + except Exception: # pragma: no cover + pass ipc_listener_thread_obj.join(timeout=1.0) if ipc_listener_thread_obj.is_alive(): logger.warning("IPC listener thread did not stop in time.") From b75fc375a54b7d1c3ab776fa37ec7b590b24f490 Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 6 Jun 2025 17:22:27 +0200 Subject: [PATCH 09/26] revert room_mas example to normal --- examples/multi-agent-systems/room_mas/room_mas.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/multi-agent-systems/room_mas/room_mas.py b/examples/multi-agent-systems/room_mas/room_mas.py index 7aadc89a..68cee1a5 100644 --- a/examples/multi-agent-systems/room_mas/room_mas.py +++ b/examples/multi-agent-systems/room_mas/room_mas.py @@ -29,11 +29,11 @@ def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=F variable_logging=True, ) # Simulate - if with_dashboard: - mas.show_results_dashboard(cleanup_results=False, block_main=False, live=True) + mas.run(until=until) # Load results: - + if with_dashboard: + mas.show_results_dashboard(cleanup_results=False, block_main=False, live=False) results = mas.get_results(cleanup=True) if not with_plots: @@ -104,4 +104,4 @@ def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=F if __name__ == "__main__": - run_example(until=86400 / 0.0001, with_plots=True, with_dashboard=True) + run_example(until=86400 / 10, with_plots=True, with_dashboard=True) From 5f52c1eaf2e9e161d81cdf61135dd0037bd15ee0 Mon Sep 17 00:00:00 2001 From: ses Date: Mon, 16 Jun 2025 10:29:44 +0200 Subject: [PATCH 10/26] fixes in simulator.py --- agentlib/modules/simulation/simulator.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 1b5da9e4..4ad5b9ab 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -509,7 +509,6 @@ def get_results_incremental( if self.config.result_filename: # Results are in CSV file - self._result.write_results(self.config.result_filename) file_path = Path(self.config.result_filename) if not file_path.exists(): @@ -584,16 +583,12 @@ def visualize_results( ) if results_data is None or results_data.empty: - cls.logger.debug( - f"No results data for Simulator '{module_id}' in agent '{agent_id}'." - ) return None if not isinstance(results_data, pd.DataFrame): - cls.logger.error( + raise ValueError( f"Expected pandas DataFrame for Simulator results for '{module_id}', got {type(results_data)}." ) - return None rows = [] current_row_children = [] From 96c0c9c9c0401cae5e6c1fef1d6037fbe691082a Mon Sep 17 00:00:00 2001 From: ses Date: Fri, 20 Jun 2025 11:15:21 +0200 Subject: [PATCH 11/26] fix default filename for overwriting in agentlogger --- agentlib/modules/utils/agent_logger.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index 7261ac35..6810e102 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -5,7 +5,6 @@ import json import logging import os -import time from ast import literal_eval from pathlib import Path from typing import Union, Optional @@ -99,13 +98,24 @@ def __init__(self, *, config: dict, agent: Agent): # Use agent ID to create a default filename logs_dir = Path("agent_logs") logs_dir.mkdir(exist_ok=True) - self._filename = str(logs_dir / f"{self.agent.id}.jsonl") - - # Handle file exists case based on overwrite_log setting - if Path(self._filename).exists() and not self.config.overwrite_log: - # Generate a unique filename by appending a timestamp - timestamp = int(time.time()) - self._filename = str(logs_dir / f"{self.agent.id}_{timestamp}.jsonl") + default_filename = logs_dir / f"{self.agent.id}.jsonl" + + # Handle file existence based on overwrite_log setting + if default_filename.exists(): + if self.config.overwrite_log: + # If overwrite is true, delete the existing file + default_filename.unlink() + self._filename = str(default_filename) + else: + raise FileExistsError( + f"Default log filename at {default_filename} " + f"already exists. We won't overwrite it automatically. " + f"You can use the key word 'overwrite_log' to " + f"activate automatic overwrite." + ) + else: + # If file does not exist, use the default name + self._filename = str(default_filename) else: self._filename = self.config.filename From 97ff8ad4fb046ae2f40c98692c99c7a3c67c9ff7 Mon Sep 17 00:00:00 2001 From: ses Date: Mon, 4 Aug 2025 09:56:14 +0200 Subject: [PATCH 12/26] fix default filename for overwriting in agentlogger --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e369adb..265d711b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Add an optional dashboard function to the mas utility. - Add optional results logging for communicators. - Add subcription based multiprocessing. +- Fixed a bug, where the AgentLogger would load an old run when no filename was specified, but overwrite_log was true ## 0.8.8 - Percentage display in logging now only appears in simulation speed, not realtime. Fixed percentage when environment has an offset. From 37036091edd50a31428d41e3728202443c357fe2 Mon Sep 17 00:00:00 2001 From: ses Date: Sat, 9 Aug 2025 15:25:47 +0200 Subject: [PATCH 13/26] change port detection and remove buggy error handling from simulator viz. Improve CHANGELOG.md --- CHANGELOG.md | 19 ++++++++- agentlib/modules/simulation/simulator.py | 52 +++++++++++------------- agentlib/utils/plotting/mas_dashboard.py | 14 ++++--- 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 265d711b..cd112966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,23 @@ # Changelog ## 0.9.0 -- Add an optional dashboard function to the mas utility. -- Add optional results logging for communicators. +- Add an optional dashboard function to the mas utility, giving a live overview over all MAS module results. Modules optionally implement the visualization themselves. + - The Dashboard can be launched after a run, blocking main, not blocking main, or experimentally also live during the run. + - Usage: + ```python + mas = LocalMASAgency(...) + mas.show_results_dashboard(live=True) # show live + mas.run(until=None) + # mas.show_results_dashboard(block_main=False) # or show after the run + # ... other plotting or analysis code + ``` +- Add optional results logging for communicators, with available granularity: none, basic, detail. Communication results are also available in mas-dashboard. + ```json + { + "type": "local", + "communication_log_level": "detail" + } + ``` - Add subcription based multiprocessing. - Fixed a bug, where the AgentLogger would load an old run when no filename was specified, but overwrite_log was true diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 4ad5b9ab..14f2cf7f 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -592,41 +592,35 @@ def visualize_results( rows = [] current_row_children = [] - try: - for i, column in enumerate(results_data.columns): - fig = go.Figure() - fig.add_trace( - go.Scatter( - x=results_data.index, - y=results_data[column], - mode="lines", - name=str(column), - ) - ) - fig.update_layout( - title=str(column), - xaxis_title="Time", - yaxis_title="Value", - margin=dict(l=40, r=20, t=40, b=30), # Compact margins - height=250, # Reduced height for compactness + for i, column in enumerate(results_data.columns): + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=results_data.index, + y=results_data[column], + mode="lines", + name=str(column), ) - # Add plot to a column, aim for 2 plots per row on medium screens - current_row_children.append(dbc.Col(dcc.Graph(figure=fig), md=6)) - - # If two plots are in the current row, or it's the last plot - if len(current_row_children) == 2 or i == len(results_data.columns) - 1: - rows.append(dbc.Row(current_row_children, className="mb-3")) - current_row_children = [] + ) + fig.update_layout( + title=str(column), + xaxis_title="Time", + yaxis_title="Value", + margin=dict(l=40, r=20, t=40, b=30), # Compact margins + height=250, # Reduced height for compactness + ) + # Add plot to a column, aim for 2 plots per row on medium screens + current_row_children.append(dbc.Col(dcc.Graph(figure=fig), md=6)) - except Exception as e: - cls.logger.error(f"Error creating plots for Simulator '{module_id}': {e}") - return None + # If two plots are in the current row, or it's the last plot + if len(current_row_children) == 2 or i == len(results_data.columns) - 1: + rows.append(dbc.Row(current_row_children, className="mb-3")) + current_row_children = [] if not rows: - cls.logger.debug( + raise ValueError( f"No plottable data generated for Simulator '{module_id}'." ) - return None return html.Div( children=[ diff --git a/agentlib/utils/plotting/mas_dashboard.py b/agentlib/utils/plotting/mas_dashboard.py index 39d5e110..76292c8b 100644 --- a/agentlib/utils/plotting/mas_dashboard.py +++ b/agentlib/utils/plotting/mas_dashboard.py @@ -441,14 +441,16 @@ def get_free_port(): """Get an available port by trying to bind to a socket.""" port = 8050 while True: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - try: - s.bind(("localhost", port)) + port = 8050 + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + is_free = s.connect_ex(("localhost", port)) != 0 + if is_free: return port - except OSError: + else: port += 1 - if port > 9000: # Avoid infinite loop in rare cases - raise OSError("Could not find a free port between 8050 and 9000.") + if port > 9000: # Avoid infinite loop in rare cases + raise OSError("Could not find a free port between 8050 and 9000.") def launch_mas_dashboard( From 27c3e5215819b9cccc66af964e1d38b9898f217b Mon Sep 17 00:00:00 2001 From: ses Date: Sat, 9 Aug 2025 16:05:16 +0200 Subject: [PATCH 14/26] fix some tests and adjust code for agent_logger.py file handling to be more in one place. ci examples are now executed with tempfile --- agentlib/modules/utils/agent_logger.py | 81 ++++++++++---------------- ci/run_examples.py | 17 ++++-- tests/test_communicator.py | 6 +- 3 files changed, 44 insertions(+), 60 deletions(-) diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index 6810e102..b182d774 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -55,32 +55,6 @@ class AgentLoggerConfig(BaseModuleConfig): description="When loading the results file, automatically merges variables by sources, leaving only alias in the columns.", ) - @field_validator("filename") - @classmethod - def check_existence_of_file(cls, filename, info: FieldValidationInfo): - """Checks whether the file already exists.""" - # pylint: disable=no-self-argument,no-self-use - - # Skip check for None, as it will be replaced in __init__ - if filename is None: - return filename - - file_path = Path(filename) - if file_path.exists(): - # remove result file, so a new one can be created - if info.data["overwrite_log"]: - file_path.unlink() - return filename - raise FileExistsError( - f"Given filename at {filename} " - f"already exists. We won't overwrite it automatically. " - f"You can use the key word 'overwrite_log' to " - f"activate automatic overwrite." - ) - # Create path in case it does not exist - file_path.parent.mkdir(parents=True, exist_ok=True) - return filename - class AgentLogger(BaseModule): """ @@ -94,30 +68,7 @@ def __init__(self, *, config: dict, agent: Agent): super().__init__(config=config, agent=agent) # If filename is None, create a custom one using the agent ID - if self.config.filename is None: - # Use agent ID to create a default filename - logs_dir = Path("agent_logs") - logs_dir.mkdir(exist_ok=True) - default_filename = logs_dir / f"{self.agent.id}.jsonl" - - # Handle file existence based on overwrite_log setting - if default_filename.exists(): - if self.config.overwrite_log: - # If overwrite is true, delete the existing file - default_filename.unlink() - self._filename = str(default_filename) - else: - raise FileExistsError( - f"Default log filename at {default_filename} " - f"already exists. We won't overwrite it automatically. " - f"You can use the key word 'overwrite_log' to " - f"activate automatic overwrite." - ) - else: - # If file does not exist, use the default name - self._filename = str(default_filename) - else: - self._filename = self.config.filename + self._filename = self._setup_log_file() self._variables_to_log = {} if not self.env.config.rt and self.config.t_sample < 60: @@ -128,6 +79,36 @@ def __init__(self, *, config: dict, agent: Agent): self.config.t_sample, ) + def _setup_log_file(self) -> str: + """Centralized file setup logic""" + # Determine the target filename + if self.config.filename is None: + logs_dir = Path("agent_logs") + target_file = logs_dir / f"{self.agent.id}.jsonl" + else: + target_file = Path(self.config.filename) + + # Handle file existence and overwrite logic + return self._handle_file_existence(target_file) + + def _handle_file_existence(self, file_path: Path) -> str: + """Consistent file existence and overwrite handling""" + # Create parent directories if they don't exist + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Handle existing file + if file_path.exists(): + if self.config.overwrite_log: + file_path.unlink() + self.logger.info(f"Overwrote existing log file: {file_path}") + else: + raise FileExistsError( + f"Log file '{file_path}' already exists. " + f"Set 'overwrite_log=True' to enable automatic overwrite." + ) + + return str(file_path) + @property def filename(self): """Return the filename where to log.""" diff --git a/ci/run_examples.py b/ci/run_examples.py index dcd32c3b..685d4295 100644 --- a/ci/run_examples.py +++ b/ci/run_examples.py @@ -1,6 +1,8 @@ """Module with functions to test the if examples are executable.""" +import shutil +import tempfile import unittest import os import subprocess @@ -17,14 +19,18 @@ class TestExamples(unittest.TestCase): """Test all examples inside the agentlib""" def setUp(self) -> None: - self.timeout = 10 # Seconds which the script is allowed to run + self.timeout = 10 self.main_cwd = os.getcwd() + # Create a unique temp directory for each test + self.temp_dir = tempfile.mkdtemp(prefix="agentlib_test_") + os.chdir(self.temp_dir) def tearDown(self) -> None: broker = LocalBroadcastBroker() broker.delete_all_clients() - # Change back cwd: os.chdir(self.main_cwd) + # Clean up the temporary directory + shutil.rmtree(self.temp_dir, ignore_errors=True) def _run_example(self, example, timeout=None): if timeout is None: @@ -100,24 +106,23 @@ def test_csv_data_source(self): func_name="run_example", with_plots=False, log_level=logging.FATAL, - extrapolation="constant" + extrapolation="constant", ) self._run_example_with_return( file="simulation//csv_data_source.py", func_name="run_example", with_plots=False, log_level=logging.FATAL, - extrapolation="repeat" + extrapolation="repeat", ) self._run_example_with_return( file="simulation//csv_data_source.py", func_name="run_example", with_plots=False, log_level=logging.FATAL, - extrapolation="backwards" + extrapolation="backwards", ) - def test_scipy_model(self): """Tests the scipy model example""" self._run_example(example="models//scipy//scipy_example.py") diff --git a/tests/test_communicator.py b/tests/test_communicator.py index a2848966..52f7a8de 100644 --- a/tests/test_communicator.py +++ b/tests/test_communicator.py @@ -58,9 +58,7 @@ def test_pd_series(self): pd.testing.assert_series_equal(variable.value, variable2.value) # communicator without json parsing - payload = comm_no_parse.short_dict( - variable, parse_json=comm_no_parse.config.parse_json - ) + payload = comm_no_parse.short_dict(variable) payload["name"] = payload["alias"] variable2 = AgentVariable(**payload) pd.testing.assert_series_equal(variable.value, variable2.value) @@ -72,7 +70,7 @@ def test_pd_series_no_json(self): _config = self.test_config.copy() _config["parse_json"] = False comm = LocalClient(config=_config, agent=self.agent_send) - payload = comm.short_dict(variable, parse_json=comm.config.parse_json) + payload = comm.short_dict(variable) pd.testing.assert_series_equal(variable.value, payload["value"]) From cdc0efd5c1b84ee13d82059462bc76a22bdf5440 Mon Sep 17 00:00:00 2001 From: ses Date: Sat, 9 Aug 2025 16:53:47 +0200 Subject: [PATCH 15/26] support python 3.8 style type hints --- agentlib/core/data_broker.py | 2 +- agentlib/core/module.py | 3 ++- agentlib/modules/communicator/communicator.py | 4 ++-- agentlib/modules/simulation/simulator.py | 4 ++-- agentlib/modules/utils/agent_logger.py | 6 +++--- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/agentlib/core/data_broker.py b/agentlib/core/data_broker.py index b969631f..a64ababf 100644 --- a/agentlib/core/data_broker.py +++ b/agentlib/core/data_broker.py @@ -404,7 +404,7 @@ def __init__( self.thread = threading.Thread( target=self._callback_thread, daemon=True, name="DataBroker" ) - self._module_queues: dict[Union[str, None], queue.Queue] = {} + self._module_queues: Dict[Union[str, None], queue.Queue] = {} env.process(self._start_executing_callbacks(env)) diff --git a/agentlib/core/module.py b/agentlib/core/module.py index 1cd66f57..e255be78 100644 --- a/agentlib/core/module.py +++ b/agentlib/core/module.py @@ -16,6 +16,7 @@ Optional, get_type_hints, Type, + Tuple, ) import pydantic @@ -708,7 +709,7 @@ def get_results(self): Some form of results data, often in the form of a pandas DataFrame. """ - def get_results_incremental(self, update_token: Optional[Any] = None) -> tuple[Any, Optional[Any]]: + def get_results_incremental(self, update_token: Optional[Any] = None) -> Tuple[Any, Optional[Any]]: """ Fetches results suitable for incremental updates in a live dashboard. diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index 6c3baeb0..f78d7a5e 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -10,7 +10,7 @@ import queue import threading from pathlib import Path -from typing import Union, List, TypedDict, Any, Optional, Literal +from typing import Union, List, TypedDict, Any, Optional, Literal, Tuple import pandas as pd from pydantic import Field, field_validator @@ -350,7 +350,7 @@ def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: def get_results_incremental( self, update_token: Optional[Any] = None - ) -> tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: + ) -> Tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: """Returns logged communication data incrementally.""" log_level = self.config.communication_log_level diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 14f2cf7f..87ee6d3f 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from math import inf from pathlib import Path -from typing import Union, Dict, List, Optional +from typing import Union, Dict, List, Optional, Tuple import numpy as np import pandas as pd @@ -502,7 +502,7 @@ def cleanup_results(self): def get_results_incremental( self, update_token: Optional[int] = None - ) -> tuple[Optional[pd.DataFrame], Optional[int]]: + ) -> Tuple[Optional[pd.DataFrame], Optional[int]]: """Fetches simulation results incrementally.""" if not self.config.save_results: return None, None diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index b182d774..de7af679 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -7,7 +7,7 @@ import os from ast import literal_eval from pathlib import Path -from typing import Union, Optional +from typing import Union, Optional, Tuple import pandas as pd from pydantic import field_validator, Field @@ -220,7 +220,7 @@ def terminate(self): def get_results_incremental( self, update_token: Optional[int] = None - ) -> tuple[Optional[pd.DataFrame], Optional[int]]: + ) -> Tuple[Optional[pd.DataFrame], Optional[int]]: """Fetches results incrementally for live dashboard.""" self._log() # Ensure current logs are written @@ -254,7 +254,7 @@ def load_from_file_incremental( start_line_index: int, values_only: bool = True, merge_sources: bool = True, - ) -> tuple[Optional[pd.DataFrame], int]: + ) -> Tuple[Optional[pd.DataFrame], int]: """Loads log file from a specific line index.""" chunks = [] lines_read_count = 0 From fc955df840dd2a6574f417e3a03a46da56a9a50a Mon Sep 17 00:00:00 2001 From: ses Date: Sat, 9 Aug 2025 16:53:47 +0200 Subject: [PATCH 16/26] support python 3.8 style type hints --- agentlib/core/data_broker.py | 2 +- agentlib/core/module.py | 7 +++++-- agentlib/modules/communicator/communicator.py | 6 +++--- agentlib/modules/simulation/simulator.py | 4 ++-- agentlib/modules/utils/agent_logger.py | 6 +++--- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/agentlib/core/data_broker.py b/agentlib/core/data_broker.py index b969631f..a64ababf 100644 --- a/agentlib/core/data_broker.py +++ b/agentlib/core/data_broker.py @@ -404,7 +404,7 @@ def __init__( self.thread = threading.Thread( target=self._callback_thread, daemon=True, name="DataBroker" ) - self._module_queues: dict[Union[str, None], queue.Queue] = {} + self._module_queues: Dict[Union[str, None], queue.Queue] = {} env.process(self._start_executing_callbacks(env)) diff --git a/agentlib/core/module.py b/agentlib/core/module.py index 1cd66f57..8b3173c9 100644 --- a/agentlib/core/module.py +++ b/agentlib/core/module.py @@ -16,6 +16,7 @@ Optional, get_type_hints, Type, + Tuple, ) import pydantic @@ -679,7 +680,7 @@ def visualize_results( try: # Import here to avoid circular dependency issues at module load time # and to only require Dash when this method is actually called. - from dash import html as dash_html + from dash import html as dash_html except ImportError: raise OptionalDependencyError( used_object=f"{cls.__name__}.visualize_results", @@ -708,7 +709,9 @@ def get_results(self): Some form of results data, often in the form of a pandas DataFrame. """ - def get_results_incremental(self, update_token: Optional[Any] = None) -> tuple[Any, Optional[Any]]: + def get_results_incremental( + self, update_token: Optional[Any] = None + ) -> Tuple[Any, Optional[Any]]: """ Fetches results suitable for incremental updates in a live dashboard. diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index 6c3baeb0..a0019e3e 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -10,7 +10,7 @@ import queue import threading from pathlib import Path -from typing import Union, List, TypedDict, Any, Optional, Literal +from typing import Union, List, TypedDict, Any, Optional, Literal, Tuple import pandas as pd from pydantic import Field, field_validator @@ -350,7 +350,7 @@ def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: def get_results_incremental( self, update_token: Optional[Any] = None - ) -> tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: + ) -> Tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: """Returns logged communication data incrementally.""" log_level = self.config.communication_log_level @@ -395,7 +395,7 @@ def get_results_incremental( @classmethod def _load_detail_log_incremental( cls, filename: str, start_line_index: int - ) -> tuple[Optional[pd.DataFrame], int]: + ) -> Tuple[Optional[pd.DataFrame], int]: """Loads detail log file from a specific line index.""" log_entries = [] lines_read_count = 0 diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 14f2cf7f..87ee6d3f 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from math import inf from pathlib import Path -from typing import Union, Dict, List, Optional +from typing import Union, Dict, List, Optional, Tuple import numpy as np import pandas as pd @@ -502,7 +502,7 @@ def cleanup_results(self): def get_results_incremental( self, update_token: Optional[int] = None - ) -> tuple[Optional[pd.DataFrame], Optional[int]]: + ) -> Tuple[Optional[pd.DataFrame], Optional[int]]: """Fetches simulation results incrementally.""" if not self.config.save_results: return None, None diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index b182d774..de7af679 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -7,7 +7,7 @@ import os from ast import literal_eval from pathlib import Path -from typing import Union, Optional +from typing import Union, Optional, Tuple import pandas as pd from pydantic import field_validator, Field @@ -220,7 +220,7 @@ def terminate(self): def get_results_incremental( self, update_token: Optional[int] = None - ) -> tuple[Optional[pd.DataFrame], Optional[int]]: + ) -> Tuple[Optional[pd.DataFrame], Optional[int]]: """Fetches results incrementally for live dashboard.""" self._log() # Ensure current logs are written @@ -254,7 +254,7 @@ def load_from_file_incremental( start_line_index: int, values_only: bool = True, merge_sources: bool = True, - ) -> tuple[Optional[pd.DataFrame], int]: + ) -> Tuple[Optional[pd.DataFrame], int]: """Loads log file from a specific line index.""" chunks = [] lines_read_count = 0 From bbc2d8bb545129ff7845fd08b1ce187036702410 Mon Sep 17 00:00:00 2001 From: ses Date: Tue, 12 Aug 2025 09:40:11 +0200 Subject: [PATCH 17/26] some adjustments to agentlogger results --- agentlib/modules/utils/agent_logger.py | 140 ++++++++---------- .../multi-agent-systems/room_mas/room_mas.py | 16 +- 2 files changed, 75 insertions(+), 81 deletions(-) diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index de7af679..4b4742ea 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -7,16 +7,19 @@ import os from ast import literal_eval from pathlib import Path -from typing import Union, Optional, Tuple +from typing import Union, Optional, Tuple, TYPE_CHECKING import pandas as pd -from pydantic import field_validator, Field -from pydantic_core.core_schema import FieldValidationInfo +from pydantic import Field from agentlib import AgentVariable from agentlib.core import BaseModule, Agent, BaseModuleConfig from agentlib.core.errors import OptionalDependencyError +if TYPE_CHECKING: + from dash import html + + logger = logging.getLogger(__name__) @@ -269,7 +272,7 @@ def load_from_file_incremental( except FileNotFoundError: return None, 0 - if not chunks: + if not any(chunks): return None, 0 full_dict = collections.ChainMap(*chunks) @@ -310,94 +313,79 @@ def visualize_results( ) if results_data is None or results_data.empty: - cls.logger.debug( + raise ValueError( f"No results data for AgentLogger '{module_id}' in agent '{agent_id}'." ) return None - if not isinstance(results_data, pd.DataFrame): - cls.logger.error( - f"Expected pandas DataFrame for AgentLogger results for '{module_id}', got {type(results_data)}." - ) - return None - rows = [] current_row_children = [] - try: - for i, col_name in enumerate(results_data.columns): - series = results_data[col_name].dropna() - if series.empty: - continue + for i, col_name in enumerate(results_data.columns): + series = results_data[col_name].dropna() + if series.empty: + continue - try: - numeric_series = pd.to_numeric(series, errors="coerce") - if numeric_series.isnull().all() and not series.isnull().all(): - is_numeric = False - else: - is_numeric = True - series_to_plot = numeric_series - except (ValueError, TypeError): + try: + numeric_series = pd.to_numeric(series, errors="coerce") + if numeric_series.isnull().all() and not series.isnull().all(): is_numeric = False - - if not is_numeric: - series_to_plot = series - - fig = go.Figure() - y_axis_title = "Value" - - if is_numeric: - fig.add_trace( - go.Scatter( - x=series_to_plot.index, - y=series_to_plot, - mode="lines+markers", - name=str(col_name), - ) - ) else: - fig.add_trace( - go.Scatter( - x=series_to_plot.index, - y=[1] * len(series_to_plot), - mode="markers", - name=str(col_name), - hovertext=[str(v) for v in series_to_plot.values], - hoverinfo="x+text", - ) + is_numeric = True + series_to_plot = numeric_series + except (ValueError, TypeError): + is_numeric = False + + if not is_numeric: + series_to_plot = series + + fig = go.Figure() + y_axis_title = "Value" + + if is_numeric: + fig.add_trace( + go.Scatter( + x=series_to_plot.index, + y=series_to_plot, + mode="lines+markers", + name=str(col_name), ) - y_axis_title = "Occurrence" - - title_str = str(col_name) - if isinstance(col_name, tuple) and len(col_name) > 0: - title_str = f"{col_name[0]}" - if len(col_name) > 1 and col_name[1]: - title_str += f" (Source: {col_name[1]})" - - fig.update_layout( - title=title_str, - xaxis_title="Time", - yaxis_title=y_axis_title, - margin=dict(l=40, r=20, t=40, b=30), - height=250, ) - current_row_children.append(dbc.Col(dcc.Graph(figure=fig), md=6)) - - if len(current_row_children) == 2 or i == len(results_data.columns) - 1: - rows.append(dbc.Row(current_row_children, className="mb-3")) - current_row_children = [] - - except Exception as e: - cls.logger.error( - f"Error creating plots for AgentLogger '{module_id}': {e}", - exc_info=True, + else: + fig.add_trace( + go.Scatter( + x=series_to_plot.index, + y=[1] * len(series_to_plot), + mode="markers", + name=str(col_name), + hovertext=[str(v) for v in series_to_plot.values], + hoverinfo="x+text", + ) + ) + y_axis_title = "Occurrence" + + title_str = str(col_name) + if isinstance(col_name, tuple) and len(col_name) > 0: + title_str = f"{col_name[0]}" + if len(col_name) > 1 and col_name[1]: + title_str += f" (Source: {col_name[1]})" + + fig.update_layout( + title=title_str, + xaxis_title="Time", + yaxis_title=y_axis_title, + margin=dict(l=40, r=20, t=40, b=30), + height=250, ) - return None + current_row_children.append(dbc.Col(dcc.Graph(figure=fig), md=6)) + + if len(current_row_children) == 2 or i == len(results_data.columns) - 1: + rows.append(dbc.Row(current_row_children, className="mb-3")) + current_row_children = [] if not rows: - cls.logger.debug( + raise ValueError( f"No plottable data generated for AgentLogger '{module_id}'." ) - return None return html.Div( children=[ diff --git a/examples/multi-agent-systems/room_mas/room_mas.py b/examples/multi-agent-systems/room_mas/room_mas.py index 68cee1a5..cfc8c62d 100644 --- a/examples/multi-agent-systems/room_mas/room_mas.py +++ b/examples/multi-agent-systems/room_mas/room_mas.py @@ -1,5 +1,6 @@ import logging import os +from typing import Union, Literal from matplotlib.ticker import AutoMinorLocator @@ -8,7 +9,12 @@ logger = logging.getLogger(__name__) -def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=False): +def run_example( + until, + with_plots=True, + log_level=logging.INFO, + with_dashboard: Union[bool, Literal["live", "after"]] = "after", +): # Start by setting the log-level logging.basicConfig(level=log_level) @@ -29,12 +35,12 @@ def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=F variable_logging=True, ) # Simulate - + if with_dashboard == "live": + mas.show_results_dashboard(live=True) mas.run(until=until) - # Load results: if with_dashboard: mas.show_results_dashboard(cleanup_results=False, block_main=False, live=False) - results = mas.get_results(cleanup=True) + results = mas.get_results(cleanup=False) if not with_plots: return results @@ -104,4 +110,4 @@ def run_example(until, with_plots=True, log_level=logging.INFO, with_dashboard=F if __name__ == "__main__": - run_example(until=86400 / 10, with_plots=True, with_dashboard=True) + run_example(until=86400 / 5, with_plots=True, with_dashboard=False) From 1aab977d3fe62ff14fcd24ec8898b798d051251f Mon Sep 17 00:00:00 2001 From: ses Date: Thu, 14 Aug 2025 16:27:07 +0200 Subject: [PATCH 18/26] fix live update should never be blocking --- agentlib/modules/utils/agent_logger.py | 2 ++ agentlib/utils/plotting/mas_dashboard.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index 4b4742ea..902ed0aa 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -174,6 +174,8 @@ def load_from_file( with open(filename, "r") as file: for data_line in file.readlines(): chunks.append(json.loads(data_line)) + if not any(chunks): + return pd.DataFrame() full_dict = collections.ChainMap(*chunks) df = pd.DataFrame.from_dict(full_dict, orient="index") df.index = df.index.astype(float) diff --git a/agentlib/utils/plotting/mas_dashboard.py b/agentlib/utils/plotting/mas_dashboard.py index 76292c8b..92326e1f 100644 --- a/agentlib/utils/plotting/mas_dashboard.py +++ b/agentlib/utils/plotting/mas_dashboard.py @@ -483,6 +483,8 @@ def launch_mas_dashboard( if not live_update and mas_results is None: logger.error("mas_results must be provided if not in live_update mode.") return None + if live_update: + block_main = False agent_module_info_for_app = {} if hasattr(mas, "_agents"): From b77d8a421a52d602491b96f423cfa6794a8b60a5 Mon Sep 17 00:00:00 2001 From: ses Date: Tue, 19 Aug 2025 10:14:08 +0200 Subject: [PATCH 19/26] multiple changes: - deal with pydantic deprecation warning - update README.md - move some logging to debug level - move communicator result handling to a separate file --- README.md | 2 + agentlib/core/module.py | 2 +- agentlib/models/fmu_model.py | 4 +- .../communication_logging_handling.py | 500 ++++++++++++++++++ agentlib/modules/communicator/communicator.py | 488 ++--------------- agentlib/modules/simulation/simulator.py | 4 +- .../multi-agent-systems/room_mas/room_mas.py | 4 +- examples/simulation/csv_data_source.py | 6 +- 8 files changed, 543 insertions(+), 467 deletions(-) create mode 100644 agentlib/modules/communicator/communication_logging_handling.py diff --git a/README.md b/README.md index a7cfd146..6b42d599 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ AgentLib has a number of optional dependencies, ranging from additional features - **plot**: Installs matplotlib, allows to plot the result of examples. - **orjson**: Faster json library, improves performance when using network communicators. - **fuzz**: Improves error messages when providing wrong configurations. + - **interactive**: Supports result visualization in dashboards using plotly dash. + **clonemap**: Support the execution of agents and their communication through [clonemap](https://github.com/sogno-platform/clonemap). As clonemapy is not available through PYPI, please install it from source, or through the AgentLib's ``requirements.txt`` . diff --git a/agentlib/core/module.py b/agentlib/core/module.py index 8b3173c9..57b4c73a 100644 --- a/agentlib/core/module.py +++ b/agentlib/core/module.py @@ -220,7 +220,7 @@ def merge_variables( for field_name, field in cls.model_fields.items(): # If field is missing in values, validation of field was not # successful. Continue and pydantic will later raise the ValidationError - if field_name not in pre_validated_instance.model_fields: + if field_name not in cls.model_fields: continue pre_merged_attr = pre_validated_instance.__getattribute__(field_name) diff --git a/agentlib/models/fmu_model.py b/agentlib/models/fmu_model.py index e1420aa1..44dd23b0 100644 --- a/agentlib/models/fmu_model.py +++ b/agentlib/models/fmu_model.py @@ -126,7 +126,7 @@ def initialize(self, **kwargs): t_start (float): Start time of simulation t_stop (float): Stop time of simulation """ - logger.info("Initializing model...") + logger.debug("Initializing model...") # Handle Logging of the FMU itself: try: callbacks = fmpy.fmi2.fmi2CallbackFunctions() @@ -156,7 +156,7 @@ def initialize(self, **kwargs): ) self.system.enterInitializationMode() self.system.exitInitializationMode() - logger.info("Model: %s initialized", self.name) + logger.debug("Model: %s initialized", self.name) def _fmu_logger(self, component, instanceName, status, category, message): """Print the FMU's log messages to the command line (works for both FMI 1.0 and 2.0)""" diff --git a/agentlib/modules/communicator/communication_logging_handling.py b/agentlib/modules/communicator/communication_logging_handling.py new file mode 100644 index 00000000..f45957db --- /dev/null +++ b/agentlib/modules/communicator/communication_logging_handling.py @@ -0,0 +1,500 @@ +""" +Communication logging and result handling functionality +""" + +import collections +import json +import logging +import os +from pathlib import Path +from typing import Union, List, Any, Optional, Tuple + +import pandas as pd + +from agentlib.core.errors import OptionalDependencyError + +logger = logging.getLogger(__name__) + + +class CommunicationLogger: + """Handles all communication logging and result processing""" + + def __init__(self, config, agent_id: str, module_id: str, env): + self.config = config + self.agent_id = agent_id + self.module_id = module_id + self.env = env + self.logger = logging.getLogger(f"{__name__}.{agent_id}.{module_id}") + + # Initialize logging attributes based on the validated config + self._sent_alias_counts: Optional[collections.Counter] = None + self._received_source_alias_counts: Optional[collections.Counter] = None + self._communication_log_filename: Optional[str] = None + self._communication_log_batch: Optional[List[dict]] = None + + if self.config.communication_log_level == "basic": + self._sent_alias_counts = collections.Counter() + self._received_source_alias_counts = collections.Counter() + elif self.config.communication_log_level == "detail": + self._init_detail_logging() + + def _init_detail_logging(self): + """Helper to initialize attributes for 'detail' logging.""" + self._communication_log_batch = [] + if self.config.communication_log_file is None: + logs_dir = Path("communicator_logs") + logs_dir.mkdir(parents=True, exist_ok=True) + self._communication_log_filename = str( + logs_dir / f"{self.agent_id}_{self.module_id}.jsonl" + ) + else: + self._communication_log_filename = self.config.communication_log_file + # Ensure parent directory exists if a custom path is given + Path(self._communication_log_filename).parent.mkdir( + parents=True, exist_ok=True + ) + + if ( + self._communication_log_filename is not None # Ensure filename is not None + and Path(self._communication_log_filename).exists() + and self.config.communication_log_overwrite + ): + os.remove(self._communication_log_filename) + self.logger.info( + f"Overwriting existing communication log: {self._communication_log_filename}" + ) + + # For non-realtime, schedule periodic flushing + if not self.env.config.rt and self.config.communication_log_t_sample > 0: + self.env.process(self._log_detail_process()) + elif self.config.communication_log_t_sample <= 0 and not self.env.config.rt: + self.logger.info( + "communication_log_t_sample <= 0, detail logs will only be written on terminate." + ) + + def _log_detail_process(self): + """SimPy process to periodically flush detail logs.""" + while True: + yield self.env.timeout(self.config.communication_log_t_sample) + self._flush_detail_log() + + def _flush_detail_log(self): + """Writes the current batch of detail logs to file.""" + if ( + not self._communication_log_batch + or self._communication_log_filename is None + ): + return + + try: + with open(self._communication_log_filename, "a", encoding="utf-8") as f: + for entry in self._communication_log_batch: + json.dump(entry, f) + f.write("\n") + self.logger.debug( + f"Flushed {len(self._communication_log_batch)} entries to {self._communication_log_filename}" + ) + self._communication_log_batch = [] # Clear batch + except IOError as e: + self.logger.error( + f"Error writing communication detail log to {self._communication_log_filename}: {e}" + ) + + def log_sent_message(self, alias: str): + """Log a sent message""" + if ( + self.config.communication_log_level == "basic" + and self._sent_alias_counts is not None + ): + self._sent_alias_counts[alias] += 1 + elif ( + self.config.communication_log_level == "detail" + and self._communication_log_batch is not None + ): + log_entry = { + "timestamp": self.env.time, + "direction": "sent", + "own_agent_id": self.agent_id, + "remote_agent_id": None, # Cannot be known for sent messages generally + "alias": alias, + } + self._communication_log_batch.append(log_entry) + + def log_received_message(self, alias: str, source_agent_id: Optional[str]): + """Log a received message""" + if ( + self.config.communication_log_level == "basic" + and self._received_source_alias_counts is not None + ): + self._received_source_alias_counts[(source_agent_id, alias)] += 1 + elif ( + self.config.communication_log_level == "detail" + and self._communication_log_batch is not None + ): + log_entry = { + "timestamp": self.env.time, + "direction": "received", + "own_agent_id": self.agent_id, + "remote_agent_id": source_agent_id, + "alias": alias, + } + self._communication_log_batch.append(log_entry) + + def terminate(self): + """Handle termination cleanup""" + if self.config.communication_log_level == "detail": + self.logger.debug( + f"Terminating communicator {self.module_id}, flushing any remaining detail logs." + ) + self._flush_detail_log() + + def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: + """Returns logged communication data based on the log level.""" + log_level = self.config.communication_log_level + if log_level == "none": + return None + elif log_level == "basic": + return { + "sent_counts": dict(self._sent_alias_counts or {}), + "received_counts": { + str(k): v + for k, v in (self._received_source_alias_counts or {}).items() + }, + } + elif log_level == "detail": + self._flush_detail_log() # Ensure all data is written before reading + if ( + self._communication_log_filename + and Path(self._communication_log_filename).exists() + ): + try: + log_entries = [] + with open( + self._communication_log_filename, "r", encoding="utf-8" + ) as f: + for line in f: + log_entries.append(json.loads(line)) + if not log_entries: # Handle empty log file + return pd.DataFrame() + df = pd.DataFrame(log_entries) + return df + except Exception as e: + self.logger.error( + f"Error loading communication detail log from {self._communication_log_filename}: {e}" + ) + return pd.DataFrame() # Return empty DataFrame on error + return pd.DataFrame() # Return empty DataFrame if file doesn't exist + return None + + def get_results_incremental( + self, update_token: Optional[Any] = None + ) -> Tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: + """Returns logged communication data incrementally.""" + log_level = self.config.communication_log_level + + if log_level == "none": + return None, None + elif log_level == "basic": + current_counts = { + "sent_counts": dict(self._sent_alias_counts or {}), + "received_counts": { + str(k): v + for k, v in (self._received_source_alias_counts or {}).items() + }, + } + if update_token is None or update_token != current_counts: + return current_counts, current_counts + return None, update_token + elif log_level == "detail": + self._flush_detail_log() + + if ( + not self._communication_log_filename + or not Path(self._communication_log_filename).exists() + ): + return None, 0 + + if update_token is None: # Initial call + df = self.get_results() + if df is None or df.empty: + return pd.DataFrame(), 0 + with open(self._communication_log_filename, "r", encoding="utf-8") as f: + lines_in_file = sum(1 for _ in f) + return df, lines_in_file + else: # Incremental call + df_chunk, lines_read = self._load_detail_log_incremental( + filename=self._communication_log_filename, + start_line_index=update_token, + ) + if df_chunk is None or df_chunk.empty: + return None, update_token + return df_chunk, update_token + lines_read + + @classmethod + def _load_detail_log_incremental( + cls, filename: str, start_line_index: int + ) -> Tuple[Optional[pd.DataFrame], int]: + """Loads detail log file from a specific line index.""" + log_entries = [] + lines_read_count = 0 + + try: + with open(filename, "r", encoding="utf-8") as f: + for i, line in enumerate(f): + if i < start_line_index: + continue + log_entries.append(json.loads(line)) + lines_read_count += 1 + except FileNotFoundError: + return None, 0 + + if not log_entries: + return None, 0 + + df = pd.DataFrame(log_entries) + return df, lines_read_count + + def cleanup_results(self): + """Deletes the communication log file if 'detail' logging was active.""" + if ( + self.config.communication_log_level == "detail" + and self._communication_log_filename + ): + try: + if Path(self._communication_log_filename).exists(): + os.remove(self._communication_log_filename) + self.logger.info( + f"Cleaned up communication log file: {self._communication_log_filename}" + ) + except OSError as e: + self.logger.error( + f"Error cleaning up communication log file {self._communication_log_filename}: {e}" + ) + + @classmethod + def visualize_results( + cls, + results_data: Optional[Union[dict, pd.DataFrame]], + module_id: str, + agent_id: str, + ) -> "Optional[html.Div]": + try: + from dash import dcc, html + import plotly.graph_objects as go + import dash_bootstrap_components as dbc + except ImportError: + raise OptionalDependencyError( + used_object="Dashboard", + dependency_install="dash", + dependency_name="interactive", + ) + if results_data is None: + return html.Div( + f"No communication data to visualize for {module_id} (Agent: {agent_id}). Log level might be 'none'." + ) + + main_container_children = [ + html.H4( + f"Communicator Activity: {module_id} (Agent: {agent_id})", + className="mb-3", + ) + ] + + if isinstance(results_data, dict): # Basic logging + log_type_info = html.P("Log Level: basic (Counters)") + sent_counts = results_data.get("sent_counts", {}) + received_counts_str_keys = results_data.get("received_counts", {}) + + received_counts_display = { + k: v for k, v in received_counts_str_keys.items() + } + + if sent_counts: + sent_fig = go.Figure( + data=[ + go.Bar(x=list(sent_counts.keys()), y=list(sent_counts.values())) + ] + ) + sent_fig.update_layout( + title_text="Sent Message Counts by Alias", + xaxis_title="Alias", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=sent_fig)) + else: + main_container_children.append( + html.P( + "No messages sent or 'basic' logging did not capture sent messages." + ) + ) + + if received_counts_display: + received_fig = go.Figure( + data=[ + go.Bar( + x=list(received_counts_display.keys()), + y=list(received_counts_display.values()), + ) + ] + ) + received_fig.update_layout( + title_text="Received Message Counts by (Source Agent, Alias)", + xaxis_title="(Source Agent, Alias)", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=received_fig)) + else: + main_container_children.append( + html.P( + "No messages received or 'basic' logging did not capture received messages." + ) + ) + + elif isinstance(results_data, pd.DataFrame): + log_type_info = html.P("Log Level: detail (Timeline & Aggregates)") + if results_data.empty: + main_container_children.append(html.P("Detail log is empty.")) + else: + # Sent Messages Summary (from DataFrame) + sent_df = results_data[results_data["direction"] == "sent"] + if not sent_df.empty: + sent_counts_df = ( + sent_df.groupby("alias").size().reset_index(name="count") + ) + sent_fig_df = go.Figure( + data=[ + go.Bar(x=sent_counts_df["alias"], y=sent_counts_df["count"]) + ] + ) + sent_fig_df.update_layout( + title_text="Sent Message Counts by Alias (from Detail Log)", + xaxis_title="Alias", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=sent_fig_df)) + else: + main_container_children.append( + html.P("No 'sent' messages in detail log.") + ) + + # Received Messages Summary (from DataFrame) + received_df = results_data[results_data["direction"] == "received"] + if not received_df.empty: + # Create a combined key for grouping, e.g., "agent_id | alias" + # Ensure remote_agent_id is not None before concatenating + received_df_copy = ( + received_df.copy() + ) # Avoid SettingWithCopyWarning + received_df_copy["source_alias_key"] = ( + received_df_copy["remote_agent_id"].fillna("Unknown") + + " | " + + received_df_copy["alias"] + ) + received_counts_df = ( + received_df_copy.groupby("source_alias_key") + .size() + .reset_index(name="count") + ) + received_counts_df = received_counts_df.sort_values( + by="source_alias_key" + ) + + received_fig_df = go.Figure( + data=[ + go.Bar( + x=received_counts_df["source_alias_key"], + y=received_counts_df["count"], + ) + ] + ) + received_fig_df.update_layout( + title_text="Received Message Counts by (Source Agent | Alias) (from Detail Log)", + xaxis_title="(Source Agent | Alias)", + yaxis_title="Count", + height=300, + margin=dict(l=40, r=20, t=40, b=30), + ) + main_container_children.append(dcc.Graph(figure=received_fig_df)) + else: + main_container_children.append( + html.P("No 'received' messages in detail log.") + ) + + # Timelines (at the bottom) + timeline_children = [] + if not sent_df.empty: + sent_timeline_fig = go.Figure( + data=[ + go.Scatter( + x=sent_df["timestamp"], + y=sent_df["alias"], + mode="markers", + name="Sent", + ) + ] + ) + sent_timeline_fig.update_layout( + title_text="Sent Messages Timeline", + xaxis_title="Time", + yaxis_title="Alias", + height=350, + margin=dict(l=40, r=20, t=40, b=30), + ) + timeline_children.append( + dbc.Col(dcc.Graph(figure=sent_timeline_fig), md=6) + ) + + if not received_df.empty: + received_timeline_fig = go.Figure() + # Group by remote_agent_id, handling potential None values + for r_agent_id, group in received_df.fillna( + {"remote_agent_id": "Unknown"} + ).groupby("remote_agent_id"): + received_timeline_fig.add_trace( + go.Scatter( + x=group["timestamp"], + y=group["alias"], + mode="markers", + name=f"From {r_agent_id}", + hovertext=[ + f"From: {ra} Alias: {al}" + for ra, al in zip( + group["remote_agent_id"], group["alias"] + ) + ], + ) + ) + received_timeline_fig.update_layout( + title_text="Received Messages Timeline", + xaxis_title="Time", + yaxis_title="Alias", + height=350, + margin=dict(l=40, r=20, t=40, b=30), + ) + timeline_children.append( + dbc.Col(dcc.Graph(figure=received_timeline_fig), md=6) + ) + + if timeline_children: + main_container_children.append(html.Hr()) + main_container_children.append( + html.H5("Message Timelines (Detail Log)", className="mt-4 mb-3") + ) + main_container_children.append(dbc.Row(timeline_children)) + + else: + return html.Div( + f"Unknown results data type for {module_id} (Agent: {agent_id}): {type(results_data)}" + ) + + main_container_children.insert( + 1, log_type_info + ) # Insert log type info after H4 + + return html.Div(main_container_children, style={"padding": "10px"}) diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index a0019e3e..a549d991 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -3,13 +3,10 @@ """ import abc -import collections import json import logging -import os import queue import threading -from pathlib import Path from typing import Union, List, TypedDict, Any, Optional, Literal, Tuple import pandas as pd @@ -20,6 +17,9 @@ from agentlib.core.errors import OptionalDependencyError from agentlib.utils.broker import Broker from agentlib.utils.validators import convert_to_list +from agentlib.modules.communicator.communication_logging_handling import ( + CommunicationLogger, +) logger = logging.getLogger(__name__) # Added logger initialization @@ -82,17 +82,15 @@ class Communicator(BaseModule): def __init__(self, *, config: dict, agent: Agent): super().__init__(config=config, agent=agent) - # Initialize logging attributes based on the validated config - self._sent_alias_counts: Optional[collections.Counter] = None - self._received_source_alias_counts: Optional[collections.Counter] = None - self._communication_log_filename: Optional[str] = None - self._communication_log_batch: Optional[List[dict]] = None - - if self.config.communication_log_level == "basic": - self._sent_alias_counts = collections.Counter() - self._received_source_alias_counts = collections.Counter() - elif self.config.communication_log_level == "detail": - self._init_detail_logging() + # Initialize communication logger + self._communication_logger = None + if self.config.communication_log_level != "none": + self._communication_logger = CommunicationLogger( + config=self.config, + agent_id=self.agent.id, + module_id=self.id, + env=self.env, + ) if self.config.use_orjson: try: @@ -115,73 +113,6 @@ def _to_json_builtin(payload: CommunicationDict) -> str: self.to_json = _to_json_builtin - def _init_detail_logging(self): - """Helper to initialize attributes for 'detail' logging.""" - self._communication_log_batch = [] - if self.config.communication_log_file is None: - logs_dir = Path("communicator_logs") - logs_dir.mkdir(parents=True, exist_ok=True) - self._communication_log_filename = str( - logs_dir / f"{self.agent.id}_{self.id}.jsonl" - ) - else: - self._communication_log_filename = self.config.communication_log_file - # Ensure parent directory exists if a custom path is given - Path(self._communication_log_filename).parent.mkdir( - parents=True, exist_ok=True - ) - - if ( - self._communication_log_filename is not None # Ensure filename is not None - and Path(self._communication_log_filename).exists() - and self.config.communication_log_overwrite - ): - try: - os.remove(self._communication_log_filename) - self.logger.info( - f"Overwriting existing communication log: {self._communication_log_filename}" - ) - except OSError as e: - self.logger.error( - f"Could not remove communication log file {self._communication_log_filename} for overwrite: {e}" - ) - - # For non-realtime, schedule periodic flushing - if not self.env.config.rt and self.config.communication_log_t_sample > 0: - self.env.process(self._log_detail_process()) - elif self.config.communication_log_t_sample <= 0 and not self.env.config.rt: - self.logger.info( - "communication_log_t_sample <= 0, detail logs will only be written on terminate." - ) - - def _log_detail_process(self): - """SimPy process to periodically flush detail logs.""" - while True: - yield self.env.timeout(self.config.communication_log_t_sample) - self._flush_detail_log() - - def _flush_detail_log(self): - """Writes the current batch of detail logs to file.""" - if ( - not self._communication_log_batch - or self._communication_log_filename is None - ): - return - - try: - with open(self._communication_log_filename, "a", encoding="utf-8") as f: - for entry in self._communication_log_batch: - json.dump(entry, f) - f.write("\n") - self.logger.debug( - f"Flushed {len(self._communication_log_batch)} entries to {self._communication_log_filename}" - ) - self._communication_log_batch = [] # Clear batch - except IOError as e: - self.logger.error( - f"Error writing communication detail log to {self._communication_log_filename}: {e}" - ) - def register_callbacks(self): """Register all outputs to the callback function""" self.agent.data_broker.register_callback( @@ -199,24 +130,9 @@ def _send_only_shared_variables(self, variable: AgentVariable): payload = self.short_dict(variable) self.logger.debug("Sending variable %s=%s", variable.alias, variable.value) - # Logging for 'sent' messages - if ( - self.config.communication_log_level == "basic" - and self._sent_alias_counts is not None - ): - self._sent_alias_counts[payload["alias"]] += 1 - elif ( - self.config.communication_log_level == "detail" - and self._communication_log_batch is not None - ): - log_entry = { - "timestamp": self.env.time, - "direction": "sent", - "own_agent_id": self.agent.id, - "remote_agent_id": None, # Cannot be known for sent messages generally - "alias": payload["alias"], - } - self._communication_log_batch.append(log_entry) + # Delegate logging to communication logger + if self._communication_logger: + self._communication_logger.log_sent_message(payload["alias"]) self._send(payload=payload) @@ -255,32 +171,14 @@ def _handle_received_variable( ): """ Centralized handler for received variables that manages logging and forwarding. - - Args: - variable: The received AgentVariable - remote_agent_id: ID of the sending agent (if known and different from variable.source.agent_id) """ - # Use remote_agent_id if provided, otherwise fall back to variable's source source_agent_id = remote_agent_id or variable.source.agent_id - # Handle logging for received messages - if ( - self.config.communication_log_level == "basic" - and self._received_source_alias_counts is not None - ): - self._received_source_alias_counts[(source_agent_id, variable.alias)] += 1 - elif ( - self.config.communication_log_level == "detail" - and self._communication_log_batch is not None - ): - log_entry = { - "timestamp": self.env.time, - "direction": "received", - "own_agent_id": self.agent.id, - "remote_agent_id": source_agent_id, - "alias": variable.alias, - } - self._communication_log_batch.append(log_entry) + # Delegate logging to communication logger + if self._communication_logger: + self._communication_logger.log_received_message( + variable.alias, source_agent_id + ) # Forward to data broker self.agent.data_broker.send_variable(variable) @@ -303,135 +201,28 @@ def to_json(self, payload: CommunicationDict) -> Union[bytes, str]: pass def terminate(self): - if self.config.communication_log_level == "detail": - self.logger.debug( - f"Terminating communicator {self.id}, flushing any remaining detail logs." - ) - self._flush_detail_log() + if self._communication_logger: + self._communication_logger.terminate() super().terminate() def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: """Returns logged communication data based on the log level.""" - log_level = self.config.communication_log_level - if log_level == "none": - return None - elif log_level == "basic": - return { - "sent_counts": dict(self._sent_alias_counts or {}), - "received_counts": { - str(k): v - for k, v in (self._received_source_alias_counts or {}).items() - }, - } - elif log_level == "detail": - self._flush_detail_log() # Ensure all data is written before reading - if ( - self._communication_log_filename - and Path(self._communication_log_filename).exists() - ): - try: - log_entries = [] - with open( - self._communication_log_filename, "r", encoding="utf-8" - ) as f: - for line in f: - log_entries.append(json.loads(line)) - if not log_entries: # Handle empty log file - return pd.DataFrame() - df = pd.DataFrame(log_entries) - return df - except Exception as e: - self.logger.error( - f"Error loading communication detail log from {self._communication_log_filename}: {e}" - ) - return pd.DataFrame() # Return empty DataFrame on error - return pd.DataFrame() # Return empty DataFrame if file doesn't exist + if self._communication_logger: + return self._communication_logger.get_results() return None def get_results_incremental( self, update_token: Optional[Any] = None ) -> Tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: """Returns logged communication data incrementally.""" - log_level = self.config.communication_log_level - - if log_level == "none": - return None, None - elif log_level == "basic": - current_counts = { - "sent_counts": dict(self._sent_alias_counts or {}), - "received_counts": { - str(k): v - for k, v in (self._received_source_alias_counts or {}).items() - }, - } - if update_token is None or update_token != current_counts: - return current_counts, current_counts - return None, update_token - elif log_level == "detail": - self._flush_detail_log() - - if ( - not self._communication_log_filename - or not Path(self._communication_log_filename).exists() - ): - return None, 0 - - if update_token is None: # Initial call - df = self.get_results() - if df is None or df.empty: - return pd.DataFrame(), 0 - with open(self._communication_log_filename, "r", encoding="utf-8") as f: - lines_in_file = sum(1 for _ in f) - return df, lines_in_file - else: # Incremental call - df_chunk, lines_read = self._load_detail_log_incremental( - filename=self._communication_log_filename, - start_line_index=update_token, - ) - if df_chunk is None or df_chunk.empty: - return None, update_token - return df_chunk, update_token + lines_read - - @classmethod - def _load_detail_log_incremental( - cls, filename: str, start_line_index: int - ) -> Tuple[Optional[pd.DataFrame], int]: - """Loads detail log file from a specific line index.""" - log_entries = [] - lines_read_count = 0 - - try: - with open(filename, "r", encoding="utf-8") as f: - for i, line in enumerate(f): - if i < start_line_index: - continue - log_entries.append(json.loads(line)) - lines_read_count += 1 - except FileNotFoundError: - return None, 0 - - if not log_entries: - return None, 0 - - df = pd.DataFrame(log_entries) - return df, lines_read_count + if self._communication_logger: + return self._communication_logger.get_results_incremental(update_token) + return None, None def cleanup_results(self): """Deletes the communication log file if 'detail' logging was active.""" - if ( - self.config.communication_log_level == "detail" - and self._communication_log_filename - ): - try: - if Path(self._communication_log_filename).exists(): - os.remove(self._communication_log_filename) - self.logger.info( - f"Cleaned up communication log file: {self._communication_log_filename}" - ) - except OSError as e: - self.logger.error( - f"Error cleaning up communication log file {self._communication_log_filename}: {e}" - ) + if self._communication_logger: + self._communication_logger.cleanup_results() @classmethod def visualize_results( @@ -440,226 +231,7 @@ def visualize_results( module_id: str, agent_id: str, ) -> "Optional[html.Div]": - try: - from dash import dcc, html - import plotly.graph_objects as go - import dash_bootstrap_components as dbc - except ImportError: - raise OptionalDependencyError( - used_object="Dashboard", - dependency_install="dash", - dependency_name="interactive", - ) - if results_data is None: - return html.Div( - f"No communication data to visualize for {module_id} (Agent: {agent_id}). Log level might be 'none'." - ) - - main_container_children = [ - html.H4( - f"Communicator Activity: {module_id} (Agent: {agent_id})", - className="mb-3", - ) - ] - - if isinstance(results_data, dict): # Basic logging - log_type_info = html.P("Log Level: basic (Counters)") - sent_counts = results_data.get("sent_counts", {}) - received_counts_str_keys = results_data.get("received_counts", {}) - - received_counts_display = { - k: v for k, v in received_counts_str_keys.items() - } - - if sent_counts: - sent_fig = go.Figure( - data=[ - go.Bar(x=list(sent_counts.keys()), y=list(sent_counts.values())) - ] - ) - sent_fig.update_layout( - title_text="Sent Message Counts by Alias", - xaxis_title="Alias", - yaxis_title="Count", - height=300, - margin=dict(l=40, r=20, t=40, b=30), - ) - main_container_children.append(dcc.Graph(figure=sent_fig)) - else: - main_container_children.append( - html.P( - "No messages sent or 'basic' logging did not capture sent messages." - ) - ) - - if received_counts_display: - received_fig = go.Figure( - data=[ - go.Bar( - x=list(received_counts_display.keys()), - y=list(received_counts_display.values()), - ) - ] - ) - received_fig.update_layout( - title_text="Received Message Counts by (Source Agent, Alias)", - xaxis_title="(Source Agent, Alias)", - yaxis_title="Count", - height=300, - margin=dict(l=40, r=20, t=40, b=30), - ) - main_container_children.append(dcc.Graph(figure=received_fig)) - else: - main_container_children.append( - html.P( - "No messages received or 'basic' logging did not capture received messages." - ) - ) - - elif isinstance(results_data, pd.DataFrame): - log_type_info = html.P("Log Level: detail (Timeline & Aggregates)") - if results_data.empty: - main_container_children.append(html.P("Detail log is empty.")) - else: - # Sent Messages Summary (from DataFrame) - sent_df = results_data[results_data["direction"] == "sent"] - if not sent_df.empty: - sent_counts_df = ( - sent_df.groupby("alias").size().reset_index(name="count") - ) - sent_fig_df = go.Figure( - data=[ - go.Bar(x=sent_counts_df["alias"], y=sent_counts_df["count"]) - ] - ) - sent_fig_df.update_layout( - title_text="Sent Message Counts by Alias (from Detail Log)", - xaxis_title="Alias", - yaxis_title="Count", - height=300, - margin=dict(l=40, r=20, t=40, b=30), - ) - main_container_children.append(dcc.Graph(figure=sent_fig_df)) - else: - main_container_children.append( - html.P("No 'sent' messages in detail log.") - ) - - # Received Messages Summary (from DataFrame) - received_df = results_data[results_data["direction"] == "received"] - if not received_df.empty: - # Create a combined key for grouping, e.g., "agent_id | alias" - # Ensure remote_agent_id is not None before concatenating - received_df_copy = ( - received_df.copy() - ) # Avoid SettingWithCopyWarning - received_df_copy["source_alias_key"] = ( - received_df_copy["remote_agent_id"].fillna("Unknown") - + " | " - + received_df_copy["alias"] - ) - received_counts_df = ( - received_df_copy.groupby("source_alias_key") - .size() - .reset_index(name="count") - ) - received_counts_df = received_counts_df.sort_values( - by="source_alias_key" - ) - - received_fig_df = go.Figure( - data=[ - go.Bar( - x=received_counts_df["source_alias_key"], - y=received_counts_df["count"], - ) - ] - ) - received_fig_df.update_layout( - title_text="Received Message Counts by (Source Agent | Alias) (from Detail Log)", - xaxis_title="(Source Agent | Alias)", - yaxis_title="Count", - height=300, - margin=dict(l=40, r=20, t=40, b=30), - ) - main_container_children.append(dcc.Graph(figure=received_fig_df)) - else: - main_container_children.append( - html.P("No 'received' messages in detail log.") - ) - - # Timelines (at the bottom) - timeline_children = [] - if not sent_df.empty: - sent_timeline_fig = go.Figure( - data=[ - go.Scatter( - x=sent_df["timestamp"], - y=sent_df["alias"], - mode="markers", - name="Sent", - ) - ] - ) - sent_timeline_fig.update_layout( - title_text="Sent Messages Timeline", - xaxis_title="Time", - yaxis_title="Alias", - height=350, - margin=dict(l=40, r=20, t=40, b=30), - ) - timeline_children.append( - dbc.Col(dcc.Graph(figure=sent_timeline_fig), md=6) - ) - - if not received_df.empty: - received_timeline_fig = go.Figure() - # Group by remote_agent_id, handling potential None values - for r_agent_id, group in received_df.fillna( - {"remote_agent_id": "Unknown"} - ).groupby("remote_agent_id"): - received_timeline_fig.add_trace( - go.Scatter( - x=group["timestamp"], - y=group["alias"], - mode="markers", - name=f"From {r_agent_id}", - hovertext=[ - f"From: {ra} Alias: {al}" - for ra, al in zip( - group["remote_agent_id"], group["alias"] - ) - ], - ) - ) - received_timeline_fig.update_layout( - title_text="Received Messages Timeline", - xaxis_title="Time", - yaxis_title="Alias", - height=350, - margin=dict(l=40, r=20, t=40, b=30), - ) - timeline_children.append( - dbc.Col(dcc.Graph(figure=received_timeline_fig), md=6) - ) - - if timeline_children: - main_container_children.append(html.Hr()) - main_container_children.append( - html.H5("Message Timelines (Detail Log)", className="mt-4 mb-3") - ) - main_container_children.append(dbc.Row(timeline_children)) - - else: - return html.Div( - f"Unknown results data type for {module_id} (Agent: {agent_id}): {type(results_data)}" - ) - - main_container_children.insert( - 1, log_type_info - ) # Insert log type info after H4 - - return html.Div(main_container_children, style={"padding": "10px"}) + return CommunicationLogger.visualize_results(results_data, module_id, agent_id) class LocalCommunicatorConfig(CommunicatorConfig): diff --git a/agentlib/modules/simulation/simulator.py b/agentlib/modules/simulation/simulator.py index 87ee6d3f..19c9807c 100644 --- a/agentlib/modules/simulation/simulator.py +++ b/agentlib/modules/simulation/simulator.py @@ -340,7 +340,7 @@ def model(self, model: Model): t_start=self.config.t_start + self.env.config.offset, t_stop=self.config.t_stop, ) - self.logger.info("Model successfully loaded model: %s", self.model.name) + self.logger.debug("Model successfully loaded model: %s", self.model.name) def run(self, until=None): """ @@ -369,7 +369,7 @@ def _register_input_callbacks(self): ): for var in ag_vars: if var.name in model_var_names: - self.logger.info( + self.logger.debug( "Registered callback for model %s %s ", _type, var.name ) self.agent.data_broker.register_callback( diff --git a/examples/multi-agent-systems/room_mas/room_mas.py b/examples/multi-agent-systems/room_mas/room_mas.py index cfc8c62d..0c465258 100644 --- a/examples/multi-agent-systems/room_mas/room_mas.py +++ b/examples/multi-agent-systems/room_mas/room_mas.py @@ -77,7 +77,7 @@ def run_example( axes[2, 1].plot(df_se["T_oda"], color="green", linestyle="-.", label="SensorAgent") # Legend, titles etc: axes[0, 0].set_ylabel("$T_{Room}$ / K") - axes[1, 0].set_ylabel("$\dot{Q}_{Room\,,in}$ / W") + axes[1, 0].set_ylabel(r"$\dot{Q}_{Room\,,in}$ / W") axes[2, 0].set_ylabel("$T_{oda}$ / K") axes[2, 0].set_xlabel("Time / s") axes[2, 1].set_xlabel("Time / s") @@ -110,4 +110,4 @@ def run_example( if __name__ == "__main__": - run_example(until=86400 / 5, with_plots=True, with_dashboard=False) + run_example(until=86400 / 10, with_plots=True, with_dashboard=True) diff --git a/examples/simulation/csv_data_source.py b/examples/simulation/csv_data_source.py index 75375061..01b9aa01 100644 --- a/examples/simulation/csv_data_source.py +++ b/examples/simulation/csv_data_source.py @@ -10,7 +10,7 @@ def create_sample_data(): date_today = datetime.now() - time = pd.date_range(date_today, date_today + timedelta(minutes=10), freq="30S") + time = pd.date_range(date_today, date_today + timedelta(minutes=10), freq="30s") data1 = np.sin(np.linspace(0, 2 * np.pi, len(time))) * 10 + 20 data2 = np.cos(np.linspace(0, 2 * np.pi, len(time))) * 5 + 15 df = pd.DataFrame({"timestamp": time, "temperature": data1, "humidity": data2}) @@ -19,7 +19,9 @@ def create_sample_data(): return df -def run_example(log_level=logging.INFO, with_plots: bool = True, extrapolation: str = "constant"): +def run_example( + log_level=logging.INFO, with_plots: bool = True, extrapolation: str = "constant" +): # Set the log-level logging.basicConfig(level=log_level) From 53ed607e29c59a01aadd08f339021d9c06a3baef Mon Sep 17 00:00:00 2001 From: ses Date: Tue, 19 Aug 2025 10:19:29 +0200 Subject: [PATCH 20/26] restructure com logging to remove some if checks --- .../communication_logging_handling.py | 125 +++++++++++------- agentlib/modules/communicator/communicator.py | 36 ++--- 2 files changed, 89 insertions(+), 72 deletions(-) diff --git a/agentlib/modules/communicator/communication_logging_handling.py b/agentlib/modules/communicator/communication_logging_handling.py index f45957db..08580ab4 100644 --- a/agentlib/modules/communicator/communication_logging_handling.py +++ b/agentlib/modules/communicator/communication_logging_handling.py @@ -7,7 +7,7 @@ import logging import os from pathlib import Path -from typing import Union, List, Any, Optional, Tuple +from typing import Union, List, Any, Optional, Tuple, Callable import pandas as pd @@ -26,21 +26,40 @@ def __init__(self, config, agent_id: str, module_id: str, env): self.env = env self.logger = logging.getLogger(f"{__name__}.{agent_id}.{module_id}") - # Initialize logging attributes based on the validated config + # Initialize attributes that might be used self._sent_alias_counts: Optional[collections.Counter] = None self._received_source_alias_counts: Optional[collections.Counter] = None self._communication_log_filename: Optional[str] = None self._communication_log_batch: Optional[List[dict]] = None - if self.config.communication_log_level == "basic": - self._sent_alias_counts = collections.Counter() - self._received_source_alias_counts = collections.Counter() - elif self.config.communication_log_level == "detail": + # Set up logging methods based on level + self._setup_logging_strategy() + + def _setup_logging_strategy(self): + """Set up the appropriate logging methods based on config level""" + log_level = self.config.communication_log_level + + if log_level == "none": + self.log_sent_message = self._log_sent_none + self.log_received_message = self._log_received_none + elif log_level == "basic": + self._init_basic_logging() + self.log_sent_message = self._log_sent_basic + self.log_received_message = self._log_received_basic + elif log_level == "detail": self._init_detail_logging() + self.log_sent_message = self._log_sent_detail + self.log_received_message = self._log_received_detail + + def _init_basic_logging(self): + """Initialize basic logging with counters""" + self._sent_alias_counts = collections.Counter() + self._received_source_alias_counts = collections.Counter() def _init_detail_logging(self): - """Helper to initialize attributes for 'detail' logging.""" + """Initialize detail logging with file handling""" self._communication_log_batch = [] + if self.config.communication_log_file is None: logs_dir = Path("communicator_logs") logs_dir.mkdir(parents=True, exist_ok=True) @@ -55,7 +74,7 @@ def _init_detail_logging(self): ) if ( - self._communication_log_filename is not None # Ensure filename is not None + self._communication_log_filename is not None and Path(self._communication_log_filename).exists() and self.config.communication_log_overwrite ): @@ -72,6 +91,56 @@ def _init_detail_logging(self): "communication_log_t_sample <= 0, detail logs will only be written on terminate." ) + # No-op logging methods for "none" level + def _log_sent_none(self, alias: str): + """No-op logging for sent messages""" + pass + + def _log_received_none(self, alias: str, source_agent_id: Optional[str]): + """No-op logging for received messages""" + pass + + # Basic logging methods + def _log_sent_basic(self, alias: str): + """Log sent message for basic level""" + self._sent_alias_counts[alias] += 1 + + def _log_received_basic(self, alias: str, source_agent_id: Optional[str]): + """Log received message for basic level""" + self._received_source_alias_counts[(source_agent_id, alias)] += 1 + + # Detail logging methods + def _log_sent_detail(self, alias: str): + """Log sent message for detail level""" + log_entry = { + "timestamp": self.env.time, + "direction": "sent", + "own_agent_id": self.agent_id, + "remote_agent_id": None, # Cannot be known for sent messages generally + "alias": alias, + } + self._communication_log_batch.append(log_entry) + + def _log_received_detail(self, alias: str, source_agent_id: Optional[str]): + """Log received message for detail level""" + log_entry = { + "timestamp": self.env.time, + "direction": "received", + "own_agent_id": self.agent_id, + "remote_agent_id": source_agent_id, + "alias": alias, + } + self._communication_log_batch.append(log_entry) + + # These methods will be assigned during init + def log_sent_message(self, alias: str): + """Log a sent message - implementation assigned during init""" + pass + + def log_received_message(self, alias: str, source_agent_id: Optional[str]): + """Log a received message - implementation assigned during init""" + pass + def _log_detail_process(self): """SimPy process to periodically flush detail logs.""" while True: @@ -100,46 +169,6 @@ def _flush_detail_log(self): f"Error writing communication detail log to {self._communication_log_filename}: {e}" ) - def log_sent_message(self, alias: str): - """Log a sent message""" - if ( - self.config.communication_log_level == "basic" - and self._sent_alias_counts is not None - ): - self._sent_alias_counts[alias] += 1 - elif ( - self.config.communication_log_level == "detail" - and self._communication_log_batch is not None - ): - log_entry = { - "timestamp": self.env.time, - "direction": "sent", - "own_agent_id": self.agent_id, - "remote_agent_id": None, # Cannot be known for sent messages generally - "alias": alias, - } - self._communication_log_batch.append(log_entry) - - def log_received_message(self, alias: str, source_agent_id: Optional[str]): - """Log a received message""" - if ( - self.config.communication_log_level == "basic" - and self._received_source_alias_counts is not None - ): - self._received_source_alias_counts[(source_agent_id, alias)] += 1 - elif ( - self.config.communication_log_level == "detail" - and self._communication_log_batch is not None - ): - log_entry = { - "timestamp": self.env.time, - "direction": "received", - "own_agent_id": self.agent_id, - "remote_agent_id": source_agent_id, - "alias": alias, - } - self._communication_log_batch.append(log_entry) - def terminate(self): """Handle termination cleanup""" if self.config.communication_log_level == "detail": diff --git a/agentlib/modules/communicator/communicator.py b/agentlib/modules/communicator/communicator.py index a549d991..98ca98fe 100644 --- a/agentlib/modules/communicator/communicator.py +++ b/agentlib/modules/communicator/communicator.py @@ -83,14 +83,12 @@ def __init__(self, *, config: dict, agent: Agent): super().__init__(config=config, agent=agent) # Initialize communication logger - self._communication_logger = None - if self.config.communication_log_level != "none": - self._communication_logger = CommunicationLogger( - config=self.config, - agent_id=self.agent.id, - module_id=self.id, - env=self.env, - ) + self._communication_logger = CommunicationLogger( + config=self.config, + agent_id=self.agent.id, + module_id=self.id, + env=self.env, + ) if self.config.use_orjson: try: @@ -131,8 +129,7 @@ def _send_only_shared_variables(self, variable: AgentVariable): self.logger.debug("Sending variable %s=%s", variable.alias, variable.value) # Delegate logging to communication logger - if self._communication_logger: - self._communication_logger.log_sent_message(payload["alias"]) + self._communication_logger.log_sent_message(payload["alias"]) self._send(payload=payload) @@ -175,10 +172,7 @@ def _handle_received_variable( source_agent_id = remote_agent_id or variable.source.agent_id # Delegate logging to communication logger - if self._communication_logger: - self._communication_logger.log_received_message( - variable.alias, source_agent_id - ) + self._communication_logger.log_received_message(variable.alias, source_agent_id) # Forward to data broker self.agent.data_broker.send_variable(variable) @@ -201,28 +195,22 @@ def to_json(self, payload: CommunicationDict) -> Union[bytes, str]: pass def terminate(self): - if self._communication_logger: - self._communication_logger.terminate() + self._communication_logger.terminate() super().terminate() def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: """Returns logged communication data based on the log level.""" - if self._communication_logger: - return self._communication_logger.get_results() - return None + return self._communication_logger.get_results() def get_results_incremental( self, update_token: Optional[Any] = None ) -> Tuple[Optional[Union[dict, pd.DataFrame]], Optional[Any]]: """Returns logged communication data incrementally.""" - if self._communication_logger: - return self._communication_logger.get_results_incremental(update_token) - return None, None + return self._communication_logger.get_results_incremental(update_token) def cleanup_results(self): """Deletes the communication log file if 'detail' logging was active.""" - if self._communication_logger: - self._communication_logger.cleanup_results() + self._communication_logger.cleanup_results() @classmethod def visualize_results( From 3161ebc4f148b2d5e406f7f5f6b9b7053212db96 Mon Sep 17 00:00:00 2001 From: ses Date: Thu, 21 Aug 2025 09:32:50 +0200 Subject: [PATCH 21/26] update mqtt to new version --- agentlib/modules/communicator/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentlib/modules/communicator/mqtt.py b/agentlib/modules/communicator/mqtt.py index d9f98f8f..a8db7ace 100644 --- a/agentlib/modules/communicator/mqtt.py +++ b/agentlib/modules/communicator/mqtt.py @@ -188,7 +188,7 @@ def disconnect(self, reasoncode=None, properties=None): """Trigger the disconnect""" self._mqttc.disconnect(reasoncode=reasoncode, properties=properties) - def _disconnect_callback(self, client, userdata, reasonCode, properties): + def _disconnect_callback(self, client, userdata, disconnect_flag, reasonCode, properties): """Stop the loop as a result of the disconnect""" self.logger.warning( "Disconnected with result code: %s | userdata: %s | properties: %s", From 0355ceca56a12fef6948f30b3a85360dc465d9b9 Mon Sep 17 00:00:00 2001 From: ses Date: Thu, 21 Aug 2025 09:35:19 +0200 Subject: [PATCH 22/26] update mqtt to new version --- agentlib/modules/communicator/mqtt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agentlib/modules/communicator/mqtt.py b/agentlib/modules/communicator/mqtt.py index a8db7ace..cbac5922 100644 --- a/agentlib/modules/communicator/mqtt.py +++ b/agentlib/modules/communicator/mqtt.py @@ -188,7 +188,9 @@ def disconnect(self, reasoncode=None, properties=None): """Trigger the disconnect""" self._mqttc.disconnect(reasoncode=reasoncode, properties=properties) - def _disconnect_callback(self, client, userdata, disconnect_flag, reasonCode, properties): + def _disconnect_callback( + self, client, userdata, disconnect_flag, reasonCode, properties + ): """Stop the loop as a result of the disconnect""" self.logger.warning( "Disconnected with result code: %s | userdata: %s | properties: %s", From 8c69569eba89d9b49bc55ecb764003cf6722c90b Mon Sep 17 00:00:00 2001 From: ses Date: Wed, 27 Aug 2025 13:38:36 +0200 Subject: [PATCH 23/26] update agentlogger filename behaviour. MAS does not provide filename anymore, instead it relies on agentloggers default. AgentLogger auto-increments existing file names, but only for default --- agentlib/modules/utils/agent_logger.py | 18 ++++++++++++++++++ agentlib/utils/multi_agent_system.py | 2 -- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/agentlib/modules/utils/agent_logger.py b/agentlib/modules/utils/agent_logger.py index 902ed0aa..4a22d957 100644 --- a/agentlib/modules/utils/agent_logger.py +++ b/agentlib/modules/utils/agent_logger.py @@ -104,7 +104,12 @@ def _handle_file_existence(self, file_path: Path) -> str: if self.config.overwrite_log: file_path.unlink() self.logger.info(f"Overwrote existing log file: {file_path}") + elif self.config.filename is None: + # Auto-increment for default filenames to support quick iteration + file_path = self._get_incremented_filename(file_path) + self.logger.info(f"Using auto-incremented filename: {file_path}") else: + # Custom filename with overwrite disabled - maintain strict behavior raise FileExistsError( f"Log file '{file_path}' already exists. " f"Set 'overwrite_log=True' to enable automatic overwrite." @@ -112,6 +117,19 @@ def _handle_file_existence(self, file_path: Path) -> str: return str(file_path) + def _get_incremented_filename(self, base_path: Path) -> Path: + """Generate an auto-incremented filename""" + counter = 1 + stem = base_path.stem + suffix = base_path.suffix + parent = base_path.parent + + while True: + new_path = parent / f"{stem}_{counter}{suffix}" + if not new_path.exists(): + return new_path + counter += 1 + @property def filename(self): """Return the filename where to log.""" diff --git a/agentlib/utils/multi_agent_system.py b/agentlib/utils/multi_agent_system.py index 06f81f82..ec405c21 100644 --- a/agentlib/utils/multi_agent_system.py +++ b/agentlib/utils/multi_agent_system.py @@ -91,13 +91,11 @@ def add_agent_logger(config: AgentConfig, sampling=60) -> AgentConfig: sampling= """ # Add Logger config - filename = f"variable_logs//Agent_{config.id}_Logger.log" cfg = { "module_id": "AgentLogger", "type": "AgentLogger", "t_sample": sampling, "values_only": True, - "filename": filename, "overwrite_log": True, "clean_up": False, } From fa8512e86e9756172f5cae2366b898af856945ea Mon Sep 17 00:00:00 2001 From: ses Date: Thu, 30 Oct 2025 19:55:42 +0100 Subject: [PATCH 24/26] delete some AI slop, put multiprocessing comm in same file --- .../communicator/local_multiprocessing.py | 117 ++++++++ .../local_multiprocessing_subscription.py | 113 -------- agentlib/utils/plotting/mas_dashboard.py | 249 +++++------------- tests/test_examples.py | 2 +- 4 files changed, 186 insertions(+), 295 deletions(-) delete mode 100644 agentlib/modules/communicator/local_multiprocessing_subscription.py diff --git a/agentlib/modules/communicator/local_multiprocessing.py b/agentlib/modules/communicator/local_multiprocessing.py index 81aa5731..532fc95c 100644 --- a/agentlib/modules/communicator/local_multiprocessing.py +++ b/agentlib/modules/communicator/local_multiprocessing.py @@ -15,6 +15,123 @@ from agentlib.utils import multi_processing_broker +import multiprocessing +import threading +import time +from ipaddress import IPv4Address + +from pydantic import Field + +from agentlib.core import Agent +from agentlib.core.datamodels import AgentVariable +from agentlib.modules.communicator.communicator import ( + Communicator, + CommunicationDict, + SubscriptionCommunicatorConfig, +) +from agentlib.utils import multi_processing_broker + + +class MultiProcessingSubscriptionClientConfig(SubscriptionCommunicatorConfig): + """Config for the Multiprocessing Subscription Communicator""" + + ipv4: IPv4Address = Field( + default="127.0.0.1", + description="IP Address for the communication server. Defaults to localhost.", + ) + port: int = Field( + default=50_001, + description="Port for setting up the connection with the broker.", + ) + authkey: bytes = Field( + default=b"useTheAgentlib", + description="Authorization key for the connection with the broker.", + ) + + +class MultiProcessingSubscriptionClient(Communicator): + """ + This communicator implements subscription-based communication between agents + via a central process broker. + """ + + config: MultiProcessingSubscriptionClientConfig + + def __init__(self, config: dict, agent: Agent): + super().__init__(config=config, agent=agent) + manager = multi_processing_broker.BrokerManager( + address=(self.config.ipv4, self.config.port), authkey=self.config.authkey + ) + started_wait = time.time() + + while True: + try: + manager.connect() + break + except ConnectionRefusedError: + time.sleep(0.01) + if time.time() - started_wait > 10: + raise RuntimeError("Could not connect to the server.") + + signup_queue = manager.get_queue() + client_read, broker_write = multiprocessing.Pipe(duplex=False) + broker_read, client_write = multiprocessing.Pipe(duplex=False) + + # Create subscription client with subscription information + signup = multi_processing_broker.MPSubscriptionClient( + agent_id=self.agent.id, + read=broker_read, + write=broker_write, + subscriptions=tuple(self.config.subscriptions), + ) + + signup_queue.put(signup) + + self._client_write = client_write + self._broker_write = broker_write + self._client_read = client_read + self._broker_read = broker_read + + # ensure the broker has set up the connection and sends its ack + self._client_read.recv() + + def process(self): + """Only start the loop once the env is running.""" + _thread = threading.Thread( + target=self._message_handler, name=str(self.source), daemon=True + ) + _thread.start() + self.agent.register_thread(thread=_thread) + yield self.env.event() + + def _message_handler(self): + """Reads messages that were put in the message queue.""" + while True: + try: + msg = self._client_read.recv() + except EOFError: + break + variable = AgentVariable.from_json(msg) + self.logger.debug(f"Received variable {variable.alias}.") + self._handle_received_variable(variable) + + def terminate(self): + """Closes all of the connections.""" + self._client_write.close() + self._client_read.close() + self._broker_write.close() + self._broker_read.close() + super().terminate() + + def _send(self, payload: CommunicationDict): + """Sends a variable to the Broker.""" + agent_id = payload["source"] + msg = multi_processing_broker.Message( + agent_id=agent_id, payload=self.to_json(payload) + ) + self._client_write.send(msg) + + class MultiProcessingBroadcastClientConfig(CommunicatorConfig): ipv4: IPv4Address = Field( default="127.0.0.1", diff --git a/agentlib/modules/communicator/local_multiprocessing_subscription.py b/agentlib/modules/communicator/local_multiprocessing_subscription.py deleted file mode 100644 index 225d5724..00000000 --- a/agentlib/modules/communicator/local_multiprocessing_subscription.py +++ /dev/null @@ -1,113 +0,0 @@ -import multiprocessing -import threading -import time -from ipaddress import IPv4Address - -from pydantic import Field - -from agentlib.core import Agent -from agentlib.core.datamodels import AgentVariable -from agentlib.modules.communicator.communicator import ( - Communicator, - CommunicationDict, - SubscriptionCommunicatorConfig, -) -from agentlib.utils import multi_processing_broker - - -class MultiProcessingSubscriptionClientConfig(SubscriptionCommunicatorConfig): - ipv4: IPv4Address = Field( - default="127.0.0.1", - description="IP Address for the communication server. Defaults to localhost.", - ) - port: int = Field( - default=50_001, - description="Port for setting up the connection with the broker.", - ) - authkey: bytes = Field( - default=b"useTheAgentlib", - description="Authorization key for the connection with the broker.", - ) - - -class MultiProcessingSubscriptionClient(Communicator): - """ - This communicator implements subscription-based communication between agents - via a central process broker. - """ - - config: MultiProcessingSubscriptionClientConfig - - def __init__(self, config: dict, agent: Agent): - super().__init__(config=config, agent=agent) - manager = multi_processing_broker.BrokerManager( - address=(self.config.ipv4, self.config.port), authkey=self.config.authkey - ) - started_wait = time.time() - - while True: - try: - manager.connect() - break - except ConnectionRefusedError: - time.sleep(0.01) - if time.time() - started_wait > 10: - raise RuntimeError("Could not connect to the server.") - - signup_queue = manager.get_queue() - client_read, broker_write = multiprocessing.Pipe(duplex=False) - broker_read, client_write = multiprocessing.Pipe(duplex=False) - - # Create subscription client with subscription information - signup = multi_processing_broker.MPSubscriptionClient( - agent_id=self.agent.id, - read=broker_read, - write=broker_write, - subscriptions=tuple(self.config.subscriptions), - ) - - signup_queue.put(signup) - - self._client_write = client_write - self._broker_write = broker_write - self._client_read = client_read - self._broker_read = broker_read - - # ensure the broker has set up the connection and sends its ack - self._client_read.recv() - - def process(self): - """Only start the loop once the env is running.""" - _thread = threading.Thread( - target=self._message_handler, name=str(self.source), daemon=True - ) - _thread.start() - self.agent.register_thread(thread=_thread) - yield self.env.event() - - def _message_handler(self): - """Reads messages that were put in the message queue.""" - while True: - try: - msg = self._client_read.recv() - except EOFError: - break - variable = AgentVariable.from_json(msg) - self.logger.debug(f"Received variable {variable.alias}.") - self._handle_received_variable(variable) - - def terminate(self): - """Closes all of the connections.""" - self._client_write.close() - self._client_read.close() - self._broker_write.close() - self._broker_read.close() - super().terminate() - - def _send(self, payload: CommunicationDict): - """Sends a variable to the Broker.""" - agent_id = payload["source"] - msg = multi_processing_broker.Message( - agent_id=agent_id, payload=self.to_json(payload) - ) - self._client_write.send(msg) diff --git a/agentlib/utils/plotting/mas_dashboard.py b/agentlib/utils/plotting/mas_dashboard.py index 92326e1f..f87da798 100644 --- a/agentlib/utils/plotting/mas_dashboard.py +++ b/agentlib/utils/plotting/mas_dashboard.py @@ -12,8 +12,10 @@ import queue # For queue.Empty import socket import threading +import time import webbrowser from typing import Dict, Optional, Tuple +from agentlib.core.errors import OptionalDependencyError try: import dash @@ -21,12 +23,12 @@ from dash.dependencies import Input, Output, State import dash_bootstrap_components as dbc except ImportError: - raise ImportError( - "Dash is not installed. Please install it to use the MAS dashboard: " - "pip install agentlib[interactive]" + raise OptionalDependencyError( + used_object=f"MAS Dashboard", + dependency_install="agentlib[interactive]", + dependency_name="interactive", ) -from agentlib.core.errors import OptionalDependencyError from agentlib.utils.multi_agent_system import LocalMASAgency logger = logging.getLogger(__name__) @@ -55,44 +57,20 @@ def _ipc_listener_loop( f"IPC listener: Request for {agent_id}/{module_id}, token: {update_token}" ) data_chunk, next_token = None, None - try: - agent = mas_instance.get_agent(agent_id) - if agent: - module = agent.get_module(module_id) - if module: - if hasattr(module, "get_results_incremental"): - data_chunk, next_token = module.get_results_incremental( - update_token=update_token - ) - else: - logger.warning( - f"Module {module_id} in agent {agent_id} does not have " - f"'get_results_incremental' method. Falling back to 'get_results'." - ) - if ( - update_token is None - ): # Only call get_results for initial load - data_chunk = module.get_results() - # next_token remains None, implying no further incremental updates from this fallback - else: - logger.error( - f"IPC listener: Module {module_id} not found in agent {agent_id}" - ) - else: - logger.error(f"IPC listener: Agent {agent_id} not found in MAS") - except Exception as e: - logger.error( - f"IPC listener: Error calling get_results_incremental for {agent_id}/{module_id}: {e}", - exc_info=True, + agent = mas_instance.get_agent(agent_id) + module = agent.get_module(module_id) + if hasattr(module, "get_results_incremental"): + data_chunk, next_token = module.get_results_incremental( + update_token=update_token ) + else: + if update_token is None: # Only call get_results for initial load + data_chunk = module.get_results() + # next_token remains None, implying no further incremental updates from this fallback response_q.put((data_chunk, next_token)) except queue.Empty: continue # Timeout, check stop_event again - except Exception as e: - logger.error(f"IPC listener: Unexpected error in loop: {e}", exc_info=True) - import time - - time.sleep(0.1) # Avoid busy-looping + time.sleep(0.1) # Avoid busy-looping logger.info("IPC listener thread stopped for MAS dashboard.") @@ -245,61 +223,44 @@ def fetch_live_data( logger.debug( f"Dash app: Requesting data for {module_key} with token: {update_token_to_send}" ) - try: - request_q.put( - (selected_agent_id, selected_module_id, update_token_to_send) - ) - data_chunk, next_token = response_q.get( - timeout=(update_interval_sec * 0.9) if update_interval_sec else 4.5 - ) - logger.debug( - f"Dash app: Received data for {module_key}. Next token: {next_token}" - ) + request_q.put((selected_agent_id, selected_module_id, update_token_to_send)) + data_chunk, next_token = response_q.get( + timeout=(update_interval_sec * 0.9) if update_interval_sec else 4.5 + ) + logger.debug( + f"Dash app: Received data for {module_key}. Next token: {next_token}" + ) - # Accumulate data in memory - if data_chunk is not None: - if ( - app_data_cache.get(module_key) is None - or update_token_to_send is None - ): - # First load or fresh start - app_data_cache[module_key] = data_chunk + # Accumulate data in memory + if data_chunk is not None: + if ( + app_data_cache.get(module_key) is None + or update_token_to_send is None + ): + # First load or fresh start + app_data_cache[module_key] = data_chunk + else: + # Append new data to existing dataframe + existing_df = app_data_cache[module_key] + if hasattr(data_chunk, "index") and hasattr(existing_df, "index"): + # Both are DataFrames - concatenate + import pandas as pd + + app_data_cache[module_key] = pd.concat( + [existing_df, data_chunk] + ) else: - # Append new data to existing dataframe - existing_df = app_data_cache[module_key] - if hasattr(data_chunk, "index") and hasattr( - existing_df, "index" - ): - # Both are DataFrames - concatenate - import pandas as pd - - app_data_cache[module_key] = pd.concat( - [existing_df, data_chunk] - ) - else: - # Handle other data types as needed - app_data_cache[module_key] = data_chunk - - new_token_cache = current_token_cache.copy() - new_token_cache[module_key] = next_token - - # Increment trigger to signal data update for visualization callback - new_trigger_val = ( - (current_trigger_val + 1) if current_trigger_val is not None else 0 - ) - return new_token_cache, new_trigger_val + # Handle other data types as needed + app_data_cache[module_key] = data_chunk - except queue.Empty: - logger.warning( - f"Dash app: Timeout waiting for data from IPC for {module_key}" - ) - return current_token_cache, dash.no_update - except Exception as e: - logger.error( - f"Dash app: Error in fetch_live_data for {module_key}: {e}", - exc_info=True, - ) - return current_token_cache, dash.no_update + new_token_cache = current_token_cache.copy() + new_token_cache[module_key] = next_token + + # Increment trigger to signal data update for visualization callback + new_trigger_val = ( + (current_trigger_val + 1) if current_trigger_val is not None else 0 + ) + return new_token_cache, new_trigger_val # Callback for displaying module visualization @app.callback( @@ -340,69 +301,31 @@ def display_module_visualization( return html.P( f"No live data currently available for module '{selected_module_id}'." ) - else: # Static mode - if not static_results: - return html.P("Static results not available.") + else: current_results_data = static_results.get(selected_agent_id, {}).get( selected_module_id, "__no_key__" ) - if ( - isinstance(current_results_data, str) - and current_results_data == "__no_key__" - ): - return html.P( - f"No results data key found for module '{selected_module_id}' in agent '{selected_agent_id}' (static mode)." - ) agent_modules_meta = agent_module_info.get(selected_agent_id, []) module_info_dict = next( (m for m in agent_modules_meta if m["id"] == selected_module_id), None ) - - if not module_info_dict: - return html.P( - f"Module metadata for '{selected_module_id}' not found in agent '{selected_agent_id}'." - ) - - try: - class_path = module_info_dict["class_path"] - module_path_str, class_name_str = class_path.rsplit(".", 1) - imported_module_obj = importlib.import_module(module_path_str) - ModuleType = getattr(imported_module_obj, class_name_str) - - visualization_layout = ModuleType.visualize_results( - results_data=current_results_data, - module_id=selected_module_id, - agent_id=selected_agent_id, - ) - if visualization_layout is None: - return html.P( - f"Visualization not implemented or not available for module " - f"'{selected_module_id}' (type: {ModuleType.__name__}) in agent '{selected_agent_id}'." - ) - return visualization_layout - except OptionalDependencyError as e: - logging.getLogger(__name__).error( - f"Optional dependency error for {class_path}: {e}" - ) - return html.Div( - [ - html.H4("Visualization Error"), - html.P(f"Could not render visualization for {class_path}."), - html.P(str(e)), - html.P("Please ensure all necessary libraries are installed."), - ] - ) - except Exception as e: - logging.getLogger(__name__).error( - f"Error generating visualization for module '{selected_module_id}' " - f"(path: {class_path}) in agent '{selected_agent_id}': {e}", - exc_info=True, - ) + class_path = module_info_dict["class_path"] + module_path_str, class_name_str = class_path.rsplit(".", 1) + imported_module_obj = importlib.import_module(module_path_str) + ModuleType = getattr(imported_module_obj, class_name_str) + + visualization_layout = ModuleType.visualize_results( + results_data=current_results_data, + module_id=selected_module_id, + agent_id=selected_agent_id, + ) + if visualization_layout is None: return html.P( - f"An error occurred while generating the visualization for " - f"module '{selected_module_id}': {str(e)}" + f"Visualization not implemented or not available for module " + f"'{selected_module_id}' (type: {ModuleType.__name__}) in agent '{selected_agent_id}'." ) + return visualization_layout return app @@ -430,11 +353,7 @@ def _run_dash_server( update_interval_sec=update_interval_sec_arg, ) logger.info(f"Dash app server starting on http://{host}:{port}") - try: - # Set use_reloader=False to prevent issues with multiprocessing and threads - app.run(debug=False, port=port, host=host, use_reloader=False) - except Exception as e: - logger.error(f"Error running Dash server: {e}", exc_info=True) + app.run(debug=False, port=port, host=host, use_reloader=False) def get_free_port(): @@ -477,12 +396,8 @@ def launch_mas_dashboard( multiprocessing.Process: The process running the Dash dashboard (if not block_main). None otherwise or if an error occurs. """ - if mas is None: - logger.error("LocalMASAgency instance ('mas') must be provided.") - return None if not live_update and mas_results is None: - logger.error("mas_results must be provided if not in live_update mode.") - return None + raise ValueError("mas_results must be provided if not in live_update mode.") if live_update: block_main = False @@ -577,31 +492,3 @@ def launch_mas_dashboard( if ipc_listener_thread_obj.is_alive(): logger.warning("IPC listener thread did not stop in time.") return None - - -if __name__ == "__main__": - # This example won't run directly as it needs a LocalMASAgency instance and results. - # It's intended to be called from another script. - print( - "To run this dashboard, import and call launch_mas_dashboard " - "from your script with a LocalMASAgency instance and its results. \n" - "Example: \n" - "import multiprocessing \n" - "if __name__ == '__main__': \n" - " multiprocessing.freeze_support() # For Windows executable freezing \n" - " # ... your MAS setup ... \n" - " # For static dashboard: \n" - " # results = my_mas.get_results() \n" - " # dashboard_proc = launch_mas_dashboard(my_mas, mas_results=results) \n" - " # For live dashboard: \n" - " # dashboard_proc = launch_mas_dashboard(my_mas, live_update=True) \n" - " # ... your main script continues ... \n" - " print('Dashboard process started. Press Ctrl+C in the console running the script, or implement other logic to stop it.') \n" - " try: \n" - " if dashboard_proc: dashboard_proc.join() \n" - " except KeyboardInterrupt: \n" - " print('Terminating dashboard process...') \n" - " if dashboard_proc: dashboard_proc.terminate(); dashboard_proc.join() \n" - " print('Dashboard process finished.')" - ) - pass diff --git a/tests/test_examples.py b/tests/test_examples.py index 55d8ffee..879d2335 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -86,7 +86,7 @@ def test_room_mas_direct_callback(self): until=8640, with_plots=False, log_level=logging.DEBUG, - use_direct_callback_databroker=True + use_direct_callback_databroker=True, ) def test_pingpong(self): From f797a7215570162eb1b96d282be1b6f3604ce9ad Mon Sep 17 00:00:00 2001 From: ses Date: Thu, 30 Oct 2025 20:02:22 +0100 Subject: [PATCH 25/26] update multiprocessing to its in one file and uses a mixin to avoid config and code duplication --- agentlib/modules/communicator/__init__.py | 2 +- .../communicator/local_multiprocessing.py | 168 ++++++------------ .../pingpong_multiprocessing_subscription.py | 2 + tests/test_examples.py | 2 +- 4 files changed, 57 insertions(+), 117 deletions(-) diff --git a/agentlib/modules/communicator/__init__.py b/agentlib/modules/communicator/__init__.py index 8c30bdf8..4c848acb 100644 --- a/agentlib/modules/communicator/__init__.py +++ b/agentlib/modules/communicator/__init__.py @@ -25,7 +25,7 @@ class_name="MultiProcessingBroadcastClient", ), "multiprocessing_subscription": ModuleImport( - import_path="agentlib.modules.communicator.local_multiprocessing_subscription", + import_path="agentlib.modules.communicator.local_multiprocessing", class_name="MultiProcessingSubscriptionClient", ), } diff --git a/agentlib/modules/communicator/local_multiprocessing.py b/agentlib/modules/communicator/local_multiprocessing.py index 532fc95c..daaa9f8f 100644 --- a/agentlib/modules/communicator/local_multiprocessing.py +++ b/agentlib/modules/communicator/local_multiprocessing.py @@ -1,9 +1,10 @@ import multiprocessing import threading import time +from abc import abstractmethod from ipaddress import IPv4Address -from pydantic import Field +from pydantic import Field, BaseModel from agentlib.core import Agent from agentlib.core.datamodels import AgentVariable @@ -11,36 +12,20 @@ Communicator, CommunicationDict, CommunicatorConfig, -) -from agentlib.utils import multi_processing_broker - - -import multiprocessing -import threading -import time -from ipaddress import IPv4Address - -from pydantic import Field - -from agentlib.core import Agent -from agentlib.core.datamodels import AgentVariable -from agentlib.modules.communicator.communicator import ( - Communicator, - CommunicationDict, SubscriptionCommunicatorConfig, ) from agentlib.utils import multi_processing_broker -class MultiProcessingSubscriptionClientConfig(SubscriptionCommunicatorConfig): - """Config for the Multiprocessing Subscription Communicator""" +class MultiProcessingConfigMixin(BaseModel): + """Mixin containing common multiprocessing configuration fields""" ipv4: IPv4Address = Field( default="127.0.0.1", description="IP Address for the communication server. Defaults to localhost.", ) port: int = Field( - default=50_001, + default=50_000, description="Port for setting up the connection with the broker.", ) authkey: bytes = Field( @@ -49,13 +34,29 @@ class MultiProcessingSubscriptionClientConfig(SubscriptionCommunicatorConfig): ) -class MultiProcessingSubscriptionClient(Communicator): +class MultiProcessingBroadcastClientConfig( + CommunicatorConfig, MultiProcessingConfigMixin +): + """Config for the Multiprocessing Broadcast Communicator""" + + pass + + +class MultiProcessingSubscriptionClientConfig( + SubscriptionCommunicatorConfig, MultiProcessingConfigMixin +): + """Config for the Multiprocessing Subscription Communicator""" + + pass + + +class MultiProcessingCommunicatorBase(Communicator): """ - This communicator implements subscription-based communication between agents - via a central process broker. + Base class for multiprocessing communicators. + Implements the common communication logic between agents via a central process broker. """ - config: MultiProcessingSubscriptionClientConfig + config: MultiProcessingConfigMixin def __init__(self, config: dict, agent: Agent): super().__init__(config=config, agent=agent) @@ -77,14 +78,8 @@ def __init__(self, config: dict, agent: Agent): client_read, broker_write = multiprocessing.Pipe(duplex=False) broker_read, client_write = multiprocessing.Pipe(duplex=False) - # Create subscription client with subscription information - signup = multi_processing_broker.MPSubscriptionClient( - agent_id=self.agent.id, - read=broker_read, - write=broker_write, - subscriptions=tuple(self.config.subscriptions), - ) - + # Create the appropriate client (to be implemented by subclasses) + signup = self._create_client(broker_read, broker_write) signup_queue.put(signup) self._client_write = client_write @@ -95,6 +90,14 @@ def __init__(self, config: dict, agent: Agent): # ensure the broker has set up the connection and sends its ack self._client_read.recv() + @abstractmethod + def _create_client(self, broker_read, broker_write): + """ + Create the appropriate client type (broadcast or subscription). + To be implemented by subclasses. + """ + pass + def process(self): """Only start the loop once the env is running.""" _thread = threading.Thread( @@ -132,99 +135,34 @@ def _send(self, payload: CommunicationDict): self._client_write.send(msg) -class MultiProcessingBroadcastClientConfig(CommunicatorConfig): - ipv4: IPv4Address = Field( - default="127.0.0.1", - description="IP Address for the communication server. Defaults to localhost.", - ) - port: int = Field( - default=50_000, - description="Port for setting up the connection with the broker.", - ) - authkey: bytes = Field( - default=b"useTheAgentlib", - description="Authorization key for the connection with the broker.", - ) - - -class MultiProcessingBroadcastClient(Communicator): +class MultiProcessingBroadcastClient(MultiProcessingCommunicatorBase): """ - This communicator implements the communication between agents via a + This communicator implements broadcast communication between agents via a central process broker. """ config: MultiProcessingBroadcastClientConfig - def __init__(self, config: dict, agent: Agent): - super().__init__(config=config, agent=agent) - manager = multi_processing_broker.BrokerManager( - address=(self.config.ipv4, self.config.port), authkey=self.config.authkey - ) - started_wait = time.time() - - while True: - try: - manager.connect() - break - except ConnectionRefusedError: - time.sleep(0.01) - if time.time() - started_wait > 10: - raise RuntimeError("Could not connect to the server.") - - signup_queue = manager.get_queue() - client_read, broker_write = multiprocessing.Pipe(duplex=False) - broker_read, client_write = multiprocessing.Pipe(duplex=False) - signup = multi_processing_broker.MPClient( + def _create_client(self, broker_read, broker_write): + """Creates a broadcast client.""" + return multi_processing_broker.MPClient( agent_id=self.agent.id, read=broker_read, write=broker_write ) - signup_queue.put(signup) - - self._client_write = client_write - self._broker_write = broker_write - self._client_read = client_read - self._broker_read = broker_read - # ensure the broker has set up the connection and sends its ack - self._client_read.recv() - - def process(self): - """Only start the loop once the env is running.""" - _thread = threading.Thread( - target=self._message_handler, name=str(self.source), daemon=True - ) - _thread.start() - self.agent.register_thread(thread=_thread) - yield self.env.event() - - def _message_handler(self): - """Reads messages that were put in the message queue.""" - while True: - try: - msg = self._client_read.recv() - except EOFError: - break - variable = AgentVariable.from_json(msg) - self.logger.debug( - f"Received multiprocessing message for variable {variable.alias}." - ) - self._handle_received_variable(variable) +class MultiProcessingSubscriptionClient(MultiProcessingCommunicatorBase): + """ + This communicator implements subscription-based communication between agents + via a central process broker. + """ - def terminate(self): - """Closes all of the connections.""" - # Terminating is important when running multiple - # simulations/environments, otherwise the broker will keep spamming all - # agents from the previous simulation, potentially filling their queues. - self._client_write.close() - self._client_read.close() - self._broker_write.close() - self._broker_read.close() - super().terminate() + config: MultiProcessingSubscriptionClientConfig - def _send(self, payload: CommunicationDict): - """Sends a variable to the Broker.""" - agent_id = payload["source"] - msg = multi_processing_broker.Message( - agent_id=agent_id, payload=self.to_json(payload) + def _create_client(self, broker_read, broker_write): + """Creates a subscription client with subscription information.""" + return multi_processing_broker.MPSubscriptionClient( + agent_id=self.agent.id, + read=broker_read, + write=broker_write, + subscriptions=tuple(self.config.subscriptions), ) - self._client_write.send(msg) diff --git a/examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py b/examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py index 6b6762ff..4c621fa8 100644 --- a/examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py +++ b/examples/multi-agent-systems/pingpong/pingpong_multiprocessing_subscription.py @@ -24,6 +24,7 @@ { "module_id": "Ag1Com", "type": "multiprocessing_subscription", + "port": 50001, "subscriptions": ["SecondAgent"], }, { @@ -40,6 +41,7 @@ { "module_id": "Ag2Com", "type": "multiprocessing_subscription", + "port": 50001, "subscriptions": ["FirstAgent"], }, { diff --git a/tests/test_examples.py b/tests/test_examples.py index 879d2335..10353c45 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -71,7 +71,7 @@ def test_room_mas(self): until=8640, with_plots=False, log_level=logging.DEBUG, - use_direct_callback_databroker=False + use_direct_callback_databroker=False, ) def test_room_mas_direct_callback(self): From 9dc6169483ffe2f94171e0bfdf8f9a1f8810ac46 Mon Sep 17 00:00:00 2001 From: ses Date: Thu, 30 Oct 2025 20:14:33 +0100 Subject: [PATCH 26/26] add tests for new comm logging --- .../communication_logging_handling.py | 71 +++------ tests/test_communicator.py | 139 +++++++++++++++++- 2 files changed, 160 insertions(+), 50 deletions(-) diff --git a/agentlib/modules/communicator/communication_logging_handling.py b/agentlib/modules/communicator/communication_logging_handling.py index 08580ab4..4f045d8d 100644 --- a/agentlib/modules/communicator/communication_logging_handling.py +++ b/agentlib/modules/communicator/communication_logging_handling.py @@ -155,19 +155,14 @@ def _flush_detail_log(self): ): return - try: - with open(self._communication_log_filename, "a", encoding="utf-8") as f: - for entry in self._communication_log_batch: - json.dump(entry, f) - f.write("\n") - self.logger.debug( - f"Flushed {len(self._communication_log_batch)} entries to {self._communication_log_filename}" - ) - self._communication_log_batch = [] # Clear batch - except IOError as e: - self.logger.error( - f"Error writing communication detail log to {self._communication_log_filename}: {e}" - ) + with open(self._communication_log_filename, "a", encoding="utf-8") as f: + for entry in self._communication_log_batch: + json.dump(entry, f) + f.write("\n") + self.logger.debug( + f"Flushed {len(self._communication_log_batch)} entries to {self._communication_log_filename}" + ) + self._communication_log_batch = [] # Clear batch def terminate(self): """Handle termination cleanup""" @@ -192,27 +187,14 @@ def get_results(self) -> Optional[Union[dict, pd.DataFrame]]: } elif log_level == "detail": self._flush_detail_log() # Ensure all data is written before reading - if ( - self._communication_log_filename - and Path(self._communication_log_filename).exists() - ): - try: - log_entries = [] - with open( - self._communication_log_filename, "r", encoding="utf-8" - ) as f: - for line in f: - log_entries.append(json.loads(line)) - if not log_entries: # Handle empty log file - return pd.DataFrame() - df = pd.DataFrame(log_entries) - return df - except Exception as e: - self.logger.error( - f"Error loading communication detail log from {self._communication_log_filename}: {e}" - ) - return pd.DataFrame() # Return empty DataFrame on error - return pd.DataFrame() # Return empty DataFrame if file doesn't exist + log_entries = [] + with open(self._communication_log_filename, "r", encoding="utf-8") as f: + for line in f: + log_entries.append(json.loads(line)) + if not log_entries: # Handle empty log file + return pd.DataFrame() + df = pd.DataFrame(log_entries) + return df return None def get_results_incremental( @@ -237,12 +219,6 @@ def get_results_incremental( elif log_level == "detail": self._flush_detail_log() - if ( - not self._communication_log_filename - or not Path(self._communication_log_filename).exists() - ): - return None, 0 - if update_token is None: # Initial call df = self.get_results() if df is None or df.empty: @@ -267,15 +243,12 @@ def _load_detail_log_incremental( log_entries = [] lines_read_count = 0 - try: - with open(filename, "r", encoding="utf-8") as f: - for i, line in enumerate(f): - if i < start_line_index: - continue - log_entries.append(json.loads(line)) - lines_read_count += 1 - except FileNotFoundError: - return None, 0 + with open(filename, "r", encoding="utf-8") as f: + for i, line in enumerate(f): + if i < start_line_index: + continue + log_entries.append(json.loads(line)) + lines_read_count += 1 if not log_entries: return None, 0 diff --git a/tests/test_communicator.py b/tests/test_communicator.py index 52f7a8de..17bcfd23 100644 --- a/tests/test_communicator.py +++ b/tests/test_communicator.py @@ -1,4 +1,7 @@ import unittest +import tempfile +import os +from pathlib import Path import pandas as pd @@ -63,6 +66,140 @@ def test_pd_series(self): variable2 = AgentVariable(**payload) pd.testing.assert_series_equal(variable.value, variable2.value) + def test_logging_none(self): + """Test that 'none' log level produces no results""" + config = {**self.test_config, "communication_log_level": "none"} + comm = LocalClient(config=config, agent=self.agent_send) + + # Trigger some sends and receives + variable = AgentVariable(**default_data) + comm._send_only_shared_variables(variable) + comm._handle_received_variable(variable, remote_agent_id="RemoteAgent") + + # Should return None + results = comm.get_results() + self.assertIsNone(results) + + def test_logging_basic(self): + """Test that 'basic' log level produces count dictionaries""" + config = {**self.test_config, "communication_log_level": "basic"} + comm = LocalClient(config=config, agent=self.agent_send) + + # Send multiple variables with different aliases + var1 = AgentVariable(**{**default_data, "name": "var1", "alias": "alias1"}) + var2 = AgentVariable(**{**default_data, "name": "var2", "alias": "alias2"}) + var3 = AgentVariable(**{**default_data, "name": "var1", "alias": "alias1"}) + + comm._send_only_shared_variables(var1) + comm._send_only_shared_variables(var2) + comm._send_only_shared_variables(var3) # alias1 sent twice + + # Receive variables from different sources + comm._handle_received_variable(var1, remote_agent_id="Agent1") + comm._handle_received_variable(var2, remote_agent_id="Agent2") + comm._handle_received_variable(var1, remote_agent_id="Agent1") # Agent1/alias1 twice + + results = comm.get_results() + + # Check structure + self.assertIsInstance(results, dict) + self.assertIn("sent_counts", results) + self.assertIn("received_counts", results) + + # Check sent counts + self.assertEqual(results["sent_counts"]["alias1"], 2) + self.assertEqual(results["sent_counts"]["alias2"], 1) + + # Check received counts (keys are strings of tuples) + received = results["received_counts"] + self.assertEqual(received["('Agent1', 'alias1')"], 2) + self.assertEqual(received["('Agent2', 'alias2')"], 1) + + def test_logging_detail(self): + """Test that 'detail' log level produces a DataFrame with timeline data""" + with tempfile.TemporaryDirectory() as tmpdir: + log_file = os.path.join(tmpdir, "test_comm.jsonl") + config = { + **self.test_config, + "communication_log_level": "detail", + "communication_log_file": log_file, + } + comm = LocalClient(config=config, agent=self.agent_send) + + # Send and receive some variables + var1 = AgentVariable(**{**default_data, "name": "var1", "alias": "temp_sensor"}) + var2 = AgentVariable(**{**default_data, "name": "var2", "alias": "pressure_sensor"}) + + comm._send_only_shared_variables(var1) + comm._send_only_shared_variables(var2) + comm._handle_received_variable(var1, remote_agent_id="SensorAgent1") + comm._handle_received_variable(var2, remote_agent_id="SensorAgent2") + + # Get results + results = comm.get_results() + + # Check it's a DataFrame + self.assertIsInstance(results, pd.DataFrame) + + # Check structure + self.assertEqual(len(results), 4) # 2 sent + 2 received + self.assertIn("timestamp", results.columns) + self.assertIn("direction", results.columns) + self.assertIn("alias", results.columns) + self.assertIn("own_agent_id", results.columns) + self.assertIn("remote_agent_id", results.columns) + + # Check sent messages + sent_df = results[results["direction"] == "sent"] + self.assertEqual(len(sent_df), 2) + self.assertIn("temp_sensor", sent_df["alias"].values) + self.assertIn("pressure_sensor", sent_df["alias"].values) + + # Check received messages + received_df = results[results["direction"] == "received"] + self.assertEqual(len(received_df), 2) + self.assertIn("SensorAgent1", received_df["remote_agent_id"].values) + self.assertIn("SensorAgent2", received_df["remote_agent_id"].values) + + # Cleanup + comm.cleanup_results() + self.assertFalse(Path(log_file).exists()) + + def test_logging_detail_incremental(self): + """Test incremental result retrieval for detail logging""" + with tempfile.TemporaryDirectory() as tmpdir: + log_file = os.path.join(tmpdir, "test_comm_incremental.jsonl") + config = { + **self.test_config, + "communication_log_level": "detail", + "communication_log_file": log_file, + } + comm = LocalClient(config=config, agent=self.agent_send) + + # Send first batch + var1 = AgentVariable(**{**default_data, "name": "var1", "alias": "alias1"}) + comm._send_only_shared_variables(var1) + + # Get initial results + results1, token1 = comm.get_results_incremental(update_token=None) + self.assertIsInstance(results1, pd.DataFrame) + self.assertEqual(len(results1), 1) + self.assertIsNotNone(token1) + + # Send second batch + var2 = AgentVariable(**{**default_data, "name": "var2", "alias": "alias2"}) + comm._send_only_shared_variables(var2) + comm._handle_received_variable(var1, remote_agent_id="Agent1") + + # Get incremental results + results2, token2 = comm.get_results_incremental(update_token=token1) + self.assertIsInstance(results2, pd.DataFrame) + self.assertEqual(len(results2), 2) # 1 sent + 1 received + self.assertEqual(token2, token1 + 2) + + # Cleanup + comm.cleanup_results() + def test_pd_series_no_json(self): """Tests whether pandas series are sent correctly""" data = {**default_data, "value": pd.Series({0: 1, 10: 2}), "type": "pd.Series"} @@ -75,4 +212,4 @@ def test_pd_series_no_json(self): if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file