diff --git a/CHANGELOG.md b/CHANGELOG.md index f689f5121..70ae4227d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.1.6] - 2025-07-30 ### Changed -- `Sink`, `Source` and `SourceAndSink` now accept multiple `flows` as `inputs` and `outputs` instead of just one. This enables to model more use cases using these classes. -- Further, both `Sink` and `Source` now have a `prevent_simultaneous_flow_rates` argument to prevent simultaneous flow rates of more than one of their Flows. +- `Sink`, `Source` and `SourceAndSink` now accept multiple `flows` as `inputs` and `outputs` instead of just one. This enables to model more use cases using these classes. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] +- Further, both `Sink` and `Source` now have a `prevent_simultaneous_flow_rates` argument to prevent simultaneous flow rates of more than one of their Flows. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] + +### Added +- Added `FlowSystem.start_network_app()` and `FlowSystem.stop_network_app()` to easily visualize the network structure of a flow system in an interactive dash web app. This is still experimental and might change in the future. [[#293](https://github.com/flixOpt/flixopt/pull/293) by [@FBumann](https://github.com/FBumann)] ### Deprecated -- For the classes `Sink`, `Source` and `SourceAndSink`: `.sink`, `.source` and `.prevent_simultaneous_sink_and_source` are deprecated in favor of the new arguments `inputs`, `outputs` and `prevent_simultaneous_flow_rates`. +- For the classes `Sink`, `Source` and `SourceAndSink`: `.sink`, `.source` and `.prevent_simultaneous_sink_and_source` are deprecated in favor of the new arguments `inputs`, `outputs` and `prevent_simultaneous_flow_rates`. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] ### Fixed -- Fixed testing issue with new `linopy` version 0.5.6 [[#296](https://github.com/PyPSA/linopy/pull/296) by [@FBumann](https://github.com/FBumann)] +- Fixed testing issue with new `linopy` version 0.5.6 [[#296](https://github.com/flixOpt/flixopt/pull/296) by [@FBumann](https://github.com/FBumann)] ## [2.1.5] - 2025-07-08 diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 44ef496d7..175211c26 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -182,6 +182,7 @@ flow_system.add_elements(bhkw_2) if use_chp_with_piecewise_conversion else flow_system.add_elements(bhkw) pprint(flow_system) # Get a string representation of the FlowSystem + flow_system.start_network_app() # Start the network app. DOes only work with extra dependencies installed # --- Solve FlowSystem --- calculation = fx.FullCalculation('complex example', flow_system, time_indices) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 93720de60..19f8a240f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -61,6 +61,8 @@ def __init__( self._connected = False + self._network_app = None + @classmethod def from_dataset(cls, ds: xr.Dataset): timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') @@ -241,6 +243,57 @@ def plot_network( node_infos, edge_infos = self.network_infos() return plotting.plot_network(node_infos, edge_infos, path, controls, show) + def start_network_app(self): + """Visualizes the network structure of a FlowSystem using Dash, Cytoscape, and networkx. + Requires optional dependencies: dash, dash-cytoscape, networkx, werkzeug. + """ + from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR, flow_graph, shownetwork + + warnings.warn( + 'The network visualization is still experimental and might change in the future.', + stacklevel=2, + category=UserWarning, + ) + + if not DASH_CYTOSCAPE_AVAILABLE: + raise ImportError( + f"Network visualization requires optional dependencies. " + f"Install with: pip install flixopt[viz], flixopt[full] or pip install dash dash_cytoscape networkx werkzeug. " + f"Original error: {VISUALIZATION_ERROR}" + ) + + if not self._connected: + self._connect_network() + + if self._network_app is not None: + logger.warning('The network app is already running. Restarting it.') + self.stop_network_app() + + self._network_app = shownetwork(flow_graph(self)) + + def stop_network_app(self): + """Stop the network visualization server.""" + from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR + if not DASH_CYTOSCAPE_AVAILABLE: + raise ImportError( + f'Network visualization requires optional dependencies. ' + f'Install with: pip install flixopt[viz]. ' + f'Original error: {VISUALIZATION_ERROR}' + ) + + if self._network_app is None: + logger.warning('No network app is currently running. Cant stop it') + return + + try: + logger.info('Stopping network visualization server...') + self._network_app.server_instance.shutdown() + logger.info('Network visualization stopped.') + except Exception as e: + logger.error(f'Failed to stop the network visualization app: {e}') + finally: + self._network_app = None + def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: if not self._connected: self._connect_network() diff --git a/flixopt/network_app.py b/flixopt/network_app.py new file mode 100644 index 000000000..419c02692 --- /dev/null +++ b/flixopt/network_app.py @@ -0,0 +1,612 @@ +import json +import logging +import socket +import threading +from typing import Any, Dict, List + +try: + import dash_cytoscape as cyto + import dash_daq as daq + import networkx + from dash import Dash, Input, Output, State, callback_context, dcc, html, no_update + from werkzeug.serving import make_server + + DASH_CYTOSCAPE_AVAILABLE = True + VISUALIZATION_ERROR = None +except ImportError as e: + DASH_CYTOSCAPE_AVAILABLE = False + VISUALIZATION_ERROR = str(e) + +from .components import LinearConverter, Sink, Source, SourceAndSink, Storage +from .elements import Bus, Component, Flow +from .flow_system import FlowSystem + +logger = logging.getLogger('flixopt') + +# Configuration class for better organization +class VisualizationConfig: + """Configuration constants for the visualization""" + + DEFAULT_COLORS = { + 'Bus': '#7F8C8D', + 'Source': '#F1C40F', + 'Sink': '#F1C40F', + 'Storage': '#2980B9', + 'Converter': '#D35400', + 'Other': '#27AE60', + } + + COLOR_PRESETS = { + 'Default': DEFAULT_COLORS, + 'Vibrant': { + 'Bus': '#FF6B6B', 'Source': '#4ECDC4', 'Sink': '#45B7D1', + 'Storage': '#96CEB4', 'Converter': '#FFEAA7', 'Other': '#DDA0DD', + }, + 'Dark': { + 'Bus': '#2C3E50', 'Source': '#34495E', 'Sink': '#7F8C8D', + 'Storage': '#95A5A6', 'Converter': '#BDC3C7', 'Other': '#ECF0F1', + }, + 'Pastel': { + 'Bus': '#FFB3BA', 'Source': '#BAFFC9', 'Sink': '#BAE1FF', + 'Storage': '#FFFFBA', 'Converter': '#FFDFBA', 'Other': '#E0BBE4', + }, + } + + DEFAULT_STYLESHEET = [ + { + 'selector': 'node', + 'style': { + 'content': 'data(label)', + 'background-color': 'data(color)', + 'font-size': 10, + 'color': 'white', + 'text-valign': 'center', + 'text-halign': 'center', + 'width': '90px', + 'height': '70px', + 'shape': 'data(shape)', + 'text-outline-color': 'black', + 'text-outline-width': 0.5, + }, + }, + { + 'selector': '[shape = "custom-source"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', + }, + }, + { + 'selector': '[shape = "custom-sink"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', + }, + }, + { + 'selector': 'edge', + 'style': { + 'curve-style': 'straight', + 'width': 2, + 'line-color': 'gray', + 'target-arrow-color': 'gray', + 'target-arrow-shape': 'triangle', + 'arrow-scale': 2, + }, + }, + ] + +def flow_graph(flow_system: FlowSystem) -> networkx.DiGraph: + """Convert FlowSystem to NetworkX graph - simplified and more robust""" + nodes = list(flow_system.components.values()) + list(flow_system.buses.values()) + edges = list(flow_system.flows.values()) + + def get_element_type(element): + """Determine element type for coloring""" + if isinstance(element, Bus): + return 'Bus' + elif isinstance(element, Source): + return 'Source' + elif isinstance(element, (Sink, SourceAndSink)): + return 'Sink' + elif isinstance(element, Storage): + return 'Storage' + elif isinstance(element, LinearConverter): + return 'Converter' + else: + return 'Other' + + def get_shape(element): + """Determine node shape""" + if isinstance(element, Bus): + return 'ellipse' + elif isinstance(element, Source): + return 'custom-source' + elif isinstance(element, (Sink, SourceAndSink)): + return 'custom-sink' + else: + return 'rectangle' + + graph = networkx.DiGraph() + + # Add nodes with attributes + for node in nodes: + graph.add_node( + node.label_full, + color=VisualizationConfig.DEFAULT_COLORS[get_element_type(node)], + shape=get_shape(node), + element_type=get_element_type(node), + parameters=str(node), + ) + + # Add edges + for edge in edges: + try: + graph.add_edge( + u_of_edge=edge.bus if edge.is_input_in_component else edge.component, + v_of_edge=edge.component if edge.is_input_in_component else edge.bus, + label=edge.label_full, + parameters=edge.__str__().replace(')', '\n)'), + ) + except Exception as e: + logger.error(f"Failed to add edge {edge}: {e}") + + return graph + +def make_cytoscape_elements(graph: networkx.DiGraph) -> List[Dict[str, Any]]: + """Convert NetworkX graph to Cytoscape elements""" + elements = [] + + # Add nodes + for node_id in graph.nodes(): + node_data = graph.nodes[node_id] + elements.append({ + 'data': { + 'id': node_id, + 'label': node_id, + 'color': node_data.get('color', '#7F8C8D'), + 'shape': node_data.get('shape', 'rectangle'), + 'element_type': node_data.get('element_type', 'Other'), + 'parameters': node_data.get('parameters', ''), + } + }) + + # Add edges + for u, v in graph.edges(): + edge_data = graph.edges[u, v] + elements.append({ + 'data': { + 'source': u, + 'target': v, + 'id': f'{u}-{v}', + 'label': edge_data.get('label', ''), + 'parameters': edge_data.get('parameters', ''), + } + }) + + return elements + +def create_color_picker_input(label: str, input_id: str, default_color: str): + """Create a compact color picker with DAQ ColorPicker""" + return html.Div([ + html.Label(label, style={ + 'color': 'white', 'font-size': '12px', 'margin-bottom': '5px', + 'display': 'block' + }), + daq.ColorPicker( + id=input_id, + label="", + value={'hex': default_color}, + size=200, + theme={'dark': True}, + style={'margin-bottom': '10px'} + ), + ]) + +def create_style_section(title: str, children: List): + """Create a collapsible section for organizing controls""" + return html.Div([ + html.H4(title, style={ + 'color': 'white', 'margin-bottom': '10px', + 'border-bottom': '2px solid #3498DB', 'padding-bottom': '5px', + }), + html.Div(children, style={'margin-bottom': '20px'}), + ]) + +def create_sidebar(): + """Create the main sidebar with improved organization""" + return html.Div([ + html.Div([ + html.H3('Style Controls', style={ + 'color': 'white', 'margin-bottom': '20px', 'text-align': 'center', + 'border-bottom': '3px solid #9B59B6', 'padding-bottom': '10px', + }), + + # Layout Section + create_style_section('Layout', [ + dcc.Dropdown( + id='layout-dropdown', + options=[ + {'label': 'Klay (horizontal)', 'value': 'klay'}, + {'label': 'Dagre (vertical)', 'value': 'dagre'}, + {'label': 'Breadthfirst', 'value': 'breadthfirst'}, + {'label': 'Cose (force-directed)', 'value': 'cose'}, + {'label': 'Grid', 'value': 'grid'}, + {'label': 'Circle', 'value': 'circle'}, + ], + value='klay', + clearable=False, + style={'width': '100%'}, + ), + ]), + + # Color Scheme Section + create_style_section('Color Scheme', [ + dcc.Dropdown( + id='color-scheme-dropdown', + options=[{'label': k, 'value': k} for k in VisualizationConfig.COLOR_PRESETS.keys()], + value='Default', + style={'width': '100%', 'margin-bottom': '10px'}, + ), + ]), + + # Color Pickers Section + create_style_section('Custom Colors', [ + create_color_picker_input('Bus', 'bus-color-picker', '#7F8C8D'), + create_color_picker_input('Source', 'source-color-picker', '#F1C40F'), + create_color_picker_input('Sink', 'sink-color-picker', '#F1C40F'), + create_color_picker_input('Storage', 'storage-color-picker', '#2980B9'), + create_color_picker_input('Converter', 'converter-color-picker', '#D35400'), + create_color_picker_input('Edge', 'edge-color-picker', '#808080'), + ]), + + # Node Settings + create_style_section('Node Settings', [ + html.Label('Size', style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='node-size-slider', min=50, max=150, step=10, value=90, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(50, 151, 25)}, + tooltip={'placement': 'bottom', 'always_visible': True}, + ), + html.Br(), + html.Label('Font Size', style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='font-size-slider', min=8, max=20, step=1, value=10, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(8, 21, 2)}, + tooltip={'placement': 'bottom', 'always_visible': True}, + ), + ]), + + # Reset Button + html.Div([ + html.Button('Reset to Defaults', id='reset-btn', n_clicks=0, style={ + 'width': '100%', 'background-color': '#E74C3C', 'color': 'white', + 'border': 'none', 'padding': '10px', 'border-radius': '5px', + 'cursor': 'pointer', 'margin-top': '20px', + }), + ]), + ], id='sidebar-content', style={ + 'width': '280px', 'height': '100vh', 'background-color': '#2C3E50', + 'padding': '20px', 'position': 'fixed', 'left': '0', 'top': '0', + 'overflow-y': 'auto', 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', 'z-index': '999', + 'transform': 'translateX(-100%)', 'transition': 'transform 0.3s ease', + }) + ]) + +def shownetwork(graph: networkx.DiGraph): + """Main function to create and run the network visualization""" + if not DASH_CYTOSCAPE_AVAILABLE: + raise ImportError(f"Required packages not available: {VISUALIZATION_ERROR}") + + app = Dash(__name__, suppress_callback_exceptions=True) + + # Load extra layouts + cyto.load_extra_layouts() + + # Create initial elements + elements = make_cytoscape_elements(graph) + + # App Layout + app.layout = html.Div([ + # Toggle button + html.Button('☰', id='toggle-sidebar', n_clicks=0, style={ + 'position': 'fixed', 'top': '20px', 'left': '20px', 'z-index': '1000', + 'background-color': '#3498DB', 'color': 'white', 'border': 'none', + 'padding': '10px 15px', 'border-radius': '5px', 'cursor': 'pointer', + 'font-size': '18px', 'box-shadow': '0 2px 5px rgba(0,0,0,0.3)', + }), + + # Data storage + dcc.Store(id='elements-store', data=elements), + + # Sidebar + create_sidebar(), + + # Main content + html.Div([ + # Header + html.Div([ + html.H2('Network Visualization', style={ + 'color': 'white', 'margin': '0', 'text-align': 'center' + }), + html.Button('Export PNG', id='export-btn', n_clicks=0, style={ + 'position': 'absolute', 'right': '20px', 'top': '15px', + 'background-color': '#27AE60', 'color': 'white', 'border': 'none', + 'padding': '10px 20px', 'border-radius': '5px', 'cursor': 'pointer', + }), + ], style={ + 'background-color': '#34495E', 'padding': '15px 20px', + 'position': 'relative', 'border-bottom': '2px solid #3498DB', + }), + + # Cytoscape graph + cyto.Cytoscape( + id='cytoscape', + layout={'name': 'klay'}, + style={'width': '100%', 'height': '70vh'}, + elements=elements, + stylesheet=VisualizationConfig.DEFAULT_STYLESHEET, + ), + + # Info panel + html.Div([ + html.H4('Element Information', style={ + 'color': 'white', 'margin': '0 0 10px 0', + 'border-bottom': '2px solid #3498DB', 'padding-bottom': '5px', + }), + html.Div(id='info-panel', children=[ + html.P('Click on a node or edge to see details.', + style={'color': '#95A5A6', 'font-style': 'italic'}) + ]), + ], style={ + 'background-color': '#2C3E50', 'padding': '15px', + 'height': '25vh', 'overflow-y': 'auto', + 'border-top': '2px solid #34495E', + }), + ], id='main-content', style={ + 'margin-left': '0', 'background-color': '#1A252F', + 'min-height': '100vh', 'transition': 'margin-left 0.3s ease', + }), + ]) + + # Callbacks + @app.callback( + [Output('sidebar-content', 'style'), Output('main-content', 'style')], + [Input('toggle-sidebar', 'n_clicks')] + ) + def toggle_sidebar(n_clicks): + is_open = (n_clicks or 0) % 2 == 1 + sidebar_transform = 'translateX(0)' if is_open else 'translateX(-100%)' + main_margin = '280px' if is_open else '0' + + sidebar_style = { + 'width': '280px', 'height': '100vh', 'background-color': '#2C3E50', + 'padding': '20px', 'position': 'fixed', 'left': '0', 'top': '0', + 'overflow-y': 'auto', 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', 'z-index': '999', + 'transform': sidebar_transform, 'transition': 'transform 0.3s ease', + } + + main_style = { + 'margin-left': main_margin, 'background-color': '#1A252F', + 'min-height': '100vh', 'transition': 'margin-left 0.3s ease', + } + + return sidebar_style, main_style + + @app.callback( + [Output('cytoscape', 'elements'), Output('cytoscape', 'stylesheet')], + [ + Input('color-scheme-dropdown', 'value'), + Input('bus-color-picker', 'value'), + Input('source-color-picker', 'value'), + Input('sink-color-picker', 'value'), + Input('storage-color-picker', 'value'), + Input('converter-color-picker', 'value'), + Input('edge-color-picker', 'value'), + Input('node-size-slider', 'value'), + Input('font-size-slider', 'value'), + ], + [State('elements-store', 'data')] + ) + def update_visualization(color_scheme, bus_color, source_color, sink_color, + storage_color, converter_color, edge_color, + node_size, font_size, stored_elements): + if not stored_elements: + return no_update, no_update + + # Determine colors to use + if any(picker for picker in [bus_color, source_color, sink_color, + storage_color, converter_color, edge_color]): + # Use custom colors from pickers + colors = { + 'Bus': bus_color.get('hex') if bus_color else '#7F8C8D', + 'Source': source_color.get('hex') if source_color else '#F1C40F', + 'Sink': sink_color.get('hex') if sink_color else '#F1C40F', + 'Storage': storage_color.get('hex') if storage_color else '#2980B9', + 'Converter': converter_color.get('hex') if converter_color else '#D35400', + 'Other': '#27AE60', + } + else: + # Use preset scheme + colors = VisualizationConfig.COLOR_PRESETS.get(color_scheme, + VisualizationConfig.DEFAULT_COLORS) + + # Update element colors + updated_elements = [] + for element in stored_elements: + if 'data' in element and 'element_type' in element['data']: + element_copy = element.copy() + element_copy['data'] = element['data'].copy() + element_type = element_copy['data']['element_type'] + if element_type in colors: + element_copy['data']['color'] = colors[element_type] + updated_elements.append(element_copy) + else: + updated_elements.append(element) + + # Create stylesheet + edge_color_hex = edge_color.get('hex') if edge_color else 'gray' + stylesheet = [ + { + 'selector': 'node', + 'style': { + 'content': 'data(label)', + 'background-color': 'data(color)', + 'font-size': font_size or 10, + 'color': 'white', + 'text-valign': 'center', + 'text-halign': 'center', + 'width': f'{node_size or 90}px', + 'height': f'{int((node_size or 90) * 0.8)}px', + 'shape': 'data(shape)', + 'text-outline-color': 'black', + 'text-outline-width': 0.5, + }, + }, + { + 'selector': '[shape = "custom-source"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', + }, + }, + { + 'selector': '[shape = "custom-sink"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', + }, + }, + { + 'selector': 'edge', + 'style': { + 'curve-style': 'straight', + 'width': 2, + 'line-color': edge_color_hex, + 'target-arrow-color': edge_color_hex, + 'target-arrow-shape': 'triangle', + 'arrow-scale': 2, + }, + }, + ] + + return updated_elements, stylesheet + + @app.callback( + Output('info-panel', 'children'), + [Input('cytoscape', 'tapNodeData'), Input('cytoscape', 'tapEdgeData')] + ) + def display_element_info(node_data, edge_data): + ctx = callback_context + if not ctx.triggered: + return [html.P('Click on a node or edge to see details.', + style={'color': '#95A5A6', 'font-style': 'italic'})] + + # Determine what was clicked + if ctx.triggered[0]['prop_id'] == 'cytoscape.tapNodeData' and node_data: + return [ + html.H5(f"Node: {node_data.get('label', 'Unknown')}", + style={'color': 'white', 'margin-bottom': '10px'}), + html.P(f"Type: {node_data.get('element_type', 'Unknown')}", + style={'color': '#BDC3C7'}), + html.Pre(node_data.get('parameters', 'No parameters'), + style={'color': '#BDC3C7', 'font-size': '11px', + 'white-space': 'pre-wrap'}) + ] + elif ctx.triggered[0]['prop_id'] == 'cytoscape.tapEdgeData' and edge_data: + return [ + html.H5(f"Edge: {edge_data.get('label', 'Unknown')}", + style={'color': 'white', 'margin-bottom': '10px'}), + html.P(f"{edge_data.get('source', '')} → {edge_data.get('target', '')}", + style={'color': '#E67E22'}), + html.Pre(edge_data.get('parameters', 'No parameters'), + style={'color': '#BDC3C7', 'font-size': '11px', + 'white-space': 'pre-wrap'}) + ] + + return [html.P('Click on a node or edge to see details.', + style={'color': '#95A5A6', 'font-style': 'italic'})] + + @app.callback( + Output('cytoscape', 'layout'), + Input('layout-dropdown', 'value') + ) + def update_layout(selected_layout): + return {'name': selected_layout} + + # Reset callback + @app.callback( + [ + Output('color-scheme-dropdown', 'value'), + Output('bus-color-picker', 'value'), + Output('source-color-picker', 'value'), + Output('sink-color-picker', 'value'), + Output('storage-color-picker', 'value'), + Output('converter-color-picker', 'value'), + Output('edge-color-picker', 'value'), + Output('node-size-slider', 'value'), + Output('font-size-slider', 'value'), + Output('layout-dropdown', 'value'), + ], + [Input('reset-btn', 'n_clicks')] + ) + def reset_controls(n_clicks): + if n_clicks and n_clicks > 0: + return ( + 'Default', # color scheme + {'hex': '#7F8C8D'}, # bus + {'hex': '#F1C40F'}, # source + {'hex': '#F1C40F'}, # sink + {'hex': '#2980B9'}, # storage + {'hex': '#D35400'}, # converter + {'hex': '#808080'}, # edge + 90, # node size + 10, # font size + 'klay', # layout + ) + return no_update + + # Export functionality + app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks > 0 && window.cy) { + var png64 = window.cy.png({scale: 3, full: true}); + var a = document.createElement('a'); + a.href = png64; + a.download = 'network_visualization.png'; + a.click(); + } + return 'Export PNG'; + } + """, + Output('export-btn', 'children'), + Input('export-btn', 'n_clicks'), + ) + + # Start server + def find_free_port(start_port=8050, end_port=8100): + for port in range(start_port, end_port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(('localhost', port)) != 0: + return port + raise Exception('No free port found') + + port = find_free_port() + server = make_server('127.0.0.1', port, app.server) + + # Start server in background thread + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + print(f'Network visualization started on http://127.0.0.1:{port}/') + + # Store server reference for cleanup + app.server_instance = server + app.port = port + + return app diff --git a/pyproject.toml b/pyproject.toml index 4624d3a00..7809c8415 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,48 +30,64 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", - "License :: OSI Approved :: MIT License", ] dependencies = [ # Core scientific computing "numpy >= 1.21.5, < 3", "pandas >= 2.0.0, < 3", - # Optimization and data handling "linopy >= 0.5.1, < 0.5.6", # Update and test regularly "netcdf4 >= 1.6.1, < 2", - # Utilities "PyYAML >= 6.0.0, < 7", "rich >= 13.0.0", - "tomli >= 2.0.1; python_version < '3.11'", #Only needed with python 3.10 or earlier - + "tomli >= 2.0.1; python_version < '3.11'", # Only needed with python 3.10 or earlier # Default solver "highspy >= 1.5.3", - - # Visualization + # Default visualization "matplotlib >= 3.5.2, < 4.0.0", "plotly >= 5.15.0, < 6.0.0", ] [project.optional-dependencies] -dev = [ - "pytest >= 7.0.0", - "ruff >= 0.9.0", - "pyvis == 0.3.1", # Visualizing FlowSystem +# Interactive network visualization with enhanced color picker +network-viz = [ + "dash >= 3.0.0", + "dash-cytoscape >= 1.0.0", + "dash-daq >= 0.6.0", + "networkx >= 3.0.0", + "werkzeug >= 3.0.0", +] + +# Full feature set (everything except dev tools) +full = [ + "pyvis == 0.3.1", # Visualizing FlowSystem Network "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0", + "dash >= 3.0.0", # Visualizing FlowSystem Network as app + "dash-cytoscape >= 1.0.0", # Visualizing FlowSystem Network as app + "dash-daq >= 0.6.0", # Visualizing FlowSystem Network as app + "networkx >= 3.0.0", # Visualizing FlowSystem Network as app + "werkzeug >= 3.0.0", # Visualizing FlowSystem Network as app ] -full = [ - "pyvis == 0.3.1", # Visualizing FlowSystem Network +# Development tools and testing +dev = [ + "pytest >= 7.0.0", + "ruff >= 0.9.0", + "pyvis == 0.3.1", # Visualizing FlowSystem "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 - "streamlit >= 1.44.0, < 2.0.0", "gurobipy >= 10.0.0", + "dash >= 3.0.0", + "dash-cytoscape >= 1.0.0", + "dash-daq >= 0.6.0", + "networkx >= 3.0.0", + "werkzeug >= 3.0.0", ] +# Documentation building docs = [ "mkdocs-material >= 9.0.0, < 10", "mkdocstrings-python >= 1.0.0", @@ -130,4 +146,4 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru [tool.ruff.format] quote-style = "single" indent-style = "space" -docstring-code-format = true +docstring-code-format = true \ No newline at end of file diff --git a/tests/test_network_app.py b/tests/test_network_app.py new file mode 100644 index 000000000..df9bb00a5 --- /dev/null +++ b/tests/test_network_app.py @@ -0,0 +1,29 @@ +from typing import Dict, List, Optional, Union + +import pytest + +import flixopt as fx +from flixopt.io import CalculationResultsPaths + +from .conftest import ( + assert_almost_equal_numeric, + flow_system_base, + flow_system_long, + flow_system_segments_of_flows_2, + simple_flow_system, +) + + +@pytest.fixture(params=[simple_flow_system, flow_system_segments_of_flows_2, flow_system_long]) +def flow_system(request): + fs = request.getfixturevalue(request.param.__name__) + if isinstance(fs, fx.FlowSystem): + return fs + else: + return fs[0] + +def test_network_app(flow_system): + """Test that flow model constraints are correctly generated.""" + flow_system.start_network_app() + flow_system.stop_network_app() +