import streamlit as st import requests from streamlit_folium import st_folium import folium from io import BytesIO from PIL import Image, ImageDraw, ImageFont from graphics_utils import draw_smooth_ellipse from staticmap import StaticMap, CircleMarker from streamlit_image_coordinates import streamlit_image_coordinates from math import radians, cos, sin, sqrt, atan2 import json import base64 from datetime import datetime import pandas as pd from process_utils import ( init_process_state, add_process, delete_process, add_stream_to_process, delete_stream_from_process, # New recursive functions for sub-subprocesses create_process_node, create_stream, add_child_to_node, delete_child_from_node, add_stream_to_node, delete_stream_from_node, iterate_all_nodes, get_level_name, ) import csv import os # Load process models from JSON file PROCESS_MODELS_PATH = os.path.join(os.path.dirname(__file__), '..', 'process_models.json') with open(PROCESS_MODELS_PATH, 'r') as f: PROCESS_MODEL_DICT = json.load(f) # ============================================================================= # RECURSIVE UI FUNCTION for rendering children at any level # ============================================================================= def render_subsubprocesses(st_module, subprocess_node, subprocess_idx, process_model_dict=None): """ Render sub-subprocesses for a subprocess. This is the FINAL level (level 2). No deeper recursion - only 3 levels: Process > Subprocess > Sub-subprocess Args: st_module: Streamlit module subprocess_node: The subprocess containing sub-subprocesses subprocess_idx: Index of the parent subprocess process_model_dict: Dict of process models for selection """ children = subprocess_node.get('children', []) for ci, child in enumerate(children): child_key = f"subsub_{subprocess_idx}_{ci}" # Ensure child has level set if 'level' not in child: child['level'] = 2 # Sub-subprocess is level 2 # Separator line st_module.markdown( "
", unsafe_allow_html=True ) # Initialize expanded state if 'expanded' not in child: child['expanded'] = False # Header row: Toggle | Name | Size | Place | Delete cols = st_module.columns([0.05, 0.45, 0.15, 0.15, 0.08]) # Toggle expand/collapse toggle_label = "▾" if child.get('expanded', False) else "▸" if cols[0].button(toggle_label, key=f"{child_key}_toggle"): child['expanded'] = not child.get('expanded', False) st_module.rerun() # Name input default_name = child.get('name', f'Sub-subprocess {ci+1}') child['name'] = cols[1].text_input( "Sub-subprocess name", value=default_name, key=f"{child_key}_name", label_visibility="collapsed", placeholder=f"Sub-subprocess {ci+1}" ) # Size slider if 'box_scale' not in child or child.get('box_scale') in (None, ''): child['box_scale'] = 0.8 try: current_scale = float(child.get('box_scale', 0.8)) except (ValueError, TypeError): current_scale = 0.8 child['box_scale'] = cols[2].slider( "Size", min_value=0.3, max_value=2.0, value=current_scale, step=0.1, key=f"{child_key}_scale", label_visibility="collapsed" ) # Place button place_key = f"child_subsub_{subprocess_idx}_{ci}" place_active = (st_module.session_state.get('placement_mode') and st_module.session_state.get('placing_process_idx') == place_key) if not place_active: if cols[3].button("Place", key=f"{child_key}_place"): st_module.session_state['placement_mode'] = True st_module.session_state['measure_mode'] = False st_module.session_state['placing_process_idx'] = place_key st_module.session_state['placing_node_ref'] = child st_module.session_state['ui_status_msg'] = f"Click on map to place: {child.get('name', 'Sub-subprocess')}" st_module.rerun() else: if cols[3].button("Done", key=f"{child_key}_place_done"): st_module.session_state['placement_mode'] = False st_module.session_state['placing_process_idx'] = None st_module.session_state['placing_node_ref'] = None st_module.session_state['ui_status_msg'] = "Placement mode disabled" st_module.rerun() # Delete button if cols[4].button("✕", key=f"{child_key}_delete"): delete_child_from_node(subprocess_node, ci) st_module.rerun() # Expanded content (Information and Streams only - no deeper children) if child.get('expanded', False): # Information toggle if 'info_expanded' not in child: child['info_expanded'] = False info_cols = st_module.columns([0.05, 0.95]) info_toggle = "▾" if child.get('info_expanded', False) else "▸" if info_cols[0].button(info_toggle, key=f"{child_key}_info_toggle"): child['info_expanded'] = not child.get('info_expanded', False) st_module.rerun() info_cols[1].markdown("**Information**") if child.get('info_expanded', False): # Coordinates coord_cols = st_module.columns([1, 1, 1]) child['lat'] = coord_cols[0].text_input("Latitude", value=str(child.get('lat', '') or ''), key=f"{child_key}_lat") child['lon'] = coord_cols[1].text_input("Longitude", value=str(child.get('lon', '') or ''), key=f"{child_key}_lon") # Hours if 'hours' not in child: child['hours'] = '' child['hours'] = coord_cols[2].text_input("Hours", value=str(child.get('hours', '') or ''), key=f"{child_key}_hours") # Notes if 'extra_info' not in child: child['extra_info'] = {} child['extra_info']['notes'] = st_module.text_area( "Notes", value=child['extra_info'].get('notes', ''), key=f"{child_key}_notes", height=60 ) # Streams section streams_header = st_module.columns([0.7, 0.3]) streams_header[0].markdown("**Streams**") if streams_header[1].button("➕ Stream", key=f"{child_key}_add_stream"): add_stream_to_node(child) st_module.rerun() streams = child.get('streams', []) if not streams: st_module.caption("No streams yet. Use ➕ to add one.") else: for si, stream in enumerate(streams): render_stream_recursive(st_module, stream, f"{child_key}_stream_{si}", child, si) def render_stream_recursive(st_module, stream, stream_key, parent_node, stream_index): """ Render a single stream with the same UI at any level. This is REUSABLE for streams in any process node. """ # Ensure stream has required fields if 'name' not in stream: stream['name'] = f"Stream {stream_index + 1}" if 'type' not in stream: stream['type'] = 'product' if 'properties' not in stream: stream['properties'] = {'prop1': 'Tin', 'prop2': 'Tout', 'prop3': 'ṁ', 'prop4': 'cp'} if 'values' not in stream: stream['values'] = {'val1': '', 'val2': '', 'val3': '', 'val4': ''} # Stream header header_cols = st_module.columns([1.5, 1, 0.5]) stream['name'] = header_cols[0].text_input( "Stream name", value=stream.get('name', ''), key=f"{stream_key}_name", label_visibility="collapsed", placeholder=f"Stream {stream_index + 1}" ) stream_types = ["product", "steam", "air", "water"] current_type = stream.get('type', 'product') if current_type not in stream_types: current_type = 'product' stream['type'] = header_cols[1].selectbox( "Type", options=stream_types, index=stream_types.index(current_type), key=f"{stream_key}_type", label_visibility="collapsed" ) if header_cols[2].button("✕", key=f"{stream_key}_delete"): delete_stream_from_node(parent_node, stream_index) st_module.rerun() # All stream types use the same selection logic with different preselected defaults display_options = ["Tin", "Tout", "ṁ", "cp", "CP", "Water Content In", "Water Content Out", "Density", "Pressure"] # Define default preselected variables based on stream type stream_type = stream.get('type', 'product') if stream_type == 'product': default_display = ["Tin", "Tout", "ṁ", "cp"] elif stream_type == 'steam': default_display = ["Tin", "ṁ"] elif stream_type == 'water': default_display = ["Tin", "ṁ", "Water Content In", "Water Content Out"] else: # air, etc. default_display = ["Tin", "ṁ"] # Initialize display_vars in session state if not present display_key = f"{stream_key}_display_vars" if display_key not in st_module.session_state: # Use existing stream setting or default based on type current_display = stream.get('display_vars', None) if current_display is None: current_display = default_display current_display = [v for v in current_display if v in display_options] st_module.session_state[display_key] = current_display selected_display = st_module.multiselect( "Variables", options=display_options, key=display_key, help="Select variables to enter" ) stream['display_vars'] = selected_display # Build list of properties to show based on selection props_to_show = selected_display n_props = len(props_to_show) if n_props > 0: prop_cols = st_module.columns(n_props) for pi, prop_name in enumerate(props_to_show): with prop_cols[pi]: st_module.caption(prop_name) current_val = stream.get('stream_values', {}).get(prop_name, '') new_val = st_module.text_input( prop_name, value=str(current_val), key=f"{stream_key}_sval_{prop_name}", label_visibility="collapsed" ) # Store in stream_values dict if 'stream_values' not in stream: stream['stream_values'] = {} stream['stream_values'][prop_name] = new_val # Update legacy fields for compatibility sv = stream.get('stream_values', {}) stream['temp_in'] = sv.get('Tin', '') stream['temp_out'] = sv.get('Tout', '') stream['mdot'] = sv.get('ṁ', '') stream['cp'] = sv.get('cp', '') # Also update properties/values structure for drawing stream['properties'] = {'prop1': 'Tin'} stream['values'] = {'val1': sv.get('Tin', '')} idx = 2 for var in selected_display: stream['properties'][f'prop{idx}'] = var stream['values'][f'val{idx}'] = sv.get(var, '') idx += 1 # Helper: convert pixel relative to center in snapshot to lon/lat using Web Mercator math def snapshot_pixel_to_lonlat(px, py, center_ll, z_level, img_w, img_h): import math as _math def lonlat_to_xy(lon_val, lat_val, z_val): lat_rad = _math.radians(lat_val) n_val = 2.0 ** z_val xtile = (lon_val + 180.0) / 360.0 * n_val ytile = (1.0 - _math.log(_math.tan(lat_rad) + 1 / _math.cos(lat_rad)) / _math.pi) / 2.0 * n_val return xtile, ytile def xy_to_lonlat(xtile_v, ytile_v, z_val): n_val = 2.0 ** z_val lon_val = xtile_v / n_val * 360.0 - 180.0 lat_rad = _math.atan(_math.sinh(_math.pi * (1 - 2 * ytile_v / n_val))) lat_val = _math.degrees(lat_rad) return lon_val, lat_val lon0, lat0 = center_ll xtile0, ytile0 = lonlat_to_xy(lon0, lat0, z_level) px_per_tile = 256 dx = px - img_w / 2 dy = py - img_h / 2 dxtile = dx / px_per_tile dytile = dy / px_per_tile xtile = xtile0 + dxtile ytile = ytile0 + dytile lon_val, lat_val = xy_to_lonlat(xtile, ytile, z_level) return lon_val, lat_val # Helper inverse: lon/lat to snapshot pixel def snapshot_lonlat_to_pixel(lon_val_in, lat_val_in, center_ll, z_level, img_w, img_h): import math as _math def lonlat_to_xy(lon_inner, lat_inner, z_val): lat_rad = _math.radians(lat_inner) n_val = 2.0 ** z_val xtile = (lon_inner + 180.0) / 360.0 * n_val ytile = (1.0 - _math.log(_math.tan(lat_rad) + 1 / _math.cos(lat_rad)) / _math.pi) / 2.0 * n_val return xtile, ytile lon0, lat0 = center_ll xtile0, ytile0 = lonlat_to_xy(lon0, lat0, z_level) xtile, ytile = lonlat_to_xy(lon_val_in, lat_val_in, z_level) dxtile = xtile - xtile0 dytile = ytile - ytile0 px_per_tile = 256 snapshot_px = img_w / 2 + dxtile * px_per_tile snapshot_py = img_h / 2 + dytile * px_per_tile return snapshot_px, snapshot_py # Helper functions for saving and loading app state def save_app_state(): """Save the current app state to a JSON file""" state_to_save = { 'timestamp': datetime.now().isoformat(), 'map_locked': st.session_state.get('map_locked', False), 'map_center': st.session_state.get('map_center', []), 'map_zoom': st.session_state.get('map_zoom', 17.5), 'current_base': st.session_state.get('current_base', 'OpenStreetMap'), 'processes': st.session_state.get('processes', []), 'proc_groups': st.session_state.get('proc_groups', []), 'proc_group_names': st.session_state.get('proc_group_names', []), 'proc_group_expanded': st.session_state.get('proc_group_expanded', []), 'proc_group_coordinates': st.session_state.get('proc_group_coordinates', {}), 'proc_group_info_expanded': st.session_state.get('proc_group_info_expanded', []), # Save notes 'project_notes': st.session_state.get('project_notes', ''), 'pinch_notes': st.session_state.get('pinch_notes', ''), } # Convert snapshots to base64 for JSON serialization snapshots = st.session_state.get('map_snapshots', {}) encoded_snapshots = {} for key, img_bytes in snapshots.items(): if img_bytes: encoded_snapshots[key] = base64.b64encode(img_bytes).decode('utf-8') state_to_save['map_snapshots_encoded'] = encoded_snapshots return json.dumps(state_to_save, indent=2) def load_app_state(state_json): """Load app state from JSON data""" try: state = json.loads(state_json) # Restore session state st.session_state['map_locked'] = state.get('map_locked', False) st.session_state['map_center'] = state.get('map_center', []) st.session_state['map_zoom'] = state.get('map_zoom', 17.5) st.session_state['current_base'] = state.get('current_base', 'OpenStreetMap') st.session_state['processes'] = state.get('processes', []) st.session_state['proc_groups'] = state.get('proc_groups', []) st.session_state['proc_group_names'] = state.get('proc_group_names', []) st.session_state['proc_group_expanded'] = state.get('proc_group_expanded', []) # Initialize map expansion states st.session_state['process_subprocess_map_expanded'] = state.get('process_subprocess_map_expanded', {}) st.session_state['subprocess_map_expanded'] = state.get('subprocess_map_expanded', {}) # Convert proc_group_coordinates keys to integers (JSON converts them to strings) raw_coords = state.get('proc_group_coordinates', {}) converted_coords = {} for key, value in raw_coords.items(): converted_coords[int(key) if isinstance(key, str) else key] = value st.session_state['proc_group_coordinates'] = converted_coords st.session_state['proc_group_info_expanded'] = state.get('proc_group_info_expanded', []) # Restore notes st.session_state['project_notes'] = state.get('project_notes', '') st.session_state['pinch_notes'] = state.get('pinch_notes', '') # Restore snapshots from base64 encoded_snapshots = state.get('map_snapshots_encoded', {}) decoded_snapshots = {} for key, encoded_data in encoded_snapshots.items(): decoded_snapshots[key] = base64.b64decode(encoded_data) st.session_state['map_snapshots'] = decoded_snapshots # Set the main snapshot - use current_base to get the right one current_base = state.get('current_base', 'OpenStreetMap') if decoded_snapshots: st.session_state['map_snapshot'] = decoded_snapshots.get(current_base) or decoded_snapshots.get('OpenStreetMap') # Also set analyze_base_layer to match st.session_state['analyze_base_layer'] = current_base # Update selector state to match loaded map st.session_state['selector_center'] = state.get('map_center', [])[:] st.session_state['selector_zoom'] = state.get('map_zoom', 17.5) # Reset UI interaction states to prevent freeze st.session_state['placement_mode'] = False st.session_state['placing_process_idx'] = None st.session_state['measure_mode'] = False st.session_state['measure_points'] = [] st.session_state['measure_distance_m'] = None st.session_state['ui_status_msg'] = "State loaded successfully" # Initialize proc_expanded if processes exist if 'proc_expanded' not in st.session_state or len(st.session_state['proc_expanded']) != len(state.get('processes', [])): st.session_state['proc_expanded'] = [False] * len(state.get('processes', [])) # Switch to Analyze mode if map was locked if state.get('map_locked', False): st.session_state['ui_mode_radio'] = 'Analyze' else: st.session_state['ui_mode_radio'] = 'Select Map' return True, f"State loaded successfully from {state.get('timestamp', 'unknown time')}" except Exception as e: return False, f"Error loading state: {str(e)}" def export_to_csv(): """Export all process data to CSV format with one row per stream""" from io import StringIO output = StringIO() writer = csv.writer(output) # Write header writer.writerow([ 'Process', 'Subprocess', 'Process Latitude', 'Process Longitude', 'Process Hours', 'Next Connection', 'Stream', 'Stream Type', 'Stream Tin (°C)', 'Stream Tout (°C)', 'Stream mdot', 'Stream cp', 'Stream CP', 'Stream Water Content In', 'Stream Water Content Out', 'Stream Density', 'Stream Pressure', 'Product Tin', 'Product Tout', 'Product mdot', 'Product cp', 'Air Tin', 'Air Tout', 'Air mdot', 'Air cp', 'Water Content In', 'Water Content Out', 'Density', 'Pressure', 'Notes' ]) # Get process group information proc_groups = st.session_state.get('proc_groups', []) proc_group_names = st.session_state.get('proc_group_names', []) proc_group_coordinates = st.session_state.get('proc_group_coordinates', {}) # Create mapping of subprocess index to group subprocess_to_group = {} for group_idx, group_subprocess_list in enumerate(proc_groups): for subprocess_idx in group_subprocess_list: subprocess_to_group[subprocess_idx] = group_idx # Iterate through all subprocesses for subprocess_idx, subprocess in enumerate(st.session_state.get('processes', [])): subprocess_name = subprocess.get('name') or f"Subprocess {subprocess_idx+1}" next_connection = subprocess.get('next', '') # Get process (group) name and coordinates parent_group_idx = subprocess_to_group.get(subprocess_idx) if parent_group_idx is not None and parent_group_idx < len(proc_group_names): process_name = proc_group_names[parent_group_idx] # Get process-level coordinates group_coords = proc_group_coordinates.get(parent_group_idx, {}) process_lat = group_coords.get('lat', '') process_lon = group_coords.get('lon', '') process_hours = group_coords.get('hours', '') else: process_name = '' process_lat = '' process_lon = '' process_hours = '' # Get subprocess product information product_tin = subprocess.get('conntemp', '') product_tout = subprocess.get('product_tout', '') product_mdot = subprocess.get('connm', '') product_cp = subprocess.get('conncp', '') # Get subprocess extra information extra_info = subprocess.get('extra_info', {}) air_tin = extra_info.get('air_tin', '') air_tout = extra_info.get('air_tout', '') air_mdot = extra_info.get('air_mdot', '') air_cp = extra_info.get('air_cp', '') water_content_in = extra_info.get('water_content_in', '') water_content_out = extra_info.get('water_content_out', '') density = extra_info.get('density', '') pressure = extra_info.get('pressure', '') notes = extra_info.get('notes', '') # Get streams streams = subprocess.get('streams', []) if streams: # One row per stream for stream_idx, stream in enumerate(streams): stream_name = stream.get('name', f"Stream {stream_idx + 1}") stream_type = stream.get('type', 'product') # Extract values from stream_values dict (new structure) stream_values = stream.get('stream_values', {}) stream_tin = stream_values.get('Tin', '') stream_tout = stream_values.get('Tout', '') stream_mdot = stream_values.get('ṁ', '') stream_cp = stream_values.get('cp', '') stream_CP = stream_values.get('CP', '') stream_water_in = stream_values.get('Water Content In', '') stream_water_out = stream_values.get('Water Content Out', '') stream_density = stream_values.get('Density', '') stream_pressure = stream_values.get('Pressure', '') # Fallback to legacy fields if stream_values is empty if not stream_values: stream_tin = stream.get('temp_in', '') stream_tout = stream.get('temp_out', '') stream_mdot = stream.get('mdot', '') stream_cp = stream.get('cp', '') writer.writerow([ process_name, subprocess_name, process_lat, process_lon, process_hours, next_connection, stream_name, stream_type, stream_tin, stream_tout, stream_mdot, stream_cp, stream_CP, stream_water_in, stream_water_out, stream_density, stream_pressure, product_tin, product_tout, product_mdot, product_cp, air_tin, air_tout, air_mdot, air_cp, water_content_in, water_content_out, density, pressure, notes ]) else: # If no streams, still write subprocess info writer.writerow([ process_name, subprocess_name, process_lat, process_lon, process_hours, next_connection, '', # No stream '', # No stream type '', # No Stream Tin '', # No Stream Tout '', # No Stream mdot '', # No Stream cp '', # No Stream CP '', # No Stream Water Content In '', # No Stream Water Content Out '', # No Stream Density '', # No Stream Pressure product_tin, product_tout, product_mdot, product_cp, air_tin, air_tout, air_mdot, air_cp, water_content_in, water_content_out, density, pressure, notes ]) return output.getvalue() st.set_page_config( page_title="Energy Data Collection", layout="wide", initial_sidebar_state="collapsed" ) # Apply styles immediately to prevent flash st.markdown( """ """, unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) st.markdown("""

Energy Data Collection

""", unsafe_allow_html=True) MAP_WIDTH = 1200 # Reduced width for better responsiveness (was 1300) MAP_HEIGHT = 650 # Slightly reduced height too # Tile templates for snapshot capture (static) TILE_TEMPLATES = { 'OpenStreetMap': 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'Positron': 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', 'Satellite': 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' } # Default start location (Universität Paderborn, Warburger Str. 100, 33098 Paderborn) DEFAULT_START_ADDRESS = "Universität Paderborn, Warburger Str. 100, 33098 Paderborn" # Approximate coordinates (lat, lon) DEFAULT_START_COORDS = [51.70814085564164, 8.772155163087213] # Session state for map lock and snapshot if 'map_locked' not in st.session_state: st.session_state['map_locked'] = False if 'map_snapshot' not in st.session_state: st.session_state['map_snapshot'] = None if 'map_snapshots' not in st.session_state: st.session_state['map_snapshots'] = {} if 'map_center' not in st.session_state: st.session_state['map_center'] = DEFAULT_START_COORDS[:] # committed (locked) center for start address if 'map_zoom' not in st.session_state: st.session_state['map_zoom'] = 17.5 # committed (locked) zoom if 'selector_center' not in st.session_state: st.session_state['selector_center'] = st.session_state['map_center'][:] if 'selector_zoom' not in st.session_state: st.session_state['selector_zoom'] = st.session_state['map_zoom'] # Base layer preference if 'map_base_choice' not in st.session_state: st.session_state['map_base_choice'] = 'OpenStreetMap' if 'analyze_base_choice' not in st.session_state: st.session_state['analyze_base_choice'] = 'OpenStreetMap' # Initialize address input explicitly (prevents KeyError after widget key changes) if 'address_input' not in st.session_state: st.session_state['address_input'] = '' # Toggle to show inline load uploader when `st.modal` is not available if 'show_load_modal' not in st.session_state: st.session_state['show_load_modal'] = False # State load tracking if 'state_just_loaded' not in st.session_state: st.session_state['state_just_loaded'] = False # Clean orphaned internal widget state keys left from layout changes (optional safeguard) for _k in list(st.session_state.keys()): if isinstance(_k, str) and _k.startswith('$$WIDGET_ID-'): try: del st.session_state[_k] except KeyError: pass # Track mode separately (avoid writing to widget key after creation) if 'ui_mode_radio' not in st.session_state: st.session_state['ui_mode_radio'] = 'Select Map' if 'measure_mode' not in st.session_state: st.session_state['measure_mode'] = False if 'measure_points' not in st.session_state: st.session_state['measure_points'] = [] init_process_state(st.session_state) if 'placing_process_idx' not in st.session_state: st.session_state['placing_process_idx'] = None if 'placement_mode' not in st.session_state: st.session_state['placement_mode'] = False if 'ui_status_msg' not in st.session_state: st.session_state['ui_status_msg'] = None if 'analyze_base_layer' not in st.session_state: st.session_state['analyze_base_layer'] = 'OpenStreetMap' # Group coordinates storage if 'proc_group_coordinates' not in st.session_state: st.session_state['proc_group_coordinates'] = {} # Map expansion states for subprocess views if 'process_subprocess_map_expanded' not in st.session_state: st.session_state['process_subprocess_map_expanded'] = {} if 'subprocess_map_expanded' not in st.session_state: st.session_state['subprocess_map_expanded'] = {} # Unified persistent base layer selection (only changed by user interaction) if 'current_base' not in st.session_state: st.session_state['current_base'] = 'OpenStreetMap' left, right = st.columns([2, 6], gap="small") # Much smaller left panel, much wider map area with left: # Compact mode buttons side by side mode_current = st.session_state['ui_mode_radio'] if mode_current == "Select Map": col_lock = st.columns([1])[0] if col_lock.button("Lock map and analyze", key="btn_lock_analyze"): # Use current live map state instead of stored selector values current_center = st.session_state.get('current_map_center') current_zoom = st.session_state.get('current_map_zoom') # Fallback to selector values if current state not available if current_center is None or current_zoom is None: new_center = st.session_state['selector_center'][:] new_zoom = st.session_state['selector_zoom'] else: new_center = current_center[:] new_zoom = current_zoom selected_base_now = st.session_state.get('current_base', 'OpenStreetMap') existing_snaps = st.session_state.get('map_snapshots', {}) regenerate = ( (st.session_state.get('map_snapshot') is None) or (new_center != st.session_state.get('map_center')) or (new_zoom != st.session_state.get('map_zoom')) or (selected_base_now not in existing_snaps) # ensure chosen base available ) st.session_state['map_center'] = new_center st.session_state['map_zoom'] = new_zoom # Store exact zoom for coordinate calculations if regenerate: try: snapshots = {} # Use rounded zoom for tile rendering but keep exact zoom for coordinates render_zoom = round(float(new_zoom)) for layer_name, template in TILE_TEMPLATES.items(): smap = StaticMap(MAP_WIDTH, MAP_HEIGHT, url_template=template) # Use rounded zoom instead of truncated to better match Folium view img_layer = smap.render(zoom=render_zoom, center=(new_center[1], new_center[0])) if img_layer is None: st.error(f"Failed to render {layer_name} map layer") continue buf_l = BytesIO() img_layer.save(buf_l, format='PNG') snapshot_data = buf_l.getvalue() if len(snapshot_data) == 0: st.error(f"Empty image data for {layer_name}") continue snapshots[layer_name] = snapshot_data st.success(f"Successfully captured {layer_name} ({len(snapshot_data)} bytes)") st.session_state['map_snapshots'] = snapshots # Legacy single snapshot retains OSM for backward compatibility st.session_state['map_snapshot'] = snapshots.get('OpenStreetMap') except RuntimeError as gen_err: st.session_state['ui_status_msg'] = f"Capture failed: {gen_err}" st.error(f"Map capture error: {gen_err}") st.rerun() except Exception as e: st.session_state['ui_status_msg'] = f"Unexpected error: {e}" st.error(f"Unexpected map error: {e}") st.rerun() st.session_state['map_locked'] = True # Freeze analyze base only if user hasn't previously switched in Analyze; preserve separate map selection st.session_state['analyze_base_layer'] = selected_base_now # Keep current_base unchanged; analyze view will use it directly # Do NOT modify base_layer_choice_map here; that belongs to Select Map context st.session_state['ui_mode_radio'] = 'Analyze' if not st.session_state.get('ui_status_msg'): st.session_state['ui_status_msg'] = "Snapshot captured" st.rerun() else: # Analyze mode col_unlock, col_add = st.columns([1,1]) if col_unlock.button("Unlock map and select", key="btn_unlock_select"): # Preserve current locked map position for seamless transition if st.session_state.get('map_center') and st.session_state.get('map_zoom'): st.session_state['selector_center'] = st.session_state['map_center'][:] st.session_state['selector_zoom'] = st.session_state['map_zoom'] st.session_state['current_map_center'] = st.session_state['map_center'][:] st.session_state['current_map_zoom'] = st.session_state['map_zoom'] st.session_state.update({ 'ui_mode_radio': 'Select Map', 'map_locked': False, 'map_snapshot': None, 'measure_mode': False, 'measure_points': [], 'measure_distance_m': None, 'placement_mode': False, 'placing_process_idx': None, 'ui_status_msg': None, }) st.rerun() if col_add.button("Add a process", key="btn_add_group_top"): # Ensure processes list exists if 'proc_groups' not in st.session_state: st.session_state['proc_groups'] = [] st.session_state['proc_groups'].append([]) # new empty process # Sync process names & expansion if 'proc_group_names' not in st.session_state: st.session_state['proc_group_names'] = [] st.session_state['proc_group_names'].append(f"Process {len(st.session_state['proc_groups'])}") if 'proc_group_expanded' not in st.session_state: st.session_state['proc_group_expanded'] = [] st.session_state['proc_group_expanded'].append(False) # Start collapsed by default if 'proc_group_info_expanded' not in st.session_state: st.session_state['proc_group_info_expanded'] = [] st.session_state['proc_group_info_expanded'].append(False) # Start collapsed by default st.session_state['ui_status_msg'] = "Added new empty process" st.rerun() st.markdown("---") # File uploader - only show when map is NOT locked (in Select Map mode) if not st.session_state.get('map_locked', False): if 'state_just_loaded' in st.session_state and st.session_state['state_just_loaded']: if st.button("Load Another", key="btn_load_another_left", help="Load another file"): st.session_state['state_just_loaded'] = False st.rerun() else: st.markdown("**Load saved state:**") # File uploader for loading saved state uploaded_file = st.file_uploader("Select JSON file", type=['json'], key="file_upload_left", label_visibility="collapsed") if uploaded_file is not None: try: file_contents = uploaded_file.getvalue().decode('utf-8') success, message = load_app_state(file_contents) if success: st.session_state['state_just_loaded'] = True st.success("State loaded successfully. Refreshing view...") st.rerun() else: st.error(message) except Exception as e: st.error(f"Error loading file: {str(e)}") st.markdown("---") mode = st.session_state['ui_mode_radio'] # Subprocess & Stream UI (only show in Analyze mode to mimic original workflow) if mode == "Analyze": # Show editor if we have any processes OR any groups (even empty groups) has_groups = bool(st.session_state.get('proc_groups')) if st.session_state['processes'] or has_groups: # Initialize expanded state tracker if 'proc_expanded' not in st.session_state: st.session_state['proc_expanded'] = [] # Preserve existing flags; extend or truncate only as needed if len(st.session_state['proc_expanded']) < len(st.session_state['processes']): st.session_state['proc_expanded'].extend([False]*(len(st.session_state['processes']) - len(st.session_state['proc_expanded']))) elif len(st.session_state['proc_expanded']) > len(st.session_state['processes']): st.session_state['proc_expanded'] = st.session_state['proc_expanded'][:len(st.session_state['processes'])] # Track pending delete index if 'proc_delete_pending' not in st.session_state: st.session_state['proc_delete_pending'] = None # Initialize explicit groups if not present if 'proc_groups' not in st.session_state: st.session_state['proc_groups'] = [list(range(len(st.session_state['processes'])))] if st.session_state['processes'] else [] # Ensure proc_expanded length matches processes if len(st.session_state['proc_expanded']) != len(st.session_state['processes']): st.session_state['proc_expanded'] = [False]*len(st.session_state['processes']) # Align names & expanded arrays to group list length group_count = len(st.session_state['proc_groups']) if 'proc_group_expanded' not in st.session_state: st.session_state['proc_group_expanded'] = [False]*group_count # Start collapsed by default elif len(st.session_state['proc_group_expanded']) != group_count: st.session_state['proc_group_expanded'] = [st.session_state['proc_group_expanded'][g] if g < len(st.session_state['proc_group_expanded']) else False for g in range(group_count)] if 'proc_group_names' not in st.session_state: st.session_state['proc_group_names'] = [f"Group {g+1}" for g in range(group_count)] elif len(st.session_state['proc_group_names']) != group_count: current_names = st.session_state['proc_group_names'] if len(current_names) < group_count: current_names += [f"Group {g+1}" for g in range(len(current_names), group_count)] else: current_names = current_names[:group_count] st.session_state['proc_group_names'] = current_names # Initialize information expanded state if 'proc_group_info_expanded' not in st.session_state: st.session_state['proc_group_info_expanded'] = [False]*group_count # Start collapsed by default elif len(st.session_state['proc_group_info_expanded']) != group_count: st.session_state['proc_group_info_expanded'] = [st.session_state['proc_group_info_expanded'][g] if g < len(st.session_state['proc_group_info_expanded']) else False for g in range(group_count)] # Track group delete confirmation if 'group_delete_pending' not in st.session_state: st.session_state['group_delete_pending'] = None def _reindex_groups_after_delete(del_idx: int): groups = st.session_state.get('proc_groups', []) new_groups = [] for grp in groups: updated = [] for proc_index in grp: if proc_index == del_idx: continue updated.append(proc_index - 1 if proc_index > del_idx else proc_index) new_groups.append(updated) st.session_state['proc_groups'] = new_groups for g, g_list in enumerate(st.session_state['proc_groups']): # Initialize all group-specific session state variables BEFORE any UI elements # Process Model initialization for groups if 'proc_group_model' not in st.session_state: st.session_state['proc_group_model'] = {} if g not in st.session_state['proc_group_model']: st.session_state['proc_group_model'][g] = {'level1': None, 'level2': None} # Top thick separator for group st.markdown("
", unsafe_allow_html=True) # Arrow | Name | Place | Count | Delete # Add a compact size control next to the group name (before Place) gh_cols = st.columns([0.05, 0.45, 0.18, 0.15, 0.12, 0.05]) g_toggle_label = "▾" if st.session_state['proc_group_expanded'][g] else "▸" if gh_cols[0].button(g_toggle_label, key=f"group_toggle_{g}"): new_state = not st.session_state['proc_group_expanded'][g] st.session_state['proc_group_expanded'][g] = new_state # If collapsing, also collapse the subprocess map view and all sub-subprocess map views if not new_state: st.session_state['process_subprocess_map_expanded'][g] = False # Also collapse all sub-subprocess map views for subprocesses in this group for subprocess_idx in g_list: st.session_state['subprocess_map_expanded'][subprocess_idx] = False st.rerun() default_name = st.session_state['proc_group_names'][g] new_name = gh_cols[1].text_input("Group name", value=default_name, key=f"group_name_{g}", label_visibility="collapsed", placeholder=f"Group {g+1}") st.session_state['proc_group_names'][g] = new_name.strip() or default_name # Ensure proc_group_coordinates entry exists if 'proc_group_coordinates' not in st.session_state: st.session_state['proc_group_coordinates'] = {} if g not in st.session_state['proc_group_coordinates']: st.session_state['proc_group_coordinates'][g] = {'lat': '', 'lon': '', 'hours': '', 'box_scale': 1.5} # Compact size slider for main process box placed next to name try: current_group_scale = float(st.session_state['proc_group_coordinates'][g].get('box_scale', 1.5) or 1.5) except (ValueError, TypeError): current_group_scale = 1.5 new_group_scale = gh_cols[2].slider( "Size", min_value=0.5, max_value=3.0, value=current_group_scale, step=0.1, key=f"group_box_scale_{g}", label_visibility="collapsed" ) st.session_state['proc_group_coordinates'][g]['box_scale'] = float(new_group_scale) # Place button for the group/process group_place_active = (st.session_state['placement_mode'] and st.session_state.get('placing_process_idx') == f"group_{g}") if not group_place_active: if gh_cols[3].button("Place", key=f"place_group_{g}"): st.session_state['placement_mode'] = True st.session_state['measure_mode'] = False st.session_state['placing_process_idx'] = f"group_{g}" group_name = st.session_state['proc_group_names'][g] st.session_state['ui_status_msg'] = f"Click on map to place: {group_name}" st.rerun() else: if gh_cols[3].button("Done", key=f"done_place_group_{g}"): st.session_state['placement_mode'] = False st.session_state['placing_process_idx'] = None st.session_state['ui_status_msg'] = "Placement mode disabled" st.rerun() gh_cols[4].markdown(f"**{len(g_list)}**") pending_group = st.session_state.get('group_delete_pending') with gh_cols[5]: if pending_group == g: st.write("Sure?") if st.button("✅", key=f"confirm_del_group_{g}"): # Delete all processes in group (highest index first) for pi in sorted(g_list, reverse=True): delete_process(st.session_state, pi) _reindex_groups_after_delete(pi) # Remove group metadata st.session_state['proc_groups'].pop(g) st.session_state['proc_group_names'].pop(g) st.session_state['proc_group_expanded'].pop(g) st.session_state['proc_group_info_expanded'].pop(g) st.session_state['group_delete_pending'] = None st.session_state['ui_status_msg'] = "Group deleted" st.rerun() if st.button("❌", key=f"cancel_del_group_{g}"): st.session_state['group_delete_pending'] = None else: if st.button("✕", key=f"del_group_{g}"): st.session_state['group_delete_pending'] = g st.rerun() if not st.session_state['proc_group_expanded'][g]: # Bottom separator for collapsed group st.markdown("
", unsafe_allow_html=True) continue # Add collapsible information section for the main process (group level) info_header_cols = st.columns([0.05, 0.95]) info_toggle_label = "▾" if st.session_state['proc_group_info_expanded'][g] else "▸" if info_header_cols[0].button(info_toggle_label, key=f"info_toggle_{g}"): st.session_state['proc_group_info_expanded'][g] = not st.session_state['proc_group_info_expanded'][g] st.rerun() info_header_cols[1].markdown("**Information**") if st.session_state['proc_group_info_expanded'][g]: info_row1_cols = st.columns([1, 1, 1]) # Initialize process coordinates and data if not exists if 'proc_group_coordinates' not in st.session_state: st.session_state['proc_group_coordinates'] = {} if g not in st.session_state['proc_group_coordinates']: st.session_state['proc_group_coordinates'][g] = {'lat': '', 'lon': '', 'hours': ''} current_coords = st.session_state['proc_group_coordinates'][g] new_lat = info_row1_cols[0].text_input("Latitude", value=str(current_coords.get('lat', '') or ''), key=f"group_lat_{g}") new_lon = info_row1_cols[1].text_input("Longitude", value=str(current_coords.get('lon', '') or ''), key=f"group_lon_{g}") new_hours = info_row1_cols[2].text_input("Hours", value=str(current_coords.get('hours', '') or ''), key=f"group_hours_{g}") # Update coordinates and hours in session state st.session_state['proc_group_coordinates'][g]['lat'] = new_lat if new_lat.strip() else '' st.session_state['proc_group_coordinates'][g]['lon'] = new_lon if new_lon.strip() else '' st.session_state['proc_group_coordinates'][g]['hours'] = new_hours if new_hours.strip() else '' # (Process size control moved to group header for compactness) # Process Model button and dialog model_btn_col, model_display_col = st.columns([0.3, 0.7]) if model_btn_col.button("Select Process Model", key=f"open_model_dialog_{g}"): # Use dialog to show process model selector @st.dialog("Process Model Selection") def show_process_model_dialog(): st.markdown("### Select process category and type") # Level 1: Main category level1_options = ["Select category..."] + list(PROCESS_MODEL_DICT.keys()) current_level1 = st.session_state['proc_group_model'][g].get('level1') level1_index = level1_options.index(current_level1) if current_level1 in level1_options else 0 selected_level1 = st.selectbox( "Category", options=level1_options, index=level1_index, key=f"dialog_model_level1_{g}" ) if selected_level1 != "Select category...": st.session_state['proc_group_model'][g]['level1'] = selected_level1 # Level 2: Subcategory level2_options = ["Select type..."] + list(PROCESS_MODEL_DICT[selected_level1].keys()) current_level2 = st.session_state['proc_group_model'][g].get('level2') if current_level2 and current_level2 not in level2_options: st.session_state['proc_group_model'][g]['level2'] = None current_level2 = None level2_index = level2_options.index(current_level2) if current_level2 in level2_options else 0 selected_level2 = st.selectbox( "Type", options=level2_options, index=level2_index, key=f"dialog_model_level2_{g}" ) if selected_level2 != "Select type...": st.session_state['proc_group_model'][g]['level2'] = selected_level2 else: st.session_state['proc_group_model'][g]['level2'] = None else: st.session_state['proc_group_model'][g]['level1'] = None st.session_state['proc_group_model'][g]['level2'] = None # Initialize parameter request state if 'proc_group_params_requested' not in st.session_state: st.session_state['proc_group_params_requested'] = {} if g not in st.session_state['proc_group_params_requested']: st.session_state['proc_group_params_requested'][g] = False if st.button("Request parameters") or st.session_state['proc_group_params_requested'][g]: st.session_state['proc_group_params_requested'][g] = True st.markdown("---") st.markdown("### Process Parameters") # Initialize parameters storage if 'proc_group_params' not in st.session_state: st.session_state['proc_group_params'] = {} if g not in st.session_state['proc_group_params']: st.session_state['proc_group_params'][g] = { 'tin': '', 'tout': '', 'time': '', 'cp': '', 'mass_flow': None, 'thermal_power': None } params = st.session_state['proc_group_params'][g] param_cols = st.columns([1, 1, 1, 1]) params['tin'] = param_cols[0].text_input("Tin (°C)", value=params.get('tin', ''), key=f"param_tin_g_{g}") params['tout'] = param_cols[1].text_input("Tout (°C)", value=params.get('tout', ''), key=f"param_tout_g_{g}") params['time'] = param_cols[2].text_input("Time (h)", value=params.get('time', ''), key=f"param_time_g_{g}") params['cp'] = param_cols[3].text_input("cp (kJ/kg·K)", value=params.get('cp', ''), key=f"param_cp_g_{g}") if st.button("Calculate energy demand", key=f"calc_g_{g}"): try: tin = float(params['tin']) tout = float(params['tout']) time = float(params['time']) cp = float(params['cp']) # Example calculation: Q = m * cp * ΔT # Assuming mass flow (m) = 1 kg/s for demonstration delta_t = abs(tout - tin) mass_flow = 1.0 # kg/s (placeholder) thermal_power = mass_flow * cp * delta_t # kW params['mass_flow'] = mass_flow params['thermal_power'] = thermal_power except (ValueError, TypeError): st.error("Please enter valid numeric values for all parameters") # Display results if calculated if params.get('mass_flow') is not None and params.get('thermal_power') is not None: st.success(f"**Mass Flow:** {params['mass_flow']:.2f} kg/s") st.success(f"**Thermal Power:** {params['thermal_power']:.2f} kW") if st.button("Import energy values", key=f"done_g_{g}"): st.rerun() show_process_model_dialog() # Display current selection current_model = st.session_state['proc_group_model'][g] if current_model.get('level2'): model_display_col.caption(f"Model: {current_model['level1']} → {current_model['level2']}") elif current_model.get('level1'): model_display_col.caption(f"Model: {current_model['level1']}") else: model_display_col.caption("No model selected") # Next Processes dropdown for the group if 'proc_group_next' not in st.session_state: st.session_state['proc_group_next'] = {} if g not in st.session_state['proc_group_next']: st.session_state['proc_group_next'][g] = '' # Build option list of other process group names all_group_names = st.session_state.get('proc_group_names', []) if len(all_group_names) <= 1: st.caption("To connect processes, add more than one process group") st.session_state['proc_group_next'][g] = '' else: # Create options excluding current group options = [name for idx, name in enumerate(all_group_names) if idx != g] # Current selection current_next = st.session_state['proc_group_next'][g] default_selection = [current_next] if current_next and current_next in options else [] selected = st.multiselect("Next Processes", options=options, default=default_selection, key=f"group_next_multi_{g}") # Store as comma-separated names st.session_state['proc_group_next'][g] = ", ".join(selected) if selected else '' # Initialize map_expanded state for this process group (controls subprocess overlay on map) if 'process_subprocess_map_expanded' not in st.session_state: st.session_state['process_subprocess_map_expanded'] = {} if g not in st.session_state['process_subprocess_map_expanded']: st.session_state['process_subprocess_map_expanded'][g] = False # Subprocesses section with Map toggle + Count + Add subprocess button subprocess_count = len(g_list) subprocess_map_exp = st.session_state['process_subprocess_map_expanded'].get(g, False) sub_header_cols = st.columns([0.30, 0.22, 0.08, 0.20, 0.20]) sub_header_cols[0].markdown("**Subprocesses:**") # Map toggle button subprocess_map_toggle_label = "🔽 Hide" if subprocess_map_exp else "🗺️ Show on Map" if sub_header_cols[1].button(subprocess_map_toggle_label, key=f"map_toggle_subprocess_{g}", help="Show/hide subprocesses on map"): st.session_state['process_subprocess_map_expanded'][g] = not subprocess_map_exp st.rerun() # Count sub_header_cols[2].markdown(f"**{subprocess_count}**") # Status indicator if subprocess_map_exp: sub_header_cols[3].caption("🗺️ On map") # Add button if sub_header_cols[4].button("➕ Add", key=f"add_proc_group_{g}", help="Add subprocess"): add_process(st.session_state) new_idx = len(st.session_state['processes']) - 1 g_list.append(new_idx) # Ensure proc_expanded has entry and keep it collapsed if len(st.session_state['proc_expanded']) <= new_idx: st.session_state['proc_expanded'].append(False) else: st.session_state['proc_expanded'][new_idx] = False st.session_state['ui_status_msg'] = f"Added subprocess to {st.session_state['proc_group_names'][g]}" st.rerun() for local_idx, i in enumerate(g_list): p = st.session_state['processes'][i] # Initialize all subprocess-specific session state variables BEFORE any UI elements # Process Model initialization if 'proc_model' not in st.session_state: st.session_state['proc_model'] = {} if i not in st.session_state['proc_model']: st.session_state['proc_model'][i] = {'level1': None, 'level2': None} # Process Model parameters initialization for subprocesses if 'proc_params' not in st.session_state: st.session_state['proc_params'] = {} if i not in st.session_state['proc_params']: st.session_state['proc_params'][i] = { 'tin': '', 'tout': '', 'time': '', 'cp': '', 'mass_flow': None, 'thermal_power': None } if 'proc_params_requested' not in st.session_state: st.session_state['proc_params_requested'] = {} if i not in st.session_state['proc_params_requested']: st.session_state['proc_params_requested'][i] = False # Per-subprocess header (toggle | name | size | place | delete) header_cols = st.columns([0.06, 0.54, 0.14, 0.16, 0.10]) toggle_label = "▾" if st.session_state['proc_expanded'][i] else "▸" if header_cols[0].button(toggle_label, key=f"proc_toggle_{i}"): new_state = not st.session_state['proc_expanded'][i] st.session_state['proc_expanded'][i] = new_state # If collapsing, also collapse the sub-subprocess map view if not new_state: st.session_state['subprocess_map_expanded'][i] = False st.rerun() # Default auto-name if empty if not p.get('name'): p['name'] = f"Subprocess {i+1}" p['name'] = header_cols[1].text_input( "Subprocess name", value=p.get('name',''), key=f"p_name_{i}", label_visibility="collapsed", placeholder=f"Subprocess {i+1}" ) # Size slider (scale factor for box rendering) if 'box_scale' not in p or p.get('box_scale') in (None, ''): p['box_scale'] = 1.0 p['box_scale'] = header_cols[2].slider( "Size", min_value=0.5, max_value=3.0, value=float(p.get('box_scale',1.0)), step=0.1, key=f"p_box_scale_{i}", label_visibility="collapsed" ) place_active = (st.session_state['placement_mode'] and st.session_state.get('placing_process_idx') == i) if not place_active: if header_cols[3].button("Place", key=f"place_{i}"): st.session_state['placement_mode'] = True st.session_state['measure_mode'] = False st.session_state['placing_process_idx'] = i st.session_state['ui_status_msg'] = f"Click on map to place: {p.get('name') or f'Subprocess {i+1}'}" st.rerun() else: if header_cols[3].button("Done", key=f"done_place_{i}"): st.session_state['placement_mode'] = False st.session_state['placing_process_idx'] = None st.session_state['ui_status_msg'] = "Placement mode disabled" st.rerun() pending = st.session_state.get('proc_delete_pending') if pending == i: with header_cols[4]: st.write("Sure?") if st.button("✅", key=f"confirm_del_{i}"): delete_process(st.session_state, i) _reindex_groups_after_delete(i) st.session_state['proc_delete_pending'] = None st.rerun() if st.button("❌", key=f"cancel_del_{i}"): st.session_state['proc_delete_pending'] = None else: if header_cols[4].button("✕", key=f"del_proc_{i}"): st.session_state['proc_delete_pending'] = i st.rerun() if st.session_state['proc_expanded'][i]: # Divider: Relationships Section st.markdown("
", unsafe_allow_html=True) st.markdown("**Relationships**") # Multi-select for next processes (exclude self) all_procs = st.session_state['processes'] if len(all_procs) <= 1: st.caption("To connect processes, add more than one") p['next'] = '' else: # Build option list of other subprocess names options = [] for j, pj in enumerate(all_procs): if j == i: continue nm = pj.get('name') or f"Subprocess {j+1}" options.append(nm) # Current selections parsed from stored string current_tokens = [t.strip() for t in (p.get('next','') or '').replace(';',',').replace('|',',').split(',') if t.strip()] # Keep only those present in options preselect = [t for t in current_tokens if t in options] selected = st.multiselect("Next processes", options=options, default=preselect, key=f"p_next_multi_{i}") # Store as comma-separated names p['next'] = ", ".join(selected) # Initialize extra_info dict if not exists if 'extra_info' not in p: p['extra_info'] = {} # Process Model button and dialog for subprocess (in same area as Relationships) model_btn_col, model_display_col = st.columns([0.3, 0.7]) if model_btn_col.button("Select Process Model", key=f"open_model_dialog_sub_{i}"): # Use dialog to show process model selector @st.dialog("Process Model Selection") def show_subprocess_model_dialog(): st.markdown("### Select process category and type") # Level 1: Main category level1_options = ["Select category..."] + list(PROCESS_MODEL_DICT.keys()) current_level1 = st.session_state['proc_model'][i].get('level1') level1_index = level1_options.index(current_level1) if current_level1 in level1_options else 0 selected_level1 = st.selectbox( "Category", options=level1_options, index=level1_index, key=f"dialog_sub_model_level1_{i}" ) if selected_level1 != "Select category...": st.session_state['proc_model'][i]['level1'] = selected_level1 # Level 2: Subcategory level2_options = ["Select type..."] + list(PROCESS_MODEL_DICT[selected_level1].keys()) current_level2 = st.session_state['proc_model'][i].get('level2') if current_level2 and current_level2 not in level2_options: st.session_state['proc_model'][i]['level2'] = None current_level2 = None level2_index = level2_options.index(current_level2) if current_level2 in level2_options else 0 selected_level2 = st.selectbox( "Type", options=level2_options, index=level2_index, key=f"dialog_sub_model_level2_{i}" ) if selected_level2 != "Select type...": st.session_state['proc_model'][i]['level2'] = selected_level2 else: st.session_state['proc_model'][i]['level2'] = None else: st.session_state['proc_model'][i]['level1'] = None st.session_state['proc_model'][i]['level2'] = None # Initialize parameter request state if st.button("Request parameters") or st.session_state['proc_params_requested'][i]: st.session_state['proc_params_requested'][i] = True st.markdown("---") st.markdown("### Process Parameters") params = st.session_state['proc_params'][i] param_cols = st.columns([1, 1, 1, 1]) params['tin'] = param_cols[0].text_input("Tin (°C)", value=params.get('tin', ''), key=f"param_tin_sub_{i}") params['tout'] = param_cols[1].text_input("Tout (°C)", value=params.get('tout', ''), key=f"param_tout_sub_{i}") params['time'] = param_cols[2].text_input("Time (h)", value=params.get('time', ''), key=f"param_time_sub_{i}") params['cp'] = param_cols[3].text_input("cp (kJ/kg·K)", value=params.get('cp', ''), key=f"param_cp_sub_{i}") if st.button("Calculate energy demand", key=f"calc_sub_{i}"): try: tin = float(params['tin']) tout = float(params['tout']) time = float(params['time']) cp = float(params['cp']) # Example calculation: Q = m * cp * ΔT # Assuming mass flow (m) = 1 kg/s for demonstration delta_t = abs(tout - tin) mass_flow = 1.0 # kg/s (placeholder) thermal_power = mass_flow * cp * delta_t # kW params['mass_flow'] = mass_flow params['thermal_power'] = thermal_power except (ValueError, TypeError): st.error("Please enter valid numeric values for all parameters") # Display results if calculated if params.get('mass_flow') is not None and params.get('thermal_power') is not None: st.success(f"**Mass Flow:** {params['mass_flow']:.2f} kg/s") st.success(f"**Thermal Power:** {params['thermal_power']:.2f} kW") if st.button("Import energy values", key=f"done_sub_{i}"): st.rerun() show_subprocess_model_dialog() # Display current selection (only if model is selected) current_model = st.session_state['proc_model'][i] if current_model.get('level2'): model_display_col.caption(f"Model: {current_model['level1']} → {current_model['level2']}") elif current_model.get('level1'): model_display_col.caption(f"Model: {current_model['level1']}") # Divider: Streams Section st.markdown("
", unsafe_allow_html=True) # Streams section with persistent add button streams = p.get('streams', []) header_c1, header_c2, header_c3 = st.columns([2,4,1]) header_c1.markdown("**Streams**") if header_c3.button("➕", key=f"btn_add_stream_header_{i}"): add_stream_to_process(st.session_state, i) st.rerun() if not streams: st.caption("No streams yet. Use ➕ to add one.") for si, s in enumerate(streams): # Ensure stream has name and type fields if 'name' not in s: s['name'] = f"Stream {si+1}" if 'type' not in s: s['type'] = "product" # Stream header row: Name | Type | Delete stream_header_cols = st.columns([1.5, 1, 0.5]) s['name'] = stream_header_cols[0].text_input( f"Stream {si+1} name", value=s.get('name', f"Stream {si+1}"), key=f"s_name_{i}_{si}", label_visibility="collapsed", placeholder=f"Stream {si+1}" ) stream_types = ["product", "steam", "air", "water"] current_type = s.get('type', 'product') if current_type not in stream_types: current_type = 'product' s['type'] = stream_header_cols[1].selectbox( "Type", options=stream_types, index=stream_types.index(current_type), key=f"s_type_{i}_{si}", label_visibility="collapsed" ) if stream_header_cols[2].button("✕", key=f"del_stream_{i}_{si}"): delete_stream_from_process(st.session_state, i, si) st.rerun() # All stream types use the same selection logic with different preselected defaults display_options = ["Tin", "Tout", "ṁ", "cp", "CP", "Water Content In", "Water Content Out", "Density", "Pressure"] # Define default preselected variables based on stream type stream_type = s.get('type', 'product') if stream_type == 'product': default_display = ["Tin", "Tout", "ṁ", "cp"] elif stream_type == 'steam': default_display = ["Tin", "ṁ"] elif stream_type == 'water': default_display = ["Tin", "ṁ", "Water Content In", "Water Content Out"] else: # air, etc. default_display = ["Tin", "ṁ"] # Initialize display_vars in session state if not present display_key = f"s_display_vars_{i}_{si}" if display_key not in st.session_state: current_display = s.get('display_vars', None) if current_display is None: current_display = default_display current_display = [v for v in current_display if v in display_options] st.session_state[display_key] = current_display selected_display = st.multiselect( "Variables", options=display_options, key=display_key, help="Select variables to enter" ) s['display_vars'] = selected_display # Build list of properties to show based on selection props_to_show = selected_display n_props = len(props_to_show) if n_props > 0: prop_cols = st.columns(n_props) for pi, prop_name in enumerate(props_to_show): with prop_cols[pi]: st.caption(prop_name) current_val = s.get('stream_values', {}).get(prop_name, '') new_val = st.text_input( prop_name, value=str(current_val), key=f"s_sval_{prop_name}_{i}_{si}", label_visibility="collapsed" ) # Store in stream_values dict if 'stream_values' not in s: s['stream_values'] = {} s['stream_values'][prop_name] = new_val # Update legacy fields for compatibility sv = s.get('stream_values', {}) s['temp_in'] = sv.get('Tin', '') s['temp_out'] = sv.get('Tout', '') s['mdot'] = sv.get('ṁ', '') s['cp'] = sv.get('cp', '') # Also update properties/values structure for drawing s['properties'] = {'prop1': 'Tin'} s['values'] = {'val1': sv.get('Tin', '')} idx = 2 for var in selected_display: s['properties'][f'prop{idx}'] = var s['values'][f'val{idx}'] = sv.get(var, '') idx += 1 # Divider between streams if si < len(streams) - 1: st.markdown("
", unsafe_allow_html=True) # Divider: Notes Section st.markdown("
", unsafe_allow_html=True) st.markdown("**Notes**") # Custom notes field (full width) p['extra_info']['notes'] = st.text_area("Notes", value=p.get('extra_info', {}).get('notes', ''), key=f"p_notes_{i}", height=80, label_visibility="collapsed") # ===================================================== # SUB-SUBPROCESSES SECTION - Recursive children # ===================================================== # Divider: Sub-subprocesses Section st.markdown("
", unsafe_allow_html=True) # Initialize children list if not exists if 'children' not in p: p['children'] = [] # Initialize map_expanded state for this subprocess (controls overlay on map) if 'subprocess_map_expanded' not in st.session_state: st.session_state['subprocess_map_expanded'] = {} if i not in st.session_state['subprocess_map_expanded']: st.session_state['subprocess_map_expanded'][i] = False # Sub-subprocesses header with Map toggle + Count + Add button child_count = len(p.get('children', [])) map_expanded = st.session_state['subprocess_map_expanded'].get(i, False) header_cols = st.columns([0.25, 0.22, 0.08, 0.20, 0.25]) header_cols[0].caption("Sub-subprocesses:") # Map toggle button (smaller) map_toggle_label = "🔽 Hide" if map_expanded else "🗺️ Show on Map" if header_cols[1].button(map_toggle_label, key=f"map_toggle_subsub_{i}", help="Show/hide sub-subprocesses on map"): st.session_state['subprocess_map_expanded'][i] = not map_expanded st.rerun() # Count header_cols[2].caption(f"{child_count}") # Status indicator if map_expanded: header_cols[3].caption("On map") # Add button (smaller) if header_cols[4].button("+ Add", key=f"add_subsub_{i}", help="Add sub-subprocess"): add_child_to_node(p, f"Sub-subprocess {child_count + 1}") st.rerun() # Render sub-subprocesses (final level - no deeper) if p.get('children'): render_subsubprocesses(st, p, i, process_model_dict=PROCESS_MODEL_DICT) if local_idx < len(g_list) - 1: # Thicker separator between subprocesses st.markdown("
", unsafe_allow_html=True) # Bottom separator after expanded group st.markdown("
", unsafe_allow_html=True) else: st.info("No groups yet. Use 'Add a process' to start.") else: # Provide a summary of existing processes while selecting map if st.session_state['processes']: st.caption(f"Processes: {len(st.session_state['processes'])} (edit in Analyze mode)") else: st.caption("Add processes in Analyze mode after locking a map view.") with right: # mode already selected on left if mode == "Select Map": # Address & base selection row addr_col, btn_col, info_col, base_col = st.columns([5,1,2,2]) address = addr_col.text_input("Search address", key="address_input") if btn_col.button("Search", key="search_button") and address: url = f"https://nominatim.openstreetmap.org/search?q={address}&format=json&limit=1" try: resp = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10) resp.raise_for_status() data = resp.json() if data: st.session_state['selector_center'] = [float(data[0]['lat']), float(data[0]['lon'])] st.session_state['selector_zoom'] = 16 # Clear cached maps so they regenerate with new center keys_to_remove = [k for k in st.session_state.keys() if k.startswith('cached_map_')] for key in keys_to_remove: del st.session_state[key] else: st.warning("Address not found.") except requests.exceptions.Timeout: st.error("Address search timed out.") except requests.exceptions.RequestException as req_err: st.error(f"Search failed: {req_err}") info_col.caption("When ready, press 'Lock map and analyze'.") # Base layer selector placed in same top row base_col.markdown("
Base
", unsafe_allow_html=True) base_options = ["OpenStreetMap", "Positron", "Satellite", "Blank"] if st.session_state['current_base'] not in base_options: st.session_state['current_base'] = 'OpenStreetMap' _map_base = base_col.selectbox( label="Base layer", options=base_options, index=base_options.index(st.session_state['current_base']), key='map_base_selector', label_visibility='collapsed' ) if _map_base != st.session_state['current_base']: st.session_state['current_base'] = _map_base st.markdown(""" """, unsafe_allow_html=True) selected_base = st.session_state.get('current_base', 'OpenStreetMap') # Check if we need to recreate the map import hashlib processes_hash = hashlib.md5(str(st.session_state.get('processes', [])).encode()).hexdigest() map_state_hash = f"{selected_base}_{processes_hash}" need_recreate = ( 'map_state_hash' not in st.session_state or st.session_state['map_state_hash'] != map_state_hash ) if need_recreate: st.session_state['map_state_hash'] = map_state_hash # Single wide map container below controls map_col = st.container() with map_col: # Get current center and zoom from session state current_center = st.session_state.get('selector_center', [51.70814085564164, 8.772155163087213]) current_zoom = st.session_state.get('selector_zoom', 17.5) # Build map with only the chosen base layer if selected_base == 'Blank': fmap = folium.Map(location=current_center, zoom_start=current_zoom, tiles=None) # Add a transparent 1x1 tile layer colored via CSS overlay not natively supported; skip tiles, map background will be default. # We'll inject simple CSS to set map background to very light gray. st.markdown(""" """, unsafe_allow_html=True) elif selected_base == 'Satellite': fmap = folium.Map(location=current_center, zoom_start=current_zoom, tiles=None) folium.TileLayer( tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attr='Esri WorldImagery', name='Satellite' ).add_to(fmap) elif selected_base == 'Positron': fmap = folium.Map(location=current_center, zoom_start=current_zoom, tiles='CartoDB positron') else: fmap = folium.Map(location=current_center, zoom_start=current_zoom, tiles='OpenStreetMap') # Feature groups for overlays process_fg = folium.FeatureGroup(name='Processes', show=True) connection_fg = folium.FeatureGroup(name='Connections', show=True) # Add subprocess markers (only when parent group is expanded) proc_groups = st.session_state.get('proc_groups', []) group_expanded = st.session_state.get('proc_group_expanded', []) # Create mapping of subprocess index to group index subprocess_to_group_folium = {} for group_idx, group_subprocess_list in enumerate(proc_groups): for subprocess_idx in group_subprocess_list: subprocess_to_group_folium[subprocess_idx] = group_idx for idx, p in enumerate(st.session_state['processes']): lat = p.get('lat'); lon = p.get('lon') if lat not in (None, "") and lon not in (None, ""): # Check if this subprocess's parent group is expanded parent_group_idx = subprocess_to_group_folium.get(idx) if parent_group_idx is not None: # Only show subprocess if parent group is expanded if (parent_group_idx >= len(group_expanded) or not group_expanded[parent_group_idx]): continue # Skip this subprocess - parent group is collapsed try: label = p.get('name') or f"P{idx+1}" html = f"""
📦 {label}
""" folium.Marker( [float(lat), float(lon)], tooltip=label, popup=f"{label}
Next: {p.get('next','')}", icon=folium.DivIcon(html=html) ).add_to(process_fg) except (ValueError, TypeError): pass # Add main process (group) markers (only when collapsed) group_coords = st.session_state.get('proc_group_coordinates', {}) group_names = st.session_state.get('proc_group_names', []) for group_idx, coords_data in group_coords.items(): # Convert group_idx to int if it's a string (happens after JSON load) group_idx = int(group_idx) if isinstance(group_idx, str) else group_idx if group_idx < len(group_names): # Only show main process marker when group is collapsed if (group_idx < len(group_expanded) and group_expanded[group_idx]): continue # Skip - group is expanded lat = coords_data.get('lat') lon = coords_data.get('lon') if lat is not None and lon is not None: try: group_name = group_names[group_idx] # Build detailed popup with subprocess information popup_html = f"
🏭 Process: {group_name}

" # Get all subprocesses in this group group_subprocess_list = proc_groups[group_idx] if group_idx < len(proc_groups) else [] if group_subprocess_list: popup_html += f"Subprocesses ({len(group_subprocess_list)}):
" popup_html += "
" for subprocess_idx in group_subprocess_list: if subprocess_idx < len(st.session_state['processes']): subprocess = st.session_state['processes'][subprocess_idx] subprocess_name = subprocess.get('name') or f"Subprocess {subprocess_idx+1}" popup_html += f"
" popup_html += f"📦 {subprocess_name}
" # Add location if available sub_lat = subprocess.get('lat') sub_lon = subprocess.get('lon') if sub_lat and sub_lon: popup_html += f"Location: ({sub_lat}, {sub_lon})
" # Add next connection if available next_val = subprocess.get('next', '') if next_val: popup_html += f"Next: {next_val}
" # Add streams information streams = subprocess.get('streams', []) if streams: popup_html += f"Streams ({len(streams)}):
" for s_idx, stream in enumerate(streams): # Handle new stream structure with flexible properties stream_name = stream.get('name', f'Stream {s_idx+1}') stream_type = stream.get('type', 'product') # Check if using new structure (properties and values lists) properties = stream.get('properties', []) values = stream.get('values', []) if properties and values and len(properties) == len(values): # New structure: display name, type, and property-value pairs popup_html += f"  • {stream_name} ({stream_type}): " prop_pairs = [] for prop, val in zip(properties, values): if prop in ['Tin', 'Tout']: prop_pairs.append(f"{prop}={val}°C") elif prop == 'ṁ': prop_pairs.append(f"ṁ={val}") elif prop == 'cp': prop_pairs.append(f"cp={val}") else: prop_pairs.append(f"{prop}={val}") popup_html += ", ".join(prop_pairs) popup_html += "
" else: # Legacy structure: fallback to old fields tin = stream.get('temp_in', '?') tout = stream.get('temp_out', '?') mdot = stream.get('mdot', '?') cp = stream.get('cp', '?') popup_html += f"  • {stream_name} ({stream_type}): Tin={tin}°C, Tout={tout}°C, ṁ={mdot}, cp={cp}
" popup_html += "
" popup_html += "
" else: popup_html += "No subprocesses yet
" popup_html += "
" html = f"""
🏭 {group_name}
""" folium.Marker( [float(lat), float(lon)], tooltip=group_name, popup=folium.Popup(popup_html, max_width=400), icon=folium.DivIcon(html=html) ).add_to(process_fg) except (ValueError, TypeError): pass name_lookup = {} coord_by_idx = {} for idx, p in enumerate(st.session_state['processes']): lat = p.get('lat'); lon = p.get('lon') try: if lat not in (None, "", "None") and lon not in (None, "", "None"): lat_f = float(lat); lon_f = float(lon) coord_by_idx[idx] = (lat_f, lon_f) nm = (p.get('name') or f"P{idx+1}").strip().lower() if nm: name_lookup.setdefault(nm, []).append(idx) except (ValueError, TypeError): continue def _parse_targets(raw_targets): if not raw_targets: return [] tokens = [] for raw_piece in raw_targets.replace(';', ',').replace('|', ',').split(','): tkn = raw_piece.strip() if tkn: tokens.append(tkn) return tokens for src_idx, p in enumerate(st.session_state['processes']): if src_idx not in coord_by_idx: continue raw_next = p.get('next', '') or '' for next_token in _parse_targets(raw_next): tgt_indices = [] if next_token.isdigit(): val = int(next_token) - 1 if val in coord_by_idx: tgt_indices.append(val) else: lname_lookup = next_token.lower() tgt_indices.extend(name_lookup.get(lname_lookup, [])) for tgt_idx in tgt_indices: if tgt_idx == src_idx: continue lat1, lon1 = coord_by_idx[src_idx] lat2, lon2 = coord_by_idx[tgt_idx] try: folium.PolyLine([(lat1, lon1), (lat2, lon2)], color='#000000', weight=3, opacity=0.9).add_to(connection_fg) import math as _math_inner dlat = lat2 - lat1; dlon = lon2 - lon1 if abs(dlat) + abs(dlon) > 0: arrow_lat = lat2 - dlat * 0.12 arrow_lon = lon2 - dlon * 0.12 ang_deg = _math_inner.degrees(_math_inner.atan2(dlat, dlon)) arrow_html = f"""
""" folium.Marker([arrow_lat, arrow_lon], icon=folium.DivIcon(html=arrow_html), tooltip="").add_to(connection_fg) except (ValueError, TypeError): pass process_fg.add_to(fmap) connection_fg.add_to(fmap) fmap_data = st_folium( fmap, key="selector_map_stable", width=MAP_WIDTH, height=MAP_HEIGHT, returned_objects=["center","zoom","last_clicked"], ) # Store current live map state for potential snapshot capture if fmap_data and fmap_data.get('center') and fmap_data.get('zoom'): st.session_state['current_map_center'] = [fmap_data['center']['lat'], fmap_data['center']['lng']] st.session_state['current_map_zoom'] = fmap_data['zoom'] if ( st.session_state.get('placing_process_idx') is not None and fmap_data and fmap_data.get('last_clicked') ): click = fmap_data['last_clicked'] lat = click.get('lat'); lon = click.get('lng') if lat is not None and lon is not None: try: pidx = st.session_state['placing_process_idx'] st.session_state['processes'][pidx]['lat'] = round(float(lat), 6) st.session_state['processes'][pidx]['lon'] = round(float(lon), 6) except (ValueError, TypeError): pass # Only save position changes, don't force update the map view # This prevents the oscillation by not feeding the map position back to itself pass # Remove the position update logic entirely st.caption("Pan/zoom, then click 'Lock map and analyze' to capture a snapshot.") else: # Analysis mode if not st.session_state['map_locked']: st.warning("No locked snapshot yet. Switch to 'Select Map' and capture one.") else: # In Analyze mode on right column proceed with snapshot tools below # --- Top action/status bar --- top_c1, top_c2, top_c3, top_c4, top_c5, top_c6 = st.columns([3,1.5,1,1,1,2]) with top_c1: # Decide dynamic status message (priority: placing > measuring > last action > default) placing_idx = st.session_state.get('placing_process_idx') placing_mode = st.session_state.get('placement_mode') measure_mode = st.session_state.get('measure_mode') measure_points = st.session_state.get('measure_points', []) dist_val = st.session_state.get('measure_distance_m') last_msg = st.session_state.get('ui_status_msg') if placing_mode and placing_idx is not None: if isinstance(placing_idx, str) and placing_idx.startswith('group_'): # Group placement group_idx = int(placing_idx.split('_')[1]) if group_idx < len(st.session_state.get('proc_group_names', [])): group_name = st.session_state['proc_group_names'][group_idx] st.info(f"📍 Placing Process: {group_name} → Click anywhere on the map to place the process rectangle") elif isinstance(placing_idx, str) and placing_idx.startswith('child_'): # Child node placement (sub-subprocess, etc.) child_node = st.session_state.get('placing_node_ref') if child_node: child_name = child_node.get('name', 'Child') child_level = child_node.get('level', 2) level_name = get_level_name(child_level) st.info(f"📍 Placing {level_name}: {child_name} → Click anywhere on the map to place") elif isinstance(placing_idx, int) and 0 <= placing_idx < len(st.session_state.get('processes', [])): # Subprocess placement pname = st.session_state['processes'][placing_idx].get('name') or f"Subprocess {placing_idx+1}" st.info(f"📍 Placing: {pname} → Click anywhere on the map to place the process rectangle") elif measure_mode: if dist_val is not None: st.success(f"Distance: {dist_val:.2f} m ({dist_val/1000:.3f} km)") else: st.info(f"Measuring distance: select {2-len(measure_points)} more point(s)") elif last_msg: st.success(last_msg) else: st.info("Snapshot locked") with top_c2: if not st.session_state['measure_mode']: if st.button("Measure Distance", key="measure_btn"): st.session_state['measure_mode'] = True st.session_state['placement_mode'] = False st.session_state['measure_points'] = [] st.session_state['measure_distance_m'] = None else: if st.button("Reset Measurement", key="reset_measure"): st.session_state['measure_points'] = [] st.session_state['measure_distance_m'] = None with top_c3: # Export button if st.button("Export CSV", key="export_csv_btn", help="Export process data to CSV"): csv_data = export_to_csv() st.session_state['_export_csv_data'] = csv_data st.session_state['_export_csv_ready'] = True if st.session_state.get('_export_csv_ready'): st.download_button( label="⬇️ Download CSV", data=st.session_state['_export_csv_data'], file_name=f"heat_integration_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", mime="text/csv", key="export_csv_download" ) with top_c4: # Save button if st.button("Save State", key="save_state_btn", help="Save current state"): state_json = save_app_state() st.session_state['_save_state_data'] = state_json st.session_state['_save_state_ready'] = True if st.session_state.get('_save_state_ready'): st.download_button( label="⬇️ Download JSON", data=st.session_state['_save_state_data'], file_name=f"heat_integration_state_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", mime="application/json", key="save_state_download" ) with top_c5: # Check if we just loaded a file - show success message or buttons if 'state_just_loaded' in st.session_state and st.session_state['state_just_loaded']: if st.button("Load Another", key="btn_load_another", help="Load another file"): st.session_state['state_just_loaded'] = False st.rerun() else: # Load button - compact file uploader st.markdown(""" """, unsafe_allow_html=True) uploaded_file = st.file_uploader("Load file", type=['json'], key="upload_state", label_visibility="collapsed") if uploaded_file is not None: try: # Read the file immediately to avoid session state issues file_contents = uploaded_file.getvalue().decode('utf-8') success, message = load_app_state(file_contents) if success: st.session_state['state_just_loaded'] = True # Clear the file uploader to prevent MediaFileStorageError if 'upload_state' in st.session_state: del st.session_state['upload_state'] st.rerun() else: st.error(message) except Exception as e: st.error(f"Error loading file: {str(e)}") with top_c6: analyze_options = ["OpenStreetMap", "Positron", "Satellite", "Blank"] if st.session_state['current_base'] not in analyze_options: st.session_state['current_base'] = 'OpenStreetMap' _an_top_base = st.selectbox( label="Base layer analyze", options=analyze_options, index=analyze_options.index(st.session_state['current_base']), key='analyze_base_selector_top', label_visibility='collapsed' ) if _an_top_base != st.session_state['current_base']: st.session_state['current_base'] = _an_top_base # Placement handled directly via per-subprocess Place/Done buttons in left panel # Determine which base layer to display in Analyze view # Always start from frozen analyze base layer, but allow user to switch (persist separately) # Active base for Analyze uses either a runtime override or the frozen at-lock base # Active base for Analyze: persistent independent selection active_base = st.session_state.get('current_base', st.session_state.get('analyze_base_layer','OpenStreetMap')) snapshots_dict = st.session_state.get('map_snapshots', {}) if active_base == 'Blank': # Create a blank light gray image placeholder same size as last snapshot (or default size) base_img = Image.new('RGBA', (MAP_WIDTH, MAP_HEIGHT), (242,242,243,255)) w, h = base_img.size else: chosen_bytes = snapshots_dict.get(active_base) or st.session_state.get('map_snapshot') base_img = Image.open(BytesIO(chosen_bytes)).convert("RGBA") if chosen_bytes else Image.new('RGBA',(MAP_WIDTH,MAP_HEIGHT),(242,242,243,255)) w, h = base_img.size if base_img: # --- Overlay subprocess boxes & connecting arrows on snapshot --- draw = ImageDraw.Draw(base_img) # Smaller font for more compact display BOX_FONT_SIZE = 20 # Smaller but still sharp text LABEL_FONT_SIZE = 6 # Very small font for stream labels # Try common font paths for different platforms font_paths = [ # Linux common fonts "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", # macOS fonts "/System/Library/Fonts/Arial.ttf", "/System/Library/Fonts/Helvetica.ttc", # Windows fonts (fallback) "C:/Windows/Fonts/arial.ttf", # Generic fallback "DejaVuSans.ttf" ] font = None label_font = None for font_path in font_paths: try: font = ImageFont.truetype(font_path, BOX_FONT_SIZE) break # Success, stop trying except (OSError, IOError): continue # Final fallback if all font paths fail if font is None: try: font = ImageFont.load_default() except: font = None # FORCE label_font to None to always use fallback sizing label_font = None # Create a tiny font for labels that will work everywhere try: # Try to create the smallest possible truetype font tiny_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 4) except: try: tiny_font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", 4) except: # Last resort: use default but we'll override the sizing tiny_font = None # First pass: compute positions & bounding boxes positioned = [] # list of dicts with: idx,label,center,box,(next_raw) name_index = {} # map lowercase name -> list of indices (to handle duplicates) # Create mapping of subprocess index to group index subprocess_to_group = {} proc_groups = st.session_state.get('proc_groups', []) for group_idx, group_subprocess_list in enumerate(proc_groups): for subprocess_idx in group_subprocess_list: subprocess_to_group[subprocess_idx] = group_idx group_expanded = st.session_state.get('proc_group_expanded', []) # Store circle information for click detection stream_circles = [] # List of dicts: {'x': int, 'y': int, 'radius': int, 'stream_info': dict} # First pass: draw main process rectangles (when collapsed) BEHIND grey overlay group_coords = st.session_state.get('proc_group_coordinates', {}) for group_idx, coords_data in group_coords.items(): # Convert group_idx to int if it's a string (happens after JSON load) group_idx = int(group_idx) if isinstance(group_idx, str) else group_idx if group_idx < len(st.session_state.get('proc_group_names', [])): # Always show main process rectangle (green box) on the map # It should remain visible regardless of UI expansion state lat = coords_data.get('lat') lon = coords_data.get('lon') if lat is not None and lon is not None: try: lat_f = float(lat) lon_f = float(lon) group_px, group_py = snapshot_lonlat_to_pixel( lon_f, lat_f, (st.session_state['map_center'][1], st.session_state['map_center'][0]), st.session_state['map_zoom'], w, h ) # Skip if far outside snapshot bounds if group_px < -50 or group_py < -20 or group_px > w + 50 or group_py > h + 20: continue group_label = st.session_state['proc_group_names'][group_idx] # Allow per-group size via proc_group_coordinates[group_idx]['box_scale'] try: scale = float(coords_data.get('box_scale', 1.5) or 1.5) except (ValueError, TypeError): scale = 1.5 base_padding = 8 padding = int(base_padding * scale) text_bbox = draw.textbbox((0, 0), group_label, font=font) if font else (0, 0, len(group_label) * 6, 10) tw = (text_bbox[2] - text_bbox[0]) th = (text_bbox[3] - text_bbox[1]) box_w = int(tw * scale + padding * 2) box_h = int(th * scale + padding * 2) x0 = int(group_px - box_w / 2) y0 = int(group_py - box_h / 2) x1 = x0 + box_w y1 = y0 + box_h if x1 < 0 or y1 < 0 or x0 > w or y0 > h: continue # Draw main process rectangle with green styling fill_color = (200, 255, 200, 245) # Light green border_color = (34, 139, 34, 255) # Forest green text_color = (0, 100, 0, 255) # Dark green border_width = 3 # Draw filled rectangle draw.rectangle([x0, y0, x1, y1], fill=fill_color, outline=border_color, width=border_width) # Center text inside box box_w = x1 - x0 box_h = y1 - y0 if font: bbox_lbl = draw.textbbox((0,0), group_label, font=font) t_w = bbox_lbl[2]-bbox_lbl[0] t_h = bbox_lbl[3]-bbox_lbl[1] else: t_w = len(group_label)*6 t_h = 10 ct_x = int(x0 + (box_w - t_w)/2) ct_y = int(y0 + (box_h - t_h)/2) if font: draw.text((ct_x, ct_y), group_label, fill=text_color, font=font) else: draw.text((ct_x, ct_y), group_label, fill=text_color) # Draw stream circles above the process box # Aggregate all streams from all subprocesses in this process group group_subprocess_list = st.session_state.get('proc_groups', [])[group_idx] if group_idx < len(st.session_state.get('proc_groups', [])) else [] all_streams = [] for sub_idx in group_subprocess_list: if sub_idx < len(st.session_state.get('processes', [])): subprocess = st.session_state['processes'][sub_idx] all_streams.extend(subprocess.get('streams', [])) if all_streams: # Calculate Q for each stream and draw circles above the box circle_y = y0 - 20 # Position circles closer to the box circle_spacing = 32 # Horizontal spacing between circles base_radius = 10 # Base radius for circles (larger) # Calculate total width needed for all circles total_width = len(all_streams) * circle_spacing start_x = int((x0 + x1) / 2 - total_width / 2 + circle_spacing / 2) for stream_idx, stream in enumerate(all_streams): # Calculate Q (heat power) for this stream sv = stream.get('stream_values', {}) tin = sv.get('Tin', '') or stream.get('temp_in', '') tout = sv.get('Tout', '') or stream.get('temp_out', '') mdot = sv.get('ṁ', '') or stream.get('mdot', '') cp_val = sv.get('cp', '') or stream.get('cp', '') CP_val = sv.get('CP', '') or stream.get('CP', '') Q = 0 try: if tin and tout: tin_f = float(tin) tout_f = float(tout) delta_T = abs(tout_f - tin_f) # Try CP first (simpler calculation) if CP_val: CP_f = float(CP_val) Q = CP_f * delta_T # Fallback to mdot * cp elif mdot and cp_val: mdot_f = float(mdot) cp_f = float(cp_val) Q = mdot_f * cp_f * delta_T except (ValueError, TypeError): Q = 0 # Determine circle size based on Q with new logic if Q > 0: # New size logic: below 1000 smaller, above 5000 very big, scaled in between if Q < 1000: radius = base_radius + 5 # smaller elif Q > 5000: radius = base_radius + 25 # very big else: # Scale between 1000-5000 scale_factor = (Q - 1000) / (5000 - 1000) # 0 to 1 radius = base_radius + 5 + int(scale_factor * 20) # 5 to 25 else: radius = base_radius # Determine color based on stream type with temperature-based intensity try: if tin and tout: tin_f = float(tin) tout_f = float(tout) max_temp = max(tin_f, tout_f) if tin_f > tout_f: # Hot stream (cooling) if max_temp > 100: circle_color = (255, 0, 0, 220) # Strong red else: circle_color = (255, 100, 100, 220) # Less strong red else: # Cold stream (heating) if max_temp > 100: circle_color = (0, 0, 255, 220) # Strong blue else: circle_color = (100, 150, 255, 220) # Less strong blue else: circle_color = (150, 150, 150, 200) # Grey for unknown except (ValueError, TypeError): circle_color = (150, 150, 150, 200) # Grey for invalid data # Draw circle circle_x = start_x + stream_idx * circle_spacing base_img = draw_smooth_ellipse( base_img, [circle_x - radius, circle_y - radius, circle_x + radius, circle_y + radius], fill=circle_color, outline=(50, 50, 50, 255), width=1, ) draw = ImageDraw.Draw(base_img) # Store circle info for click detection stream_circles.append({ 'x': circle_x, 'y': circle_y, 'radius': radius, 'stream_name': stream.get('name', 'Unnamed Stream'), 'stream_type': stream.get('type', 'product'), 'tin': tin, 'tout': tout, 'mdot': mdot, 'cp': cp_val, 'CP': CP_val, 'Q': f"{Q:.2f}" if Q > 0 else "N/A", 'process': st.session_state['proc_group_names'][group_idx] if group_idx < len(st.session_state.get('proc_group_names', [])) else 'Unknown' }) except (ValueError, TypeError): continue # Second pass: draw large grey overlays for expanded processes ABOVE main processes # Now controlled by process_subprocess_map_expanded (the Map toggle button), not proc_group_expanded process_subprocess_map_exp = st.session_state.get('process_subprocess_map_expanded', {}) for group_idx, coords_data in group_coords.items(): # Convert group_idx to int if it's a string (happens after JSON load) group_idx = int(group_idx) if isinstance(group_idx, str) else group_idx # Only draw overlay if the Map toggle is enabled for this process group if process_subprocess_map_exp.get(group_idx, False): try: # Always use fixed size overlay centered in screen # Fixed size: 90% width, 90% height overlay_w = int(w * 0.9) overlay_h = int(h * 0.9) # Always center in the middle of the screen center_px = w // 2 center_py = h // 2 overlay_x0 = int(center_px - overlay_w / 2) overlay_y0 = int(center_py - overlay_h / 2) overlay_x1 = overlay_x0 + overlay_w overlay_y1 = overlay_y0 + overlay_h # Ensure overlay stays within map bounds with some padding margin = 20 overlay_x0 = max(margin, overlay_x0) overlay_y0 = max(margin, overlay_y0) overlay_x1 = min(w - margin, overlay_x1) overlay_y1 = min(h - margin, overlay_y1) # Draw very light grey semi-transparent overlay draw.rectangle([overlay_x0, overlay_y0, overlay_x1, overlay_y1], fill=(250, 250, 250, 40), # Almost white with very low opacity outline=(245, 245, 245, 80), width=1) # Optional: Add a subtle label in the corner if group_idx < len(st.session_state.get('proc_group_names', [])): group_name = st.session_state['proc_group_names'][group_idx] overlay_label = f"Process Area: {group_name}" if font: label_bbox = draw.textbbox((0, 0), overlay_label, font=font) label_w = label_bbox[2] - label_bbox[0] label_h = label_bbox[3] - label_bbox[1] else: label_w = len(overlay_label) * 6 label_h = 10 # Place label in top-left corner of overlay with padding label_x = overlay_x0 + 15 label_y = overlay_y0 + 15 # Very subtle background for label draw.rectangle([label_x-5, label_y-3, label_x+label_w+5, label_y+label_h+3], fill=(255, 255, 255, 120), outline=(220, 220, 220, 100), width=1) # Draw label text in subtle grey if font: draw.text((label_x, label_y), overlay_label, fill=(40, 40, 40, 255), font=font) else: draw.text((label_x, label_y), overlay_label, fill=(40, 40, 40, 255)) except (ValueError, TypeError): continue # Third pass: draw subprocesses ABOVE grey overlay (only when Map toggle is enabled) for i, p in enumerate(st.session_state['processes']): lat = p.get('lat'); lon = p.get('lon') if lat in (None, "", "None") or lon in (None, "", "None"): continue # Check if this subprocess's parent group has Map toggle enabled parent_group_idx = subprocess_to_group.get(i) if parent_group_idx is not None: # Only show subprocess if parent group's Map toggle is ON if not process_subprocess_map_exp.get(parent_group_idx, False): continue # Skip this subprocess - Map toggle is off try: lat_f = float(lat); lon_f = float(lon) proc_px, proc_py = snapshot_lonlat_to_pixel( lon_f, lat_f, (st.session_state['map_center'][1], st.session_state['map_center'][0]), st.session_state['map_zoom'], w, h ) # Skip if far outside snapshot bounds (padding margin) if proc_px < -50 or proc_py < -20 or proc_px > w + 50 or proc_py > h + 20: continue label = p.get('name') or f"P{i+1}" scale = float(p.get('box_scale', 6.0) or 6.0) base_padding = 18 padding = int(base_padding * scale) text_bbox = draw.textbbox((0, 0), label, font=font) if font else (0, 0, len(label) * 6, 10) tw = (text_bbox[2] - text_bbox[0]) th = (text_bbox[3] - text_bbox[1]) box_w = int(tw * scale + padding * 2) box_h = int(th * scale + padding * 2) x0 = int(proc_px - box_w / 2) y0 = int(proc_py - box_h / 2) x1 = x0 + box_w y1 = y0 + box_h if x1 < 0 or y1 < 0 or x0 > w or y0 > h: continue positioned.append({ 'idx': i, 'label': label, 'center': (proc_px, proc_py), 'box': (x0, y0, x1, y1), 'next_raw': p.get('next', '') or '', 'type': 'subprocess' }) lname = label.strip().lower() name_index.setdefault(lname, []).append(len(positioned) - 1) except (ValueError, TypeError): continue # Helper: draw arrow with head def _draw_arrow(draw_ctx, x_start, y_start, x_end, y_end, color=(0, 0, 0, 255), width=3, head_len=18, head_angle_deg=30): import math from PIL import ImageDraw # Use PIL's line drawing with proper width for smooth diagonal lines draw_ctx.line([(x_start, y_start), (x_end, y_end)], fill=color, width=width, joint='curve') ang = math.atan2(y_end - y_start, x_end - x_start) ang_left = ang - math.radians(head_angle_deg) ang_right = ang + math.radians(head_angle_deg) x_left = x_end - head_len * math.cos(ang_left) y_left = y_end - head_len * math.sin(ang_left) x_right = x_end - head_len * math.cos(ang_right) y_right = y_end - head_len * math.sin(ang_right) draw_ctx.polygon([(x_end, y_end), (x_left, y_left), (x_right, y_right)], fill=color) # Build quick lookup by subprocess name (case-insensitive) # Also allow fallback tokens like numeric indices (1-based) or label exactly def _resolve_targets(target_token): target_token = target_token.strip() if not target_token: return [] # numeric index support if target_token.isdigit(): idx_int = int(target_token) - 1 for d in positioned: if d['idx'] == idx_int: return [d] return [] lname2 = target_token.lower() if lname2 in name_index: return [positioned[i] for i in name_index[lname2]] # Try exact match ignoring case across original names (robustness) return [d for d in positioned if d['label'].lower() == lname2] # Second pass: draw arrows (under boxes for clarity -> so draw now, then boxes after?) # We'll draw arrows first then boxes so boxes sit on top. # Also collect product stream info for labeling on connection arrows connection_product_streams = [] # List of (src_idx, tgt_idx, start_x, start_y, end_x, end_y, streams) for src in positioned: raw_next = src['next_raw'] if not raw_next: continue # Split by common delimiters parts = [] for chunk in raw_next.replace(';', ',').replace('|', ',').split(','): part = chunk.strip() if part: parts.append(part) if not parts: continue sx, sy = src['center'] for part_token in parts: targets = _resolve_targets(part_token) for tgt in targets: if tgt is src: continue # no self-loop for now tx, ty = tgt['center'] # Adjust start/end to box edges # Source box dims sx0, sy0, sx1, sy1 = src['box'] sw2 = (sx1 - sx0) / 2.0 sh2 = (sy1 - sy0) / 2.0 # Target box dims tx0, ty0, tx1, ty1 = tgt['box'] tw2 = (tx1 - tx0) / 2.0 th2 = (ty1 - ty0) / 2.0 vec_dx = tx - sx vec_dy = ty - sy if vec_dx == 0 and vec_dy == 0: continue import math as _math # Factor to exit source box boundary factors_s = [] if vec_dx != 0: factors_s.append(sw2 / abs(vec_dx)) if vec_dy != 0: factors_s.append(sh2 / abs(vec_dy)) t_s = min(factors_s) if factors_s else 0 # Factor to enter target box boundary from target center backwards factors_t = [] if vec_dx != 0: factors_t.append(tw2 / abs(vec_dx)) if vec_dy != 0: factors_t.append(th2 / abs(vec_dy)) t_t = min(factors_t) if factors_t else 0 start_x = sx + vec_dx * t_s * 1.02 start_y = sy + vec_dy * t_s * 1.02 end_x = tx - vec_dx * t_t * 1.02 end_y = ty - vec_dy * t_t * 1.02 _draw_arrow(draw, start_x, start_y, end_x, end_y, color=(0, 0, 0, 245), width=5) # Collect product streams from source subprocess for labeling on this connection if src.get('type') == 'subprocess': src_proc = st.session_state['processes'][src['idx']] src_streams = src_proc.get('streams', []) or [] product_streams = [s for s in src_streams if s.get('type', 'product') == 'product'] if product_streams: connection_product_streams.append({ 'src_idx': src['idx'], 'tgt_idx': tgt['idx'], 'start_x': start_x, 'start_y': start_y, 'end_x': end_x, 'end_y': end_y, 'streams': product_streams }) # Draw product stream labels along connection arrows def _draw_connection_stream_label(draw_ctx, start_x, start_y, end_x, end_y, streams, font_obj): """Draw product stream info below the connection line between processes.""" import math as _m from PIL import ImageFont # Try to create a smaller font for stream labels small_font = None try: small_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10) except: try: small_font = ImageFont.truetype("arial.ttf", 10) except: small_font = font_obj # fallback to regular font # Collect values from all product streams all_parts = [] for s in streams: # Get display variables setting (Tin is always shown) display_vars = s.get('display_vars', []) # Try to get values from stream_values first (new structure) sv = s.get('stream_values', {}) if not sv: sv = s.get('product_values', {}) # fallback to old name tin_val = sv.get('Tin', '') tout_val = sv.get('Tout', '') mdot_val = sv.get('ṁ', '') cp_val = sv.get('cp', '') CP_val = sv.get('CP', '') # Fallback to properties/values structure if not tin_val or not tout_val or not mdot_val or not cp_val: props = s.get('properties', {}) vals = s.get('values', {}) if isinstance(props, dict) and isinstance(vals, dict): for pk, pname in props.items(): vk = pk.replace('prop', 'val') v = vals.get(vk, '') if pname == 'Tin' and v and not tin_val: tin_val = v elif pname == 'Tout' and v and not tout_val: tout_val = v elif pname == 'ṁ' and v and not mdot_val: mdot_val = v elif pname == 'cp' and v and not cp_val: cp_val = v elif pname == 'CP' and v and not CP_val: CP_val = v # Fallback to legacy fields if not tin_val: tin_val = s.get('temp_in', '') if not tout_val: tout_val = s.get('temp_out', '') if not mdot_val: mdot_val = s.get('mdot', '') if not cp_val: cp_val = s.get('cp', '') # Build parts for THIS stream based on display_vars (Tin is always shown) stream_parts = [] stream_name = s.get('name', '') if tin_val: stream_parts.append(f"Tin={tin_val}") if tout_val and "Tout" in display_vars: stream_parts.append(f"Tout={tout_val}") if mdot_val and "ṁ" in display_vars: stream_parts.append(f"ṁ={mdot_val}") if cp_val and "cp" in display_vars: stream_parts.append(f"cp={cp_val}") if CP_val and "CP" in display_vars: stream_parts.append(f"CP={CP_val}") if stream_parts: # Add stream name prefix if available label = " | ".join(stream_parts) if stream_name: label = f"{stream_name}: {label}" all_parts.append(label) if not all_parts: return # Calculate midpoint of the connection line mid_x = (start_x + end_x) / 2 mid_y = (start_y + end_y) / 2 perp_offset = 18 # pixels below the line line_height = 14 # height per stream label line # Draw each stream label on a separate line for line_idx, stream_label in enumerate(all_parts): # Calculate text dimensions with smaller font if small_font: tb = draw_ctx.textbbox((0, 0), stream_label, font=small_font) t_width = tb[2] - tb[0] t_height = tb[3] - tb[1] else: t_width = len(stream_label) * 5 t_height = 8 # Position label below the midpoint, stacked vertically lx = int(mid_x - t_width / 2) ly = int(mid_y + perp_offset + (line_idx * line_height)) # Draw white background draw_ctx.rectangle([lx - 3, ly - 1, lx + t_width + 3, ly + t_height + 1], fill=(255, 255, 255, 235), outline=(120, 120, 120, 150)) # Draw text with smaller font if small_font: draw_ctx.text((lx, ly), stream_label, fill=(0, 70, 0, 255), font=small_font) else: draw_ctx.text((lx, ly), stream_label, fill=(0, 70, 0, 255)) # Third pass: draw boxes & labels on top for item in positioned: x0, y0, x1, y1 = item['box'] label = item['label'] item_type = item.get('type', 'subprocess') padding = 6 # Retrieve scale from subprocess for consistent font positioning relative to new box proc_scale = 1.0 if item_type == 'subprocess': try: proc_scale = float(st.session_state['processes'][item['idx']].get('box_scale',4.0) or 4.0) except (ValueError, TypeError, KeyError): proc_scale = 1.0 padding = int(6 * proc_scale) # Different colors for processes vs subprocesses if item_type == 'process': # Main process: Green fill with dark green border fill_color = (200, 255, 200, 245) # Light green border_color = (34, 139, 34, 255) # Forest green text_color = (0, 100, 0, 255) # Dark green border_width = 3 else: # Subprocess: Light blue fill with dark blue border (original) fill_color = (224, 242, 255, 245) border_color = (23, 105, 170, 255) text_color = (10, 53, 85, 255) border_width = 2 # Draw filled rectangle draw.rectangle([x0, y0, x1, y1], fill=fill_color, outline=border_color, width=border_width) # Center text inside box box_w = x1 - x0 box_h = y1 - y0 if font: bbox_lbl = draw.textbbox((0,0), label, font=font) t_w = bbox_lbl[2]-bbox_lbl[0] t_h = bbox_lbl[3]-bbox_lbl[1] else: t_w = len(label)*6 t_h = 10 ct_x = int(x0 + (box_w - t_w)/2) ct_y = int(y0 + (box_h - t_h)/2) if font: draw.text((ct_x, ct_y), label, fill=text_color, font=font) else: draw.text((ct_x, ct_y), label, fill=text_color) # Sixth pass: draw subprocess overlays for expanded process groups process_subprocess_map_expanded = st.session_state.get('process_subprocess_map_expanded', {}) for proc_group_idx, is_expanded in process_subprocess_map_expanded.items(): if not is_expanded: continue proc_group_idx_int = int(proc_group_idx) if isinstance(proc_group_idx, str) else proc_group_idx # Get process group name and subprocess indices proc_group_names = st.session_state.get('proc_group_names', []) proc_group_lists = st.session_state.get('proc_group_lists', []) if proc_group_idx_int >= len(proc_group_names) or proc_group_idx_int >= len(proc_group_lists): continue proc_group_name = proc_group_names[proc_group_idx_int] subprocess_indices = proc_group_lists[proc_group_idx_int] try: # Full screen overlay (85% of screen) pg_overlay_w = int(w * 0.85) pg_overlay_h = int(h * 0.85) # Center in the middle of the screen center_px = w // 2 center_py = h // 2 pg_overlay_x0 = int(center_px - pg_overlay_w / 2) pg_overlay_y0 = int(center_py - pg_overlay_h / 2) pg_overlay_x1 = pg_overlay_x0 + pg_overlay_w pg_overlay_y1 = pg_overlay_y0 + pg_overlay_h # Ensure overlay stays within map bounds margin = 20 pg_overlay_x0 = max(margin, pg_overlay_x0) pg_overlay_y0 = max(margin, pg_overlay_y0) pg_overlay_x1 = min(w - margin, pg_overlay_x1) pg_overlay_y1 = min(h - margin, pg_overlay_y1) # Draw light blue overlay with border (for subprocess level) draw.rectangle([pg_overlay_x0, pg_overlay_y0, pg_overlay_x1, pg_overlay_y1], fill=(240, 248, 255, 245), # Light blue (AliceBlue) outline=(70, 130, 180, 220), # SteelBlue border width=2) # Add label in top-left corner pg_overlay_label = f"Subprocess Area: {proc_group_name}" if font: label_bbox = draw.textbbox((0, 0), pg_overlay_label, font=font) label_w = label_bbox[2] - label_bbox[0] label_h = label_bbox[3] - label_bbox[1] else: label_w = len(pg_overlay_label) * 6 label_h = 10 label_x = pg_overlay_x0 + 15 label_y = pg_overlay_y0 + 12 # Background for label draw.rectangle([label_x-5, label_y-3, label_x+label_w+5, label_y+label_h+3], fill=(220, 235, 250, 200), outline=(100, 150, 200, 150), width=1) if font: draw.text((label_x, label_y), pg_overlay_label, fill=(30, 60, 90, 255), font=font) else: draw.text((label_x, label_y), pg_overlay_label, fill=(30, 60, 90, 255)) # Draw subprocess boxes inside the overlay if subprocess_indices: n_subprocs = len(subprocess_indices) usable_w = pg_overlay_x1 - pg_overlay_x0 - 60 usable_h = pg_overlay_y1 - pg_overlay_y0 - 80 # Calculate grid layout cols = min(n_subprocs, 4) rows = (n_subprocs + cols - 1) // cols sub_box_w = min(180, usable_w // cols - 20) sub_box_h = min(70, usable_h // rows - 20) # Starting position for grid grid_start_x = pg_overlay_x0 + 30 grid_start_y = pg_overlay_y0 + 50 for si, sub_idx in enumerate(subprocess_indices): if sub_idx >= len(st.session_state['processes']): continue subprocess = st.session_state['processes'][sub_idx] col_idx = si % cols row_idx = si // cols # Default grid position sx = grid_start_x + col_idx * (sub_box_w + 25) + sub_box_w // 2 sy = grid_start_y + row_idx * (sub_box_h + 25) + sub_box_h // 2 # Check if subprocess has lat/lon - if so, draw at that location sub_lat = subprocess.get('lat') sub_lon = subprocess.get('lon') if sub_lat and sub_lon and str(sub_lat).strip() and str(sub_lon).strip(): try: sub_lat_f = float(sub_lat) sub_lon_f = float(sub_lon) sx, sy = snapshot_lonlat_to_pixel( sub_lon_f, sub_lat_f, (st.session_state['map_center'][1], st.session_state['map_center'][0]), st.session_state['map_zoom'], w, h ) except (ValueError, TypeError): pass sub_name = subprocess.get('name', f'Subprocess {sub_idx + 1}') sub_scale = float(subprocess.get('box_scale', 1.0) or 1.0) # Calculate box dimensions if font: name_bbox = draw.textbbox((0, 0), sub_name, font=font) name_w = name_bbox[2] - name_bbox[0] name_h = name_bbox[3] - name_bbox[1] else: name_w = len(sub_name) * 6 name_h = 10 box_w_sub = int(name_w * sub_scale + 28) box_h_sub = int(name_h * sub_scale + 22) sx0 = int(sx - box_w_sub // 2) sy0 = int(sy - box_h_sub // 2) sx1 = sx0 + box_w_sub sy1 = sy0 + box_h_sub # Draw subprocess box with blue color draw.rectangle([sx0, sy0, sx1, sy1], fill=(224, 242, 255, 245), # Light blue outline=(23, 105, 170, 255), # Dark blue border width=2) # Center text text_x = int(sx - name_w // 2) text_y = int(sy - name_h // 2) if font: draw.text((text_x, text_y), sub_name, fill=(10, 53, 85, 255), font=font) else: draw.text((text_x, text_y), sub_name, fill=(10, 53, 85, 255)) except (ValueError, TypeError): continue # Fourth pass: vertical stream arrows (Tin above box entering, Tout below leaving) # Color rule: if Tin > Tout -> red (cooling stream), else blue (heating stream). Unknown -> gray. # Labels now ONLY appear at top (Tin, ṁ, cp) and bottom (Tout, ṁ, cp) – nothing in the middle so arrows can be closer. arrow_len_v = 45 # vertical arrow length above / below box base_stream_spacing = 35 # Even more reduced spacing def _draw_v_arrow(draw_ctx, x_pos, y_from, y_to, head_at_end=True, color=(0,0,0,245), width=3): """Draw vertical arrow from y_from to y_to at x_pos. If head_at_end True head at y_to else at y_from.""" draw_ctx.line([(x_pos, y_from), (x_pos, y_to)], fill=color, width=width) head_len = 11 head_half = 7 if head_at_end: # Triangle pointing direction of (y_to - y_from) direction = 1 if y_to >= y_from else -1 tip_y = y_to base_y = y_to - direction * head_len else: direction = 1 if y_from >= y_to else -1 tip_y = y_from base_y = y_from - direction * head_len # Triangle horizontal span draw_ctx.polygon([ (x_pos, tip_y), (x_pos - head_half, base_y), (x_pos + head_half, base_y) ], fill=color) def _label_centered(text_str, x_center, y_baseline, above=True): if not text_str: return # Try to use tiny font, otherwise use manual sizing if tiny_font: tb = draw.textbbox((0,0), text_str, font=tiny_font) t_width = tb[2]-tb[0] t_height = tb[3]-tb[1] text_xc = int(x_center - t_width/2) text_yc = int(y_baseline - (t_height if above else 0)) draw.rectangle([text_xc-1, text_yc-1, text_xc+t_width+1, text_yc+t_height+1], fill=(255,255,255,240)) draw.text((text_xc, text_yc), text_str, fill=(0,0,0,255), font=tiny_font) else: # Manual ultra-small sizing as fallback t_width = len(text_str) * 2.5 t_height = 4 text_xc = int(x_center - t_width/2) text_yc = int(y_baseline - (t_height if above else 0)) draw.rectangle([text_xc-1, text_yc-1, text_xc+t_width+1, text_yc+t_height+1], fill=(255,255,255,240)) draw.text((text_xc, text_yc), text_str, fill=(0,0,0,255)) for item in positioned: # Only draw streams for subprocesses, not main processes if item.get('type') != 'subprocess': continue proc_idx = item['idx'] proc = st.session_state['processes'][proc_idx] streams = proc.get('streams', []) or [] if not streams: continue # Filter out product streams (they are shown on horizontal connection arrows) non_product_streams = [s for s in streams if s.get('type', 'product') != 'product'] if not non_product_streams: continue x0, y0, x1, y1 = item['box'] box_center_x = (x0 + x1) / 2 n_streams = len(non_product_streams) # Dynamic spacing: attempt to shrink if narrow box; ensure minimum 28 px spacing stream_h_spacing = max(28, min(base_stream_spacing, (x1 - x0 - 20) / max(1, n_streams))) if n_streams > 1 else 0 for s_i, s in enumerate(non_product_streams): offset = (s_i - (n_streams - 1)/2) * stream_h_spacing sx = int(box_center_x + offset) # Attempt to parse temperatures tin_raw = s.get('temp_in', '') tout_raw = s.get('temp_out', '') try: tin_val = float(str(tin_raw).strip()) except (ValueError, TypeError): tin_val = None try: tout_val = float(str(tout_raw).strip()) except (ValueError, TypeError): tout_val = None # Color logic if tin_val is not None and tout_val is not None: is_cooling = tin_val > tout_val col = (200, 25, 25, 255) if is_cooling else (25, 80, 200, 255) else: col = (90, 90, 90, 255) # Inbound arrow (above box pointing downward INTO box) inbound_bottom = y0 - 2 # head just touches box border inbound_top = inbound_bottom - arrow_len_v _draw_v_arrow(draw, sx, inbound_top, inbound_bottom, head_at_end=True, color=col, width=4) # Outbound arrow (below box pointing downward AWAY from box) outbound_top = y1 + 2 outbound_bottom = outbound_top + arrow_len_v _draw_v_arrow(draw, sx, outbound_top, outbound_bottom, head_at_end=True, color=col, width=4) # Labels # m dot symbol fallback handling mdot_raw = s.get('mdot','') cp_raw = s.get('cp','') # Prefer combining dot ṁ; if font renders poorly user can switch later; keep fallback token m_symbol = "ṁ" # m + combining dot above if isinstance(mdot_raw, str) and mdot_raw.strip() == '': mdot_part = '' else: mdot_part = f"{m_symbol}={mdot_raw}" if mdot_raw not in (None,'') else '' cp_part = f"cp={cp_raw}" if cp_raw not in (None,'') else '' # Build top & bottom label clusters tin_label = f"Tin={tin_raw}" if tin_raw not in ('', None) else 'Tin=?' tout_label = f"Tout={tout_raw}" if tout_raw not in ('', None) else 'Tout=?' top_components = [tin_label] if mdot_part: top_components.append(mdot_part) if cp_part: top_components.append(cp_part) bot_components = [tout_label] if mdot_part: bot_components.append(mdot_part) if cp_part: bot_components.append(cp_part) top_text = " | ".join(top_components) bot_text = " | ".join(bot_components) # Labels removed - only arrows shown in subprocess view # Seventh pass: draw sub-subprocess overlays for expanded subprocesses (LAST - on top of everything) subprocess_map_expanded = st.session_state.get('subprocess_map_expanded', {}) for subprocess_idx, is_expanded in subprocess_map_expanded.items(): if not is_expanded: continue subprocess_idx_int = int(subprocess_idx) if isinstance(subprocess_idx, str) else subprocess_idx # Get subprocess position if subprocess_idx_int >= len(st.session_state['processes']): continue subprocess = st.session_state['processes'][subprocess_idx_int] sub_lat = subprocess.get('lat') sub_lon = subprocess.get('lon') # Draw overlay even if subprocess has no coordinates (centered on screen) try: # Full screen overlay like process overlay, but slightly smaller (80% instead of 90%) sub_overlay_w = int(w * 0.80) sub_overlay_h = int(h * 0.80) # Always center in the middle of the screen center_px = w // 2 center_py = h // 2 sub_overlay_x0 = int(center_px - sub_overlay_w / 2) sub_overlay_y0 = int(center_py - sub_overlay_h / 2) sub_overlay_x1 = sub_overlay_x0 + sub_overlay_w sub_overlay_y1 = sub_overlay_y0 + sub_overlay_h # Ensure overlay stays within map bounds with some padding margin = 25 sub_overlay_x0 = max(margin, sub_overlay_x0) sub_overlay_y0 = max(margin, sub_overlay_y0) sub_overlay_x1 = min(w - margin, sub_overlay_x1) sub_overlay_y1 = min(h - margin, sub_overlay_y1) # Draw white overlay with subtle border (similar to process overlay but white) draw.rectangle([sub_overlay_x0, sub_overlay_y0, sub_overlay_x1, sub_overlay_y1], fill=(255, 255, 255, 245), outline=(150, 150, 150, 220), width=2) # Add label in top-left corner subprocess_name = subprocess.get('name', f'Subprocess {subprocess_idx_int + 1}') sub_overlay_label = f"Sub-subprocess Area: {subprocess_name}" if font: label_bbox = draw.textbbox((0, 0), sub_overlay_label, font=font) label_w = label_bbox[2] - label_bbox[0] label_h = label_bbox[3] - label_bbox[1] else: label_w = len(sub_overlay_label) * 6 label_h = 10 label_x = sub_overlay_x0 + 15 label_y = sub_overlay_y0 + 12 # Subtle background for label draw.rectangle([label_x-5, label_y-3, label_x+label_w+5, label_y+label_h+3], fill=(240, 240, 240, 200), outline=(180, 180, 180, 150), width=1) if font: draw.text((label_x, label_y), sub_overlay_label, fill=(40, 40, 40, 255), font=font) else: draw.text((label_x, label_y), sub_overlay_label, fill=(40, 40, 40, 255)) # Draw sub-subprocess boxes inside the overlay children = subprocess.get('children', []) if children: n_children = len(children) usable_w = sub_overlay_x1 - sub_overlay_x0 - 60 usable_h = sub_overlay_y1 - sub_overlay_y0 - 80 # Calculate grid layout cols = min(n_children, 4) rows = (n_children + cols - 1) // cols child_box_w = min(150, usable_w // cols - 20) child_box_h = min(60, usable_h // rows - 20) # Starting position for grid grid_start_x = sub_overlay_x0 + 30 grid_start_y = sub_overlay_y0 + 50 for ci, child in enumerate(children): col_idx = ci % cols row_idx = ci // cols # Default grid position cx = grid_start_x + col_idx * (child_box_w + 25) + child_box_w // 2 cy = grid_start_y + row_idx * (child_box_h + 25) + child_box_h // 2 # Check if child has lat/lon - if so, draw at that location instead child_lat = child.get('lat') child_lon = child.get('lon') if child_lat and child_lon and str(child_lat).strip() and str(child_lon).strip(): try: child_lat_f = float(child_lat) child_lon_f = float(child_lon) cx, cy = snapshot_lonlat_to_pixel( child_lon_f, child_lat_f, (st.session_state['map_center'][1], st.session_state['map_center'][0]), st.session_state['map_zoom'], w, h ) except (ValueError, TypeError): pass child_name = child.get('name', f'Sub-sub {ci + 1}') child_scale = float(child.get('box_scale', 0.8) or 0.8) # Calculate box dimensions if font: name_bbox = draw.textbbox((0, 0), child_name, font=font) name_w = name_bbox[2] - name_bbox[0] name_h = name_bbox[3] - name_bbox[1] else: name_w = len(child_name) * 6 name_h = 10 box_w_child = int(name_w * child_scale + 24) box_h_child = int(name_h * child_scale + 18) cx0 = int(cx - box_w_child // 2) cy0 = int(cy - box_h_child // 2) cx1 = cx0 + box_w_child cy1 = cy0 + box_h_child # Draw child box with purple/violet color (to distinguish from subprocesses) draw.rectangle([cx0, cy0, cx1, cy1], fill=(245, 230, 255, 245), # Light purple outline=(128, 0, 128, 255), # Purple border width=2) # Center text text_x = int(cx - name_w // 2) text_y = int(cy - name_h // 2) if font: draw.text((text_x, text_y), child_name, fill=(75, 0, 75, 255), font=font) else: draw.text((text_x, text_y), child_name, fill=(75, 0, 75, 255)) # Draw stream arrows for sub-subprocess child_streams = child.get('streams', []) or [] if child_streams: child_arrow_len = 35 # slightly shorter arrows for sub-subprocesses child_stream_spacing = 40 n_child_streams = len(child_streams) child_box_center_x = (cx0 + cx1) / 2 for cs_i, cs in enumerate(child_streams): cs_offset = (cs_i - (n_child_streams - 1)/2) * child_stream_spacing cs_x = int(child_box_center_x + cs_offset) # Parse temperatures from stream cs_tin_raw = cs.get('temp_in', '') cs_tout_raw = cs.get('temp_out', '') # Also check values dict for property-based streams if not cs_tin_raw and cs.get('properties', {}).get('prop1') == 'Tin': cs_tin_raw = cs.get('values', {}).get('val1', '') if not cs_tout_raw and cs.get('properties', {}).get('prop2') == 'Tout': cs_tout_raw = cs.get('values', {}).get('val2', '') try: cs_tin_val = float(str(cs_tin_raw).strip()) except (ValueError, TypeError): cs_tin_val = None try: cs_tout_val = float(str(cs_tout_raw).strip()) except (ValueError, TypeError): cs_tout_val = None # Color logic if cs_tin_val is not None and cs_tout_val is not None: cs_is_cooling = cs_tin_val > cs_tout_val cs_col = (200, 25, 25, 255) if cs_is_cooling else (25, 80, 200, 255) else: cs_col = (90, 90, 90, 255) # Inbound arrow (above box pointing downward INTO box) cs_inbound_bottom = cy0 - 2 cs_inbound_top = cs_inbound_bottom - child_arrow_len # Draw arrow line draw.line([(cs_x, cs_inbound_top), (cs_x, cs_inbound_bottom)], fill=cs_col, width=3) # Draw arrow head draw.polygon([ (cs_x, cs_inbound_bottom), (cs_x - 5, cs_inbound_bottom - 8), (cs_x + 5, cs_inbound_bottom - 8) ], fill=cs_col) # Outbound arrow (below box pointing downward AWAY from box) cs_outbound_top = cy1 + 2 cs_outbound_bottom = cs_outbound_top + child_arrow_len # Draw arrow line draw.line([(cs_x, cs_outbound_top), (cs_x, cs_outbound_bottom)], fill=cs_col, width=3) # Draw arrow head draw.polygon([ (cs_x, cs_outbound_bottom), (cs_x - 5, cs_outbound_bottom - 8), (cs_x + 5, cs_outbound_bottom - 8) ], fill=cs_col) except (ValueError, TypeError): continue # Present snapshot full width (base selector moved to top bar) img = base_img # for coordinate capture # Save the rendered subprocess canvas image to session state for report generation # This captures the exact view with all connections, energy data, colors, etc. process_subprocess_map_exp = st.session_state.get('process_subprocess_map_expanded', {}) any_subprocess_view_active = any(process_subprocess_map_exp.get(g, False) for g in process_subprocess_map_exp) if any_subprocess_view_active: # Save as PNG bytes subprocess_canvas_buffer = BytesIO() base_img.save(subprocess_canvas_buffer, format='PNG') st.session_state['subprocess_canvas_image'] = subprocess_canvas_buffer.getvalue() coords = streamlit_image_coordinates(img, key="meas_img", width=w) # Check for stream circle clicks (when not in placement mode) if coords is not None and not st.session_state['placement_mode']: x_click, y_click = coords['x'], coords['y'] # Check if click is within any circle clicked_stream = None for circle in stream_circles: import math as _m dist = _m.sqrt((x_click - circle['x'])**2 + (y_click - circle['y'])**2) if dist <= circle['radius']: clicked_stream = circle break if clicked_stream: # Display stream information st.info(f"**{clicked_stream['stream_name']}** ({clicked_stream['stream_type']})") info_cols = st.columns(2) with info_cols[0]: st.write(f"**Process:** {clicked_stream['process']}") if 'subprocess' in clicked_stream: st.write(f"**Subprocess:** {clicked_stream['subprocess']}") st.write(f"**Type:** {clicked_stream['stream_type']}") with info_cols[1]: st.write(f"**Tin:** {clicked_stream['tin']} °C") st.write(f"**Tout:** {clicked_stream['tout']} °C") if clicked_stream.get('CP'): st.write(f"**CP:** {clicked_stream['CP']} kW/K") if clicked_stream.get('mdot'): st.write(f"**ṁ:** {clicked_stream['mdot']}") if clicked_stream.get('cp'): st.write(f"**cp:** {clicked_stream['cp']}") st.success(f"**Heat Power (Q): {clicked_stream['Q']} kW**") if st.session_state['placement_mode'] and coords is not None and st.session_state.get('placing_process_idx') is not None: x_px, y_px = coords['x'], coords['y'] lon_new, lat_new = snapshot_pixel_to_lonlat(x_px, y_px, st.session_state['map_center'][::-1], st.session_state['map_zoom'], w, h) placing_idx = st.session_state['placing_process_idx'] try: if isinstance(placing_idx, str) and placing_idx.startswith('group_'): # Group placement group_idx = int(placing_idx.split('_')[1]) group_name = st.session_state['proc_group_names'][group_idx] st.session_state['proc_group_coordinates'][group_idx] = { 'lat': round(lat_new, 6), 'lon': round(lon_new, 6) } st.session_state['ui_status_msg'] = f"✅ Process {group_name} placed at ({lat_new:.6f}, {lon_new:.6f})" # Auto-disable placement mode after successful placement st.session_state['placement_mode'] = False st.session_state['placing_process_idx'] = None st.rerun() elif isinstance(placing_idx, str) and placing_idx.startswith('child_'): # Child node placement (sub-subprocess, sub-sub-subprocess, etc.) # The child node reference should be stored in session state child_node = st.session_state.get('placing_node_ref') if child_node is not None: child_node['lat'] = round(lat_new, 6) child_node['lon'] = round(lon_new, 6) child_name = child_node.get('name', 'Child') st.session_state['ui_status_msg'] = f"✅ {child_name} placed at ({lat_new:.6f}, {lon_new:.6f})" # Auto-disable placement mode after successful placement st.session_state['placement_mode'] = False st.session_state['placing_process_idx'] = None st.session_state['placing_node_ref'] = None st.rerun() elif isinstance(placing_idx, int): # Subprocess placement process_name = st.session_state['processes'][placing_idx].get('name') or f"Subprocess {placing_idx+1}" st.session_state['processes'][placing_idx]['lat'] = round(lat_new, 6) st.session_state['processes'][placing_idx]['lon'] = round(lon_new, 6) st.session_state['ui_status_msg'] = f"✅ {process_name} placed at ({lat_new:.6f}, {lon_new:.6f})" # Auto-disable placement mode after successful placement st.session_state['placement_mode'] = False st.session_state['placing_process_idx'] = None st.rerun() except (ValueError, TypeError, IndexError): st.error("Failed to set coordinates") if st.session_state['measure_mode']: if coords is not None and len(st.session_state['measure_points']) < 2: st.session_state['measure_points'].append((coords['x'], coords['y'])) st.session_state['measure_points'] = st.session_state['measure_points'][-2:] # Compute distance if two points selected (store in session for status bar) if len(st.session_state['measure_points']) == 2: x1, y1 = st.session_state['measure_points'][0] x2, y2 = st.session_state['measure_points'][1] lon1, lat1 = snapshot_pixel_to_lonlat(x1, y1, st.session_state['map_center'][::-1], st.session_state['map_zoom'], w, h) lon2, lat2 = snapshot_pixel_to_lonlat(x2, y2, st.session_state['map_center'][::-1], st.session_state['map_zoom'], w, h) def haversine(_lat1, _lon1, _lat2, _lon2): RADIUS_EARTH_M = 6371000 phi1 = radians(_lat1) phi2 = radians(_lat2) dphi = radians(_lat2 - _lat1) dlambda = radians(_lon2 - _lon1) a_val = sin(dphi/2)**2 + cos(phi1)*cos(phi2)*sin(dlambda/2)**2 c_val = 2*atan2(sqrt(a_val), sqrt(1-a_val)) return RADIUS_EARTH_M * c_val dist_m = haversine(lat1, lon1, lat2, lon2) st.session_state['measure_distance_m'] = dist_m else: # clear stored distance if points < 2 st.session_state['measure_distance_m'] = None # Stream data table for expanded subprocess view process_subprocess_map_exp = st.session_state.get('process_subprocess_map_expanded', {}) subprocess_map_exp = st.session_state.get('subprocess_map_expanded', {}) # Check if any subprocess view is expanded if any(process_subprocess_map_exp.get(g, False) for g in process_subprocess_map_exp): st.markdown("---") st.markdown("**📊 Subprocess Streams Overview**") # Find which process group is expanded for proc_group_idx, is_expanded in process_subprocess_map_exp.items(): if is_expanded: proc_group_idx_int = int(proc_group_idx) if isinstance(proc_group_idx, str) else proc_group_idx proc_groups = st.session_state.get('proc_groups', []) if proc_group_idx_int < len(proc_groups): subprocess_indices = proc_groups[proc_group_idx_int] # Collect all streams from all subprocesses in this process group all_stream_data = [] for sub_idx in subprocess_indices: if sub_idx < len(st.session_state.get('processes', [])): subprocess = st.session_state['processes'][sub_idx] subprocess_name = subprocess.get('name', f'Subprocess {sub_idx + 1}') streams = subprocess.get('streams', []) for stream in streams: sv = stream.get('stream_values', {}) stream_data = { 'Subprocess': subprocess_name, 'Stream Name': stream.get('name', 'Unnamed'), 'Type': stream.get('type', 'product'), 'Tin (°C)': sv.get('Tin', '') or stream.get('temp_in', ''), 'Tout (°C)': sv.get('Tout', '') or stream.get('temp_out', ''), 'ṁ': sv.get('ṁ', '') or stream.get('mdot', ''), 'cp': sv.get('cp', '') or stream.get('cp', ''), 'CP': sv.get('CP', '') or stream.get('CP', ''), } # Calculate Q if possible try: tin = stream_data['Tin (°C)'] tout = stream_data['Tout (°C)'] if tin and tout: tin_f = float(tin) tout_f = float(tout) delta_T = abs(tout_f - tin_f) CP_val = stream_data['CP'] mdot = stream_data['ṁ'] cp = stream_data['cp'] if CP_val: Q = float(CP_val) * delta_T stream_data['Q (kW)'] = f"{Q:.2f}" elif mdot and cp: Q = float(mdot) * float(cp) * delta_T stream_data['Q (kW)'] = f"{Q:.2f}" else: stream_data['Q (kW)'] = 'N/A' else: stream_data['Q (kW)'] = 'N/A' except (ValueError, TypeError): stream_data['Q (kW)'] = 'N/A' all_stream_data.append(stream_data) if all_stream_data: df = pd.DataFrame(all_stream_data) st.dataframe(df, width='stretch', hide_index=True) else: st.info("No streams found in this subprocess view") else: st.warning(f"Process group index {proc_group_idx_int} out of range") break # Check if any sub-subprocess view is expanded elif any(subprocess_map_exp.get(s, False) for s in subprocess_map_exp): st.markdown("---") st.markdown("**📊 Sub-subprocess Streams Overview**") # Find which subprocess is expanded for subprocess_idx, is_expanded in subprocess_map_exp.items(): if is_expanded: subprocess_idx_int = int(subprocess_idx) if isinstance(subprocess_idx, str) else subprocess_idx if subprocess_idx_int < len(st.session_state.get('processes', [])): subprocess = st.session_state['processes'][subprocess_idx_int] subprocess_name = subprocess.get('name', f'Subprocess {subprocess_idx_int + 1}') children = subprocess.get('children', []) # Collect all streams from all sub-subprocesses all_stream_data = [] for child in children: child_name = child.get('name', 'Unnamed') streams = child.get('streams', []) for stream in streams: sv = stream.get('stream_values', {}) stream_data = { 'Sub-subprocess': child_name, 'Stream Name': stream.get('name', 'Unnamed'), 'Type': stream.get('type', 'product'), 'Tin (°C)': sv.get('Tin', '') or stream.get('temp_in', ''), 'Tout (°C)': sv.get('Tout', '') or stream.get('temp_out', ''), 'ṁ': sv.get('ṁ', '') or stream.get('mdot', ''), 'cp': sv.get('cp', '') or stream.get('cp', ''), 'CP': sv.get('CP', '') or stream.get('CP', ''), } # Calculate Q if possible try: tin = stream_data['Tin (°C)'] tout = stream_data['Tout (°C)'] if tin and tout: tin_f = float(tin) tout_f = float(tout) delta_T = abs(tout_f - tin_f) CP_val = stream_data['CP'] mdot = stream_data['ṁ'] cp = stream_data['cp'] if CP_val: Q = float(CP_val) * delta_T stream_data['Q (kW)'] = f"{Q:.2f}" elif mdot and cp: Q = float(mdot) * float(cp) * delta_T stream_data['Q (kW)'] = f"{Q:.2f}" else: stream_data['Q (kW)'] = 'N/A' else: stream_data['Q (kW)'] = 'N/A' except (ValueError, TypeError): stream_data['Q (kW)'] = 'N/A' all_stream_data.append(stream_data) if all_stream_data: df = pd.DataFrame(all_stream_data) st.dataframe(df, width='stretch', hide_index=True) else: st.info("No streams found in this sub-subprocess view") break # Notes section below the map st.markdown("---") st.markdown("**📝 Notes**") # Initialize notes in session state if not present if 'project_notes' not in st.session_state: st.session_state['project_notes'] = "" st.session_state['project_notes'] = st.text_area( "Project Notes", value=st.session_state.get('project_notes', ''), height=150, key="notes_text_area", label_visibility="collapsed", placeholder="Enter notes about your project here..." ) else: st.warning("Snapshot missing. Unlock and re-capture if needed.")