Spaces:
Sleeping
Sleeping
| """ | |
| Generic Interdependence Analysis Dashboard | |
| Β© Benjamin R. Berton 2025 Polytechnique Montreal | |
| Parametric for any Human-Autonomy Team configuration. | |
| Auto-detects team structure from CSV or allows manual definition. | |
| """ | |
| import dash | |
| from dash import html, dcc, dash_table, Input, Output, State, callback_context | |
| import plotly.graph_objects as go | |
| import pandas as pd | |
| import os | |
| import base64 | |
| import io | |
| import textwrap | |
| import time | |
| import pathlib | |
| # Path to the bundled example CSV (works locally and in deployment) | |
| _HERE = pathlib.Path(__file__).parent | |
| _EXAMPLE_CANDIDATES = [_HERE / "V7" / "IA_V7.csv", _HERE / "IA_V7.csv"] | |
| EXAMPLE_CSV = next((p for p in _EXAMPLE_CANDIDATES if p.exists()), None) | |
| app = dash.Dash(__name__, suppress_callback_exceptions=True, external_stylesheets=["assets/styles.css"]) | |
| server = app.server | |
| # βββ Design Palette ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Edit these to re-skin all graphs / inline styles in one place. | |
| BG = "#f3f0eb" | |
| SURFACE = "#f7f7f5" | |
| SURFACE = "#f7f7f5" | |
| INK = "#1a1a1a" | |
| INK_MUTED = "#313131" | |
| BORDER = "#b0ada6" | |
| ACCENT = "#b42806" # also used for highlights | |
| DARK_GREY = "#1F1F1F" | |
| # Semantic colours (mapped from the IA red/yellow/green/orange scheme) | |
| PAL_RED = "#b40606" | |
| PAL_ORANGE = "#d95e21" | |
| PAL_YELLOW = "#f5e74e" | |
| PAL_GREEN = "#1b7f41" | |
| # Map used by style_table() and dot colours | |
| COLOR_MAP = { | |
| "red": PAL_RED, | |
| "orange": PAL_ORANGE, | |
| "yellow": PAL_YELLOW, | |
| "green": PAL_GREEN, | |
| } | |
| # Lighter variants for pie charts / secondary usage | |
| COLOR_MAP_LIGHT = { | |
| "red": "#d44040", | |
| "orange": "#de7642", | |
| "yellow": "#f5e74e", | |
| "green": "#1b7f41", | |
| } | |
| # Shade families for grouped bar charts (capacity charts) | |
| COLOR_SHADES = { | |
| "green": [PAL_GREEN, "#1b7f41", "#2a8f52", "#3a9f63", "#4abf74", "#4f8a5c"], | |
| "yellow": [PAL_YELLOW, "#f5e74e", "#f7f9a8", "#f9fbc4", "#fbe98a", "#c9b43e"], | |
| "orange": [PAL_ORANGE, "#d88a5c", "#e09c73", "#b45a2e", "#c47648", "#a04e22"], | |
| } | |
| # βββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| COLOR_OPTIONS = ["red", "yellow", "green", "orange"] | |
| VALID_COLORS = {"red", "yellow", "green", "orange"} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # UTILITY FUNCTIONS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def wrap_text(text, max_width=30): | |
| if not isinstance(text, str): | |
| return "" | |
| return "<br>".join(textwrap.wrap(text, width=max_width)) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TEAM CONFIGURATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def detect_team_config(df): | |
| """ | |
| Auto-detect team configuration from a DataFrame. | |
| Identifies color-valued columns as agent role columns, then groups them | |
| into team alternatives using a state machine that detects transitions | |
| between performer (*) and supporter (no *) column groups. | |
| Returns a config dict or None if detection fails. | |
| """ | |
| # 1. Find columns whose values are mostly valid colors | |
| color_columns = [] | |
| for col in df.columns: | |
| vals = df[col].dropna().astype(str).str.strip().str.lower() | |
| if len(vals) > 0 and vals.isin(VALID_COLORS).sum() / len(vals) > 0.3: | |
| color_columns.append(col) | |
| if not color_columns: | |
| return None | |
| # 2. Parse team alternatives with a state machine | |
| # Rule: consecutive performer columns (*) form one group, then consecutive | |
| # supporter columns form another. When we see a performer after supporters, | |
| # a new alternative begins. | |
| alternatives = [] | |
| current_alt = {"performers": [], "supporters": []} | |
| state = "start" | |
| for col in color_columns: | |
| is_performer = col.endswith("*") | |
| if is_performer: | |
| if state == "in_supporters": | |
| # Transition supporterβperformer = new alternative | |
| alternatives.append(current_alt) | |
| current_alt = {"performers": [], "supporters": []} | |
| current_alt["performers"].append(col) | |
| state = "in_performers" | |
| else: | |
| current_alt["supporters"].append(col) | |
| state = "in_supporters" | |
| if current_alt["performers"] or current_alt["supporters"]: | |
| alternatives.append(current_alt) | |
| # 3. Extract unique agent base names (preserving first-seen order) | |
| agent_names = [] | |
| seen = set() | |
| for alt in alternatives: | |
| for p in alt["performers"]: | |
| base = p.rstrip("*") | |
| if base not in seen: | |
| agent_names.append(base) | |
| seen.add(base) | |
| for s in alt["supporters"]: | |
| if s not in seen: | |
| agent_names.append(s) | |
| seen.add(s) | |
| agents = [] | |
| for name in agent_names: | |
| atype = "human" if name.lower() in ["human", "pilot", "operator", "crew"] else "autonomous" | |
| agents.append({"name": name, "type": atype}) | |
| # 4. Detect the task description column | |
| task_column = None | |
| for candidate in ["Task Object", "Task"]: | |
| if candidate in df.columns: | |
| task_column = candidate | |
| break | |
| if task_column is None: | |
| color_set = set(color_columns) | |
| skip = {"Row", "Procedure"} | |
| for col in df.columns: | |
| if col not in skip and col not in color_set: | |
| if df[col].dropna().dtype == object: | |
| task_column = col | |
| break | |
| # 4b. Detect the procedure column (hierarchical grouping above tasks) | |
| procedure_column = None | |
| for candidate in ["Procedure", "procedure", "Phase", "Group", "Section"]: | |
| if candidate in df.columns: | |
| procedure_column = candidate | |
| break | |
| # 4c. Detect the category column (used for Automation Proportion) | |
| category_column = None | |
| color_set = set(color_columns) | |
| structural_set = {"Row", procedure_column or "Procedure", task_column or "Task"} | |
| for candidate in ["Category", "Type", "Classification", "Class", "Stage"]: | |
| if candidate in df.columns: | |
| category_column = candidate | |
| break | |
| if category_column is None: | |
| for col in df.columns: | |
| if col in structural_set or col in color_set: | |
| continue | |
| vals = df[col].dropna().astype(str) | |
| # Heuristic: few unique values relative to row count β categorical | |
| if 1 < vals.nunique() <= max(10, len(df) * 0.2): | |
| category_column = col | |
| break | |
| # 5. Identify metadata columns (everything not Row/structural/task/agent/category) | |
| structural = {"Row", procedure_column or "Procedure"} | |
| if task_column: | |
| structural.add(task_column) | |
| if category_column: | |
| structural.add(category_column) | |
| color_set = set(color_columns) | |
| metadata = [c for c in df.columns if c not in structural and c not in color_set] | |
| config = { | |
| "agents": agents, | |
| "alternatives": [ | |
| {"name": f"Team Alternative {i+1}", **alt} | |
| for i, alt in enumerate(alternatives) | |
| ], | |
| "task_column": task_column or "Task", | |
| "procedure_column": procedure_column or "Procedure", | |
| "category_column": category_column, | |
| "color_columns": color_columns, | |
| "metadata_columns": metadata, | |
| "all_columns": list(df.columns), | |
| } | |
| return config | |
| def build_config_from_manual(column_str, task_col="Task", procedure_col="Procedure", category_col=None): | |
| """ | |
| Build team config from a manual column specification string. | |
| Example input: "Human*, TARS, TARS*, Human" | |
| This will be parsed with the same state machine as CSV detection. | |
| """ | |
| cols = [c.strip() for c in column_str.split(",") if c.strip()] | |
| if not cols: | |
| return None | |
| # Parse alternatives | |
| alternatives = [] | |
| current_alt = {"performers": [], "supporters": []} | |
| state = "start" | |
| for col in cols: | |
| is_perf = col.endswith("*") | |
| if is_perf: | |
| if state == "in_supporters": | |
| alternatives.append(current_alt) | |
| current_alt = {"performers": [], "supporters": []} | |
| current_alt["performers"].append(col) | |
| state = "in_performers" | |
| else: | |
| current_alt["supporters"].append(col) | |
| state = "in_supporters" | |
| if current_alt["performers"] or current_alt["supporters"]: | |
| alternatives.append(current_alt) | |
| # Extract agents | |
| agent_names = [] | |
| seen = set() | |
| for alt in alternatives: | |
| for p in alt["performers"]: | |
| base = p.rstrip("*") | |
| if base not in seen: | |
| agent_names.append(base) | |
| seen.add(base) | |
| for s in alt["supporters"]: | |
| if s not in seen: | |
| agent_names.append(s) | |
| seen.add(s) | |
| agents = [] | |
| for name in agent_names: | |
| atype = "human" if name.lower() in ["human", "pilot", "operator", "crew"] else "autonomous" | |
| agents.append({"name": name, "type": atype}) | |
| extra_cols = ["Observability", "Predictability", "Directability"] | |
| struct_cols = ["Row", procedure_col, task_col] | |
| if category_col and category_col not in struct_cols: | |
| struct_cols.insert(3, category_col) # insert after task_col | |
| all_columns = struct_cols + cols + extra_cols | |
| config = { | |
| "agents": agents, | |
| "alternatives": [ | |
| {"name": f"Team Alternative {i+1}", **alt} | |
| for i, alt in enumerate(alternatives) | |
| ], | |
| "task_column": task_col, | |
| "procedure_column": procedure_col, | |
| "category_column": category_col, | |
| "color_columns": cols, | |
| "metadata_columns": extra_cols, | |
| "all_columns": all_columns, | |
| } | |
| return config | |
| # βββ Config helper accessors ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_agent_columns(config): | |
| """Ordered list of all agent (color) columns.""" | |
| return config.get("color_columns", []) | |
| def get_performer_columns(config): | |
| """All performer columns (ending with *) across all alternatives.""" | |
| out = [] | |
| for alt in config.get("alternatives", []): | |
| out.extend(alt["performers"]) | |
| return out | |
| def get_supporter_columns(config): | |
| """All supporter columns (no *) across all alternatives.""" | |
| out = [] | |
| for alt in config.get("alternatives", []): | |
| out.extend(alt["supporters"]) | |
| return out | |
| def get_chosen_performer(row, config, strategy, category_overrides=None): | |
| """ | |
| Determine the chosen performer column for a task row based on the strategy. | |
| Strategies: | |
| - human_baseline / human_full_support: prefer human-type performers | |
| - agent_whenever_possible / agent_whenever_possible_full_support: prefer autonomous performers | |
| - most_reliable: best color (green > yellow), human preferred in ties | |
| Returns column name (e.g. "Human*") or None. | |
| """ | |
| performer_cols = get_performer_columns(config) | |
| agent_types = {a["name"]: a["type"] for a in config["agents"]} | |
| COLOR_PRIORITY = {"green": 1, "yellow": 2, "orange": 3} | |
| # Gather available performers (non-red) | |
| available = {} | |
| for pc in performer_cols: | |
| val = str(row.get(pc, "") or "").strip().lower() | |
| if val in VALID_COLORS and val != "red": | |
| available[pc] = val | |
| if not available: | |
| return None | |
| # Category override check | |
| if category_overrides: | |
| cat_col = config.get("category_column") or "Category" | |
| cat = str(row.get(cat_col, "") or "").strip() | |
| if cat in category_overrides: | |
| override_type = category_overrides[cat].lower() # "human" or "autonomous" | |
| preferred = { | |
| pc: c for pc, c in available.items() | |
| if agent_types.get(pc.rstrip("*"), "").lower() == override_type | |
| } | |
| if preferred: | |
| return min(preferred, key=lambda pc: COLOR_PRIORITY.get(preferred[pc], 999)) | |
| return min(available, key=lambda pc: COLOR_PRIORITY.get(available[pc], 999)) | |
| if strategy in ("human_baseline", "human_full_support"): | |
| human_perfs = { | |
| pc: c for pc, c in available.items() | |
| if agent_types.get(pc.rstrip("*"), "").lower() == "human" | |
| } | |
| if human_perfs: | |
| return min(human_perfs, key=lambda pc: COLOR_PRIORITY.get(human_perfs[pc], 999)) | |
| return None | |
| elif strategy in ("agent_whenever_possible", "agent_whenever_possible_full_support"): | |
| auto_perfs = { | |
| pc: c for pc, c in available.items() | |
| if agent_types.get(pc.rstrip("*"), "").lower() == "autonomous" | |
| } | |
| if auto_perfs: | |
| return min(auto_perfs, key=lambda pc: COLOR_PRIORITY.get(auto_perfs[pc], 999)) | |
| human_perfs = { | |
| pc: c for pc, c in available.items() | |
| if agent_types.get(pc.rstrip("*"), "").lower() == "human" | |
| } | |
| if human_perfs: | |
| return min(human_perfs, key=lambda pc: COLOR_PRIORITY.get(human_perfs[pc], 999)) | |
| return None | |
| elif strategy == "most_reliable": | |
| def sort_key(pc): | |
| cprio = COLOR_PRIORITY.get(available[pc], 999) | |
| tprio = 0 if agent_types.get(pc.rstrip("*"), "").lower() == "human" else 1 | |
| return (cprio, tprio) | |
| return min(available, key=sort_key) | |
| return None | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TABLE BUILDING | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_table_columns(config): | |
| """Build DataTable column definitions with 3-level merged headers. | |
| Row 0 (top) : "Activity Decomposition" | "Capacity Assessment" | "" (metadata) | |
| Row 1 (mid) : "" | "Team Alternative N" | "" | |
| Row 2 (bottom): actual column name | |
| """ | |
| agent_set = set(get_agent_columns(config)) | |
| # Map every agent column β its alternative label | |
| col_to_alt: dict[str, str] = {} | |
| for i, alt in enumerate(config.get("alternatives", []), 1): | |
| label = alt.get("name") or f"Team Alternative {i}" | |
| for col in alt.get("performers", []) + alt.get("supporters", []): | |
| col_to_alt[col] = label | |
| all_columns = config.get("all_columns", []) | |
| agent_indices = [i for i, c in enumerate(all_columns) if c in agent_set] | |
| first_agent_idx = min(agent_indices) if agent_indices else len(all_columns) | |
| # Columns that belong under the "Teaming Requirements" group header | |
| TEAMING_COLS = {"Observability", "Predictability", "Directability"} | |
| columns = [] | |
| for idx, col in enumerate(all_columns): | |
| if col in agent_set: | |
| alt_label = col_to_alt.get(col, "Team Alternative ?") | |
| name = ["Capacity Assessment", alt_label, col] | |
| elif idx < first_agent_idx: | |
| name = ["Activity Decomposition", " ", col] | |
| elif col in TEAMING_COLS: | |
| name = ["Teaming Requirements", " ", col] | |
| else: | |
| # Other metadata columns after the agent block (unique spacing | |
| # prevents accidental merging with neighbouring groups) | |
| name = [" ", " ", col] | |
| d = {"name": name, "id": col} | |
| if col == "Row": | |
| d["editable"] = False | |
| elif col in agent_set: | |
| d["editable"] = True | |
| d["presentation"] = "dropdown" | |
| else: | |
| d["editable"] = True | |
| columns.append(d) | |
| return columns | |
| def build_dropdowns(config): | |
| return { | |
| col: {"options": [{"label": c.capitalize(), "value": c} for c in COLOR_OPTIONS]} | |
| for col in get_agent_columns(config) | |
| } | |
| def build_borders(config): | |
| """Thick borders to visually separate team alternatives.""" | |
| borders = [] | |
| for alt in config.get("alternatives", []): | |
| all_in_alt = alt["performers"] + alt["supporters"] | |
| if all_in_alt: | |
| borders.append( | |
| {"if": {"column_id": all_in_alt[0]}, "borderLeft": "3px solid black"} | |
| ) | |
| borders.append( | |
| {"if": {"column_id": all_in_alt[-1]}, "borderRight": "3px solid black"} | |
| ) | |
| return borders | |
| def style_table(df, config): | |
| """Conditional styles: color cells match their value. | |
| Uses filter_query so styles update live when the user edits a cell. | |
| """ | |
| agent_cols = get_agent_columns(config) | |
| styles = [] | |
| for col in agent_cols: | |
| for color in VALID_COLORS: | |
| for variant in [color, color.capitalize(), color.upper()]: | |
| styles.append({ | |
| "if": { | |
| "filter_query": f"{{{col}}} = '{variant}'", | |
| "column_id": col, | |
| }, | |
| "backgroundColor": COLOR_MAP.get(color, color), | |
| "color": COLOR_MAP.get(color, color), | |
| "textAlign": "center", | |
| "fontWeight": "bold", | |
| }) | |
| return styles | |
| def style_procedure_merge(df, config): | |
| """Visual pseudo-merge for the Procedure column. | |
| - Continuation rows (same Procedure as the row above): Procedure cell text | |
| is made transparent so the cell appears empty, mimicking a merged cell. | |
| - First row of each new Procedure group (after the very first): a top | |
| separator line is drawn across every column to visually delimit clusters. | |
| """ | |
| proc_col = config.get("procedure_column", "Procedure") | |
| if proc_col not in df.columns or df.empty: | |
| return [] | |
| all_cols = config.get("all_columns", []) | |
| styles = [] | |
| df_reset = df.reset_index(drop=True) # ensure 0-based positional index | |
| prev_proc = None | |
| for i in range(len(df_reset)): | |
| proc = str(df_reset.at[i, proc_col]).strip() | |
| if proc == prev_proc: | |
| # Continuation row β hide repeated Procedure label | |
| styles.append({ | |
| "if": {"row_index": i, "column_id": proc_col}, | |
| "color": "transparent", | |
| "borderTop": "1px solid #e8e8e8", # keep a very faint line | |
| }) | |
| else: | |
| # First row of a new group β draw a visible separator | |
| if i > 0: | |
| for col in all_cols: | |
| styles.append({ | |
| "if": {"row_index": i, "column_id": col}, | |
| "borderTop": "2px solid #555555", | |
| }) | |
| prev_proc = proc | |
| return styles | |
| # Columns always shown when present; everything else is hidden by default. | |
| # Agent columns from the config are also always shown. | |
| DEFAULT_VISIBLE_COLUMNS = { | |
| "Row", "Procedure", "Class", "Type", "Category", "Task", "Task Object", | |
| "Object", "Value", | |
| "Observability", "Predictability", "Directability", | |
| "TARS Performer Role", "TARS Supporter Role", | |
| } | |
| def build_hidden_columns(config): | |
| """Return a list of column IDs that should be hidden by default.""" | |
| agent_set = set(get_agent_columns(config)) | |
| visible = DEFAULT_VISIBLE_COLUMNS | agent_set | |
| return [c for c in config.get("all_columns", []) if c not in visible] | |
| def build_data_table(df, config): | |
| """Create a new DataTable component from a DataFrame and config.""" | |
| hidden = build_hidden_columns(config) | |
| return dash_table.DataTable( | |
| id="responsibility-table", | |
| columns=build_table_columns(config), | |
| data=df.to_dict("records"), | |
| editable=True, | |
| row_deletable=True, | |
| hidden_columns=hidden, | |
| merge_duplicate_headers=True, | |
| dropdown=build_dropdowns(config), | |
| style_data_conditional=style_table(df, config) + style_procedure_merge(df, config), | |
| style_cell={"textAlign": "left", "padding": "5px", "whiteSpace": "normal", | |
| "fontFamily": "'Space Grotesk', 'Inter', sans-serif", | |
| "backgroundColor": BG, "color": INK, "border": f"1px solid {BORDER}"}, | |
| style_cell_conditional=build_borders(config), | |
| style_header={"fontWeight": "bold", "textAlign": "center"}, | |
| style_header_conditional=[ | |
| # Row 0 β top group labels | |
| { | |
| "if": {"header_index": 0}, | |
| "backgroundColor": INK, | |
| "color": BG, | |
| "fontSize": "13px", | |
| "borderBottom": f"2px solid {BG}", | |
| "fontFamily": "'Space Grotesk', 'Inter', sans-serif", | |
| "letterSpacing": "0.04em", | |
| "textTransform": "uppercase", | |
| }, | |
| # Row 1 β team alternative labels | |
| { | |
| "if": {"header_index": 1}, | |
| "backgroundColor": "#3a3a3a", | |
| "color": BG, | |
| "fontSize": "12px", | |
| "borderBottom": f"2px solid {BG}", | |
| "fontFamily": "'Space Grotesk', 'Inter', sans-serif", | |
| }, | |
| # Row 2 β column names (standard) | |
| { | |
| "if": {"header_index": 2}, | |
| "backgroundColor": SURFACE, | |
| "color": INK, | |
| "fontSize": "12px", | |
| "fontFamily": "'Space Grotesk', 'Inter', sans-serif", | |
| }, | |
| ], | |
| style_table={"overflowX": "auto", "border": f"2px solid {INK}"}, | |
| ) | |
| def create_empty_df(config): | |
| """Create an empty DataFrame with one blank row for a new team.""" | |
| all_cols = config.get("all_columns", ["Row", "Procedure", "Task"]) | |
| row = {c: "" for c in all_cols} | |
| row["Row"] = 1 | |
| return pd.DataFrame([row]) | |
| def ensure_row_column(df): | |
| """Make sure the Row column exists and is properly numbered.""" | |
| df = df.copy() | |
| df["Row"] = range(1, len(df) + 1) | |
| return df | |
| def config_summary_html(config): | |
| """Render team config as HTML for display.""" | |
| if not config: | |
| return html.P("No configuration loaded.") | |
| agents_str = ", ".join( | |
| [f"{a['name']} ({a['type']})" for a in config["agents"]] | |
| ) | |
| alt_items = [] | |
| for alt in config["alternatives"]: | |
| perfs = ", ".join(alt["performers"]) | |
| sups = ", ".join(alt["supporters"]) if alt["supporters"] else "None" | |
| alt_items.append( | |
| html.Li(f"{alt['name']}: Performers [{perfs}] β Supporters [{sups}]") | |
| ) | |
| # Build column list with visible ones bolded | |
| agent_set = set(get_agent_columns(config)) | |
| visible = DEFAULT_VISIBLE_COLUMNS | agent_set | |
| all_cols = config.get("all_columns", []) | |
| col_spans = [] | |
| for i, col in enumerate(all_cols): | |
| label = html.B(col) if col in visible else html.Span(col, style={"color": "var(--ink-muted)"}) | |
| col_spans.append(label) | |
| if i < len(all_cols) - 1: | |
| col_spans.append(", ") | |
| return html.Div([ | |
| html.P([html.B("Agents: "), agents_str]), | |
| html.P([html.B("Procedure column: "), config.get("procedure_column", "Procedure")]), | |
| html.P([html.B("Task column: "), config["task_column"]]), | |
| html.P([html.B("Category column: "), config.get("category_column") or "β (none detected)"]), | |
| html.P([html.B("Columns: ")] + col_spans), | |
| html.Ul(alt_items), | |
| ]) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WORKFLOW GRAPH | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_workflow_figure_base(df, config, procedure=None, view_mode="full"): | |
| """ | |
| Build the base workflow graph structure (without highlighting). | |
| Returns (fig, arrow_info) where arrow_info contains indices needed for | |
| the fast highlighting pass. | |
| """ | |
| if config is None or df.empty: | |
| return go.Figure(), None | |
| agent_cols = get_agent_columns(config) | |
| performer_cols = set(get_performer_columns(config)) | |
| task_col = config.get("task_column", "Task") | |
| # Performers-only view: filter to performer columns only | |
| if view_mode == "performers": | |
| agent_cols = [c for c in agent_cols if c in performer_cols] | |
| proc_col = config.get("procedure_column", "Procedure") if config else "Procedure" | |
| single_procedure = procedure is not None | |
| if single_procedure and proc_col in df.columns: | |
| df = df[df[proc_col] == procedure].copy() | |
| if df.empty: | |
| return go.Figure() | |
| df = df.reset_index(drop=True) | |
| df["task_idx"] = df.index | |
| tasks = df[task_col].tolist() if task_col in df.columns else [f"Task {i}" for i in range(len(df))] | |
| # ββ Y-coordinate mapping ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # When showing all procedures, insert a 1-unit gap between groups so | |
| # procedure-divider lines can be drawn in the extra space. | |
| # When filtered to a single procedure, y == task_idx (no gaps needed). | |
| GAP = 1.2 # extra y-units reserved for the divider between groups | |
| y_pos = [] # y_pos[i] = the plot y-coordinate for task i | |
| proc_dividers = [] # list of (y_between, proc_label) for separator lines | |
| procs = df[proc_col].tolist() if proc_col in df.columns else [""] * len(tasks) | |
| if single_procedure: | |
| y_pos = list(range(len(tasks))) | |
| else: | |
| y = 0.0 | |
| # Seed the first procedure label above the first task | |
| if procs: | |
| proc_dividers.append((-0.6, procs[0])) | |
| for i, task_i in enumerate(tasks): | |
| if i > 0 and procs[i] != procs[i - 1]: | |
| # mid-point of the gap between groups | |
| proc_dividers.append((y + GAP / 2 - 0.5, procs[i])) | |
| y += GAP | |
| y_pos.append(y) | |
| y += 1.0 | |
| agent_pos = {agent: i for i, agent in enumerate(agent_cols)} | |
| dots = [] # {task, y, agent, color} | |
| hover_lookup = {} # (task_idx, col) -> hover text | |
| dashed_arrows = [] | |
| # ββ Per-task processing βββββββββββββββββββββββββββββββββββββββββββββββ | |
| for i, row in df.iterrows(): | |
| task_idx = row["task_idx"] | |
| yp = y_pos[task_idx] | |
| task_label = wrap_text(str(row.get(task_col, ""))) | |
| # Place dots + precompute hover text | |
| for col in agent_cols: | |
| if col in df.columns: | |
| val = str(row.get(col, "") or "").strip().lower() | |
| if val in VALID_COLORS: | |
| dots.append({"task": task_idx, "y": yp, "agent": col, "color": val}) | |
| hover_parts = [ | |
| f"<b>Task:</b> {task_label}", | |
| f"<b>Agent:</b> {col}", | |
| ] | |
| for meta in ["Observability", "Predictability", "Directability"]: | |
| if meta in df.columns: | |
| hover_parts.append( | |
| f"<b>{meta}:</b><br>{wrap_text(str(row.get(meta, '')))}" | |
| ) | |
| hover_lookup[(task_idx, col)] = "<br><br>".join(hover_parts) | |
| # Dashed arrows: supporter β performer within each alternative | |
| for alt in config["alternatives"]: | |
| active_perfs = [ | |
| pc for pc in alt["performers"] | |
| if pc in df.columns | |
| and str(row.get(pc, "") or "").strip().lower() in VALID_COLORS | |
| and str(row.get(pc, "") or "").strip().lower() != "red" | |
| ] | |
| for sc in alt["supporters"]: | |
| if sc in df.columns: | |
| sval = str(row.get(sc, "") or "").strip().lower() | |
| if sval in VALID_COLORS and sval != "red": | |
| for pc in active_perfs: | |
| dashed_arrows.append({ | |
| "start_agent": sc, | |
| "end_agent": pc, | |
| "task": task_idx, | |
| "y": yp, | |
| }) | |
| # ββ Solid arrows between consecutive tasks ββββββββββββββββββββββββββββ | |
| performers_by_task = {} | |
| for d in dots: | |
| if d["agent"] in performer_cols and d["color"] != "red": | |
| performers_by_task.setdefault(d["task"], set()).add(d["agent"]) | |
| solid_arrows = [] | |
| for i in range(1, len(df)): | |
| for pa in performers_by_task.get(i - 1, set()): | |
| for ca in performers_by_task.get(i, set()): | |
| solid_arrows.append({ | |
| "start_task": i - 1, "start_y": y_pos[i - 1], "start_agent": pa, | |
| "end_task": i, "end_y": y_pos[i], "end_agent": ca, | |
| }) | |
| # ββ Render figure βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fig = go.Figure() | |
| # Procedure divider lines + labels (rendered first so dots sit on top) | |
| x_min = -0.5 | |
| x_max = len(agent_cols) - 0.5 | |
| # Build alt-label lookup and per-alt column groups (only cols present in agent_cols) | |
| col_to_alt: dict[str, str] = {} | |
| alt_col_groups: list[tuple[str, list[int]]] = [] # (label, [x indices]) | |
| for i, alt in enumerate(config.get("alternatives", []), 1): | |
| label = alt.get("name") or f"Alt {i}" | |
| cols_in_alt = [c for c in alt.get("performers", []) + alt.get("supporters", []) | |
| if c in agent_pos] | |
| for col in cols_in_alt: | |
| col_to_alt[col] = label | |
| if cols_in_alt: | |
| alt_col_groups.append((label, [agent_pos[c] for c in cols_in_alt])) | |
| # In performers-only view anchor the procedure label to the left edge so it | |
| # doesn't sit on top of the connector lines that run through the centre. | |
| if view_mode == "performers": | |
| label_x = x_min | |
| label_xanchor = "left" | |
| else: | |
| label_x = (x_min + x_max) / 2 | |
| label_xanchor = "center" | |
| for div_y, proc_label in proc_dividers: | |
| fig.add_shape( | |
| type="line", | |
| x0=x_min, y0=div_y, x1=x_max, y1=div_y, | |
| xref="x", yref="y", | |
| line=dict(color=INK_MUTED, width=1.5, dash="dot"), | |
| ) | |
| fig.add_annotation( | |
| x=label_x, y=div_y, | |
| xref="x", yref="y", | |
| text=f"<b>{proc_label}</b>", | |
| showarrow=False, | |
| xanchor=label_xanchor, | |
| font=dict(size=11, color=INK), | |
| bgcolor="rgba(221,217,210,0.85)", | |
| bordercolor=BORDER, | |
| borderwidth=1, | |
| borderpad=4, | |
| ) | |
| # One centred alt label per alternative group, sitting above the divider line | |
| for alt_label, x_indices in alt_col_groups: | |
| centre_x = sum(x_indices) / len(x_indices) | |
| fig.add_annotation( | |
| x=centre_x, y=div_y - 0.08, | |
| xref="x", yref="y", | |
| text=f"<i>{alt_label}</i>", | |
| showarrow=False, | |
| xanchor="center", | |
| yanchor="bottom", | |
| font=dict(size=9, color=INK_MUTED), | |
| bgcolor="rgba(0,0,0,0)", | |
| borderwidth=0, | |
| ) | |
| # Per-column agent name annotations just above the divider line | |
| for col, x_idx in agent_pos.items(): | |
| role = "Performer" if col in performer_cols else "Supporter" | |
| fig.add_annotation( | |
| x=x_idx, y=div_y - 0.22, | |
| xref="x", yref="y", | |
| text=f"<b>{col}</b><br><span style='font-size:8px'>{role}</span>", | |
| showarrow=False, | |
| xanchor="center", | |
| yanchor="bottom", | |
| font=dict(size=10, color=INK), | |
| bgcolor="rgba(0,0,0,0)", | |
| borderwidth=0, | |
| ) | |
| # One scatter trace per agent column with per-point colors β much fewer traces | |
| for col in agent_cols: | |
| col_dots = [d for d in dots if d["agent"] == col] | |
| if not col_dots: | |
| continue | |
| fig.add_trace(go.Scatter( | |
| x=[agent_pos[col]] * len(col_dots), | |
| y=[d["y"] for d in col_dots], | |
| mode="markers", | |
| marker=dict( | |
| size=20, | |
| color=[COLOR_MAP.get(d["color"], d["color"]) for d in col_dots], | |
| symbol="circle", | |
| ), | |
| showlegend=False, | |
| hoverinfo="text", | |
| hovertext=[hover_lookup.get((d["task"], col), "") for d in col_dots], | |
| )) | |
| # Group dashed arrows by task for vertical offset | |
| dashed_by_task = {} | |
| for arrow in dashed_arrows: | |
| if arrow["start_agent"] in agent_pos and arrow["end_agent"] in agent_pos: | |
| dashed_by_task.setdefault(arrow["task"], []).append(arrow) | |
| dashed_arrow_info = [] | |
| for task, arrows in dashed_by_task.items(): | |
| n = len(arrows) | |
| offsets = [0] * n if n == 1 else [ | |
| -0.08 + 0.16 * i / (n - 1) for i in range(n) | |
| ] | |
| for arrow, offset in zip(arrows, offsets): | |
| fig.add_shape( | |
| type="line", | |
| x0=agent_pos[arrow["start_agent"]], y0=arrow["y"] + offset, | |
| x1=agent_pos[arrow["end_agent"]], y1=arrow["y"] + offset, | |
| line=dict(color=INK, width=2, dash="dot"), | |
| ) | |
| dashed_arrow_info.append({"task": arrow["task"], "end_agent": arrow["end_agent"]}) | |
| solid_arrow_info = [] | |
| for arrow in solid_arrows: | |
| fig.add_annotation( | |
| x=agent_pos[arrow["end_agent"]], y=arrow["end_y"], | |
| ax=agent_pos[arrow["start_agent"]], ay=arrow["start_y"], | |
| xref="x", yref="y", axref="x", ayref="y", | |
| showarrow=True, arrowhead=3, arrowsize=1, | |
| arrowwidth=2, | |
| arrowcolor=INK, | |
| opacity=0.9, | |
| ) | |
| solid_arrow_info.append({ | |
| "start_task": arrow["start_task"], "start_agent": arrow["start_agent"], | |
| "end_task": arrow["end_task"], "end_agent": arrow["end_agent"], | |
| }) | |
| # Layout | |
| title_suffix = f" β {procedure}" if single_procedure else " (All Procedures)" | |
| y_labels = [wrap_text(str(t)) for t in tasks] | |
| # Estimated height: task rows + gap rows | |
| total_y_span = y_pos[-1] if y_pos else len(tasks) | |
| height = max(400, 100 + int(total_y_span * 80)) | |
| fig.update_layout( | |
| title=f"Workflow Graph{title_suffix}", | |
| xaxis=dict( | |
| tickvals=list(agent_pos.values()), | |
| ticktext=list(agent_pos.keys()), | |
| title="Agent", | |
| showgrid=True, gridcolor=BORDER, | |
| range=[x_min, x_max], | |
| ), | |
| yaxis=dict( | |
| tickvals=y_pos, | |
| ticktext=y_labels, | |
| title="Task", | |
| range=[(y_pos[-1] + 0.5) if y_pos else len(tasks), -1.0], | |
| showgrid=False, | |
| ), | |
| height=height, | |
| margin=dict(l=250, r=50, t=50, b=50), | |
| plot_bgcolor=BG, | |
| paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| ) | |
| arrow_info = { | |
| "n_divider_shapes": len(proc_dividers), | |
| # 1 proc-label + 1 per alt group + 1 per agent column, all per divider | |
| "n_divider_annotations": len(proc_dividers) * (1 + len(alt_col_groups) + len(agent_cols)), | |
| "dashed_arrows": dashed_arrow_info, | |
| "solid_arrows": solid_arrow_info, | |
| } | |
| return fig, arrow_info | |
| def apply_workflow_highlighting(fig_dict, arrow_info, df, config, procedure=None, | |
| highlight_track=None, category_overrides=None): | |
| """Apply highlighting to a cached base workflow figure. Fast: only updates colors/widths.""" | |
| if fig_dict is None: | |
| return go.Figure() | |
| fig = go.Figure(fig_dict) | |
| if arrow_info is None: | |
| return fig | |
| if (not highlight_track or highlight_track == "none") and not category_overrides: | |
| return fig | |
| if category_overrides is None: | |
| category_overrides = {} | |
| proc_col = config.get("procedure_column", "Procedure") if config else "Procedure" | |
| if procedure is not None and proc_col in df.columns: | |
| df = df[df[proc_col] == procedure].copy() | |
| df = df.reset_index(drop=True) | |
| df["task_idx"] = df.index | |
| should_hl_support = highlight_track in ( | |
| "human_full_support", "agent_whenever_possible_full_support", "most_reliable", | |
| ) | |
| # Build highlight set | |
| highlight_set = set() | |
| if highlight_track and highlight_track != "none": | |
| for _, row in df.iterrows(): | |
| chosen = get_chosen_performer(row, config, highlight_track, category_overrides) | |
| if chosen: | |
| highlight_set.add((row["task_idx"], chosen)) | |
| # Apply highlighting to dashed arrow shapes | |
| if fig.layout.shapes: | |
| shapes = list(fig.layout.shapes) | |
| offset = arrow_info.get("n_divider_shapes", 0) | |
| for i, info in enumerate(arrow_info.get("dashed_arrows", [])): | |
| idx = offset + i | |
| if idx < len(shapes): | |
| is_hl = should_hl_support and (info["task"], info["end_agent"]) in highlight_set | |
| shapes[idx].line.color = ACCENT if is_hl else INK | |
| shapes[idx].line.width = 4 if is_hl else 2 | |
| fig.layout.shapes = shapes | |
| # Apply highlighting to solid arrow annotations | |
| if fig.layout.annotations: | |
| annotations = list(fig.layout.annotations) | |
| offset = arrow_info.get("n_divider_annotations", 0) | |
| for i, info in enumerate(arrow_info.get("solid_arrows", [])): | |
| idx = offset + i | |
| if idx < len(annotations): | |
| is_hl = bool(highlight_set) and \ | |
| (info["start_task"], info["start_agent"]) in highlight_set and \ | |
| (info["end_task"], info["end_agent"]) in highlight_set | |
| annotations[idx].arrowcolor = ACCENT if is_hl else INK | |
| annotations[idx].arrowwidth = 4 if is_hl else 2 | |
| fig.layout.annotations = annotations | |
| return fig | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # BAR CHARTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_capacity_bar_chart(df, config): | |
| """Bar chart showing performer/supporter capacities per agent.""" | |
| if config is None or df.empty: | |
| return go.Figure() | |
| performer_cols = get_performer_columns(config) | |
| supporter_cols = get_supporter_columns(config) | |
| color_shades = COLOR_SHADES | |
| fig = go.Figure() | |
| for grade in ["green", "yellow", "orange"]: | |
| shades = color_shades[grade] | |
| # Performers | |
| for i, col in enumerate(performer_cols): | |
| base_name = col.rstrip("*") | |
| count = sum( | |
| 1 for _, row in df.iterrows() | |
| if str(row.get(col, "") or "").strip().lower() == grade | |
| ) | |
| fig.add_trace(go.Bar( | |
| name=base_name, | |
| x=[f"Performer {grade.capitalize()}"], | |
| y=[count], | |
| marker_color=shades[i % len(shades)], | |
| showlegend=False, | |
| text=[base_name], textposition="outside", textangle=0, | |
| )) | |
| # Supporters | |
| for i, col in enumerate(supporter_cols): | |
| count = sum( | |
| 1 for _, row in df.iterrows() | |
| if str(row.get(col, "") or "").strip().lower() == grade | |
| ) | |
| fig.add_trace(go.Bar( | |
| name=col, | |
| x=[f"Supporter {grade.capitalize()}"], | |
| y=[count], | |
| marker_color=shades[i % len(shades)], | |
| showlegend=False, | |
| text=[col], textposition="outside", textangle=0, | |
| )) | |
| fig.update_layout( | |
| title="Performer and Supporter Capacities", | |
| xaxis_title="Role and Capacity", | |
| yaxis_title="Number of Tasks", | |
| barmode="group", bargap=0.15, bargroupgap=0.1, | |
| plot_bgcolor=BG, paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| showlegend=False, | |
| ) | |
| return fig | |
| def build_allocation_bar_chart(df, config): | |
| """Pie chart showing task type (allocation) distribution.""" | |
| if config is None or df.empty: | |
| return go.Figure() | |
| performer_cols = get_performer_columns(config) | |
| supporter_cols = get_supporter_columns(config) | |
| single_independent = 0 | |
| multiple_independent = 0 | |
| single_interdependent = 0 | |
| multiple_interdependent = 0 | |
| for _, row in df.iterrows(): | |
| perfs = [ | |
| c for c in performer_cols | |
| if c in df.columns | |
| and str(row.get(c, "") or "").strip().lower() in VALID_COLORS | |
| and str(row.get(c, "") or "").strip().lower() != "red" | |
| ] | |
| sups = [ | |
| c for c in supporter_cols | |
| if c in df.columns | |
| and str(row.get(c, "") or "").strip().lower() in VALID_COLORS | |
| and str(row.get(c, "") or "").strip().lower() != "red" | |
| ] | |
| if len(sups) > 0: | |
| if len(perfs) == 1: | |
| single_interdependent += 1 | |
| else: | |
| multiple_interdependent += 1 | |
| elif len(perfs) == 1: | |
| single_independent += 1 | |
| elif len(perfs) > 1: | |
| multiple_independent += 1 | |
| labels = [ | |
| "Single Allocation Independent", | |
| "Multiple Allocation Independent", | |
| "Single Allocation Interdependent", | |
| "Multiple Allocation Interdependent", | |
| ] | |
| values = [single_independent, multiple_independent, single_interdependent, multiple_interdependent] | |
| # High-contrast fills: | |
| # 1. solid white | |
| # 2. white + faint grey dots | |
| # 3. white + bold ink diagonal lines | |
| # 4. dark grey solid (no pattern needed) | |
| fig = go.Figure(go.Pie( | |
| labels=labels, | |
| values=values, | |
| marker=dict( | |
| colors=["white", "white", "white", DARK_GREY], | |
| pattern=dict( | |
| shape=["", ".", "/", ""], | |
| fgcolor=[INK, BORDER, INK, DARK_GREY], | |
| size=[6, 6, 7, 6], | |
| solidity=[1.0, 0.35, 0.75, 1.0], | |
| ), | |
| line=dict(color=INK, width=2), | |
| ), | |
| textinfo="label+percent", | |
| textposition="outside", | |
| hovertemplate="%{label}<br>Count: %{value}<br>%{percent}<extra></extra>", | |
| hole=0.3, | |
| )) | |
| fig.update_layout( | |
| title="Task Type Distribution", | |
| height=520, | |
| paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| margin=dict(l=20, r=20, t=60, b=20), | |
| ) | |
| return fig | |
| def build_autonomy_bar_chart(df, config): | |
| """Pie charts showing agent autonomy (task continuity) β one pie per performer.""" | |
| if config is None or df.empty: | |
| return go.Figure() | |
| from plotly.subplots import make_subplots | |
| performer_cols = get_performer_columns(config) | |
| agent_autonomy = {c: {"autonomous": 0, "non_autonomous": 0} for c in performer_cols} | |
| prev_performers = [] | |
| for idx, row in df.iterrows(): | |
| current_performers = [] | |
| for col in performer_cols: | |
| if col in df.columns: | |
| val = str(row.get(col, "") or "").strip().lower() | |
| if val in VALID_COLORS and val != "red": | |
| current_performers.append(col) | |
| if val == "orange": | |
| agent_autonomy[col]["non_autonomous"] += 1 | |
| elif idx == 0 or col not in prev_performers: | |
| agent_autonomy[col]["non_autonomous"] += 1 | |
| else: | |
| agent_autonomy[col]["autonomous"] += 1 | |
| prev_performers = current_performers | |
| active_cols = [c for c in performer_cols if (agent_autonomy[c]["autonomous"] + agent_autonomy[c]["non_autonomous"]) > 0] | |
| if not active_cols: | |
| return go.Figure() | |
| n = len(active_cols) | |
| fig = make_subplots( | |
| rows=1, cols=n, | |
| specs=[[{"type": "pie"}] * n], | |
| subplot_titles=[c.rstrip("*") for c in active_cols], | |
| ) | |
| for i, col in enumerate(active_cols, 1): | |
| auto = agent_autonomy[col]["autonomous"] | |
| non_auto = agent_autonomy[col]["non_autonomous"] | |
| fig.add_trace(go.Pie( | |
| labels=["Autonomous", "Non-Autonomous"], | |
| values=[auto, non_auto], | |
| marker=dict( | |
| colors=["white", "#555250"], # solid white / dark grey solid | |
| pattern=dict( | |
| shape=["", ""], | |
| fgcolor=[INK, "#555250"], | |
| size=[6, 6], | |
| solidity=1.0, | |
| ), | |
| line=dict(color=INK, width=2), | |
| ), | |
| textinfo="label+percent", | |
| textposition="outside", | |
| hovertemplate="%{label}<br>Count: %{value}<br>%{percent}<extra></extra>", | |
| hole=0.3, | |
| showlegend=(i == 1), | |
| ), row=1, col=i) | |
| fig.update_layout( | |
| title="Agent Autonomy: Task Continuity", | |
| height=400, | |
| paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| margin=dict(l=20, r=20, t=80, b=20), | |
| legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5), | |
| ) | |
| return fig | |
| def build_most_reliable_bar_chart(df, config): | |
| """Performer/supporter capacities along the most reliable path.""" | |
| if config is None or df.empty: | |
| return go.Figure() | |
| perf_green, perf_yellow, perf_orange = 0, 0, 0 | |
| sup_green, sup_yellow, sup_orange = 0, 0, 0 | |
| for _, row in df.iterrows(): | |
| chosen = get_chosen_performer(row, config, "most_reliable") | |
| if not chosen: | |
| continue | |
| val = str(row.get(chosen, "") or "").strip().lower() | |
| if val == "green": | |
| perf_green += 1 | |
| elif val == "yellow": | |
| perf_yellow += 1 | |
| elif val == "orange": | |
| perf_orange += 1 | |
| # Find active supporter for the chosen performer's alternative | |
| for alt in config["alternatives"]: | |
| if chosen in alt["performers"]: | |
| for sc in alt["supporters"]: | |
| if sc in df.columns: | |
| sval = str(row.get(sc, "") or "").strip().lower() | |
| if sval == "green": | |
| sup_green += 1 | |
| elif sval == "yellow": | |
| sup_yellow += 1 | |
| elif sval == "orange": | |
| sup_orange += 1 | |
| fig = go.Figure() | |
| for label, count, color in [ | |
| ("Performer Green", perf_green, PAL_GREEN), | |
| ("Performer Yellow", perf_yellow, PAL_YELLOW), | |
| ("Performer Orange", perf_orange, PAL_ORANGE), | |
| ("Supporter Green", sup_green, COLOR_MAP_LIGHT["green"]), | |
| ("Supporter Yellow", sup_yellow, COLOR_MAP_LIGHT["yellow"]), | |
| ("Supporter Orange", sup_orange, COLOR_MAP_LIGHT["orange"]), | |
| ]: | |
| fig.add_trace(go.Bar(name=label, x=[label], y=[count], marker_color=color, showlegend=False)) | |
| fig.update_layout( | |
| title="Most Reliable Path: Performer and Supporter Capacities", | |
| xaxis_title="Role and Capacity", yaxis_title="Number of Tasks", | |
| barmode="group", bargap=0.15, | |
| plot_bgcolor=BG, paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| showlegend=False, | |
| ) | |
| return fig | |
| def build_human_baseline_bar_chart(df, config): | |
| """Human-only performer capacities (no support, no autonomous agents).""" | |
| if config is None or df.empty: | |
| return go.Figure() | |
| agent_types = {a["name"]: a["type"] for a in config["agents"]} | |
| performer_cols = get_performer_columns(config) | |
| human_perfs = [ | |
| pc for pc in performer_cols | |
| if agent_types.get(pc.rstrip("*"), "").lower() == "human" | |
| ] | |
| if not human_perfs: | |
| return go.Figure() | |
| perf_green, perf_yellow, perf_orange = 0, 0, 0 | |
| for _, row in df.iterrows(): | |
| for pc in human_perfs: | |
| if pc in df.columns: | |
| val = str(row.get(pc, "") or "").strip().lower() | |
| if val == "green": | |
| perf_green += 1 | |
| elif val == "yellow": | |
| perf_yellow += 1 | |
| elif val == "orange": | |
| perf_orange += 1 | |
| fig = go.Figure() | |
| fig.add_trace(go.Bar(name="Green", x=["Green"], y=[perf_green], marker_color=PAL_GREEN, showlegend=False)) | |
| fig.add_trace(go.Bar(name="Yellow", x=["Yellow"], y=[perf_yellow], marker_color=PAL_YELLOW, showlegend=False)) | |
| fig.add_trace(go.Bar(name="Orange", x=["Orange"], y=[perf_orange], marker_color=PAL_ORANGE, showlegend=False)) | |
| fig.update_layout( | |
| title="Human-Only Baseline: Human Performer Capacities", | |
| xaxis_title="Capacity Level", yaxis_title="Number of Tasks", | |
| barmode="group", bargap=0.15, | |
| plot_bgcolor=BG, paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| showlegend=False, | |
| ) | |
| return fig | |
| # βββ Automation Proportion ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_automation_proportion_data(df, config, highlight_track, category_overrides=None): | |
| """ | |
| Compute automation proportion P using Liu & Kaber (2025) method. | |
| For each category k: p_k = (1/T_k) * sum(w(t)) | |
| Where w(t) = 1.0 if autonomous performer chosen, | |
| 0.5 if human performer chosen with autonomous support (full_support modes), | |
| 0.0 if human performer chosen alone. | |
| P = (1/K) * sum(p_k) | |
| Returns (P, category_scores_dict) or (None, {}) if not applicable. | |
| """ | |
| cat_col = config.get("category_column") or "Category" | |
| if config is None or df.empty or cat_col not in df.columns: | |
| return None, {} | |
| if not highlight_track or highlight_track == "none": | |
| return None, {} | |
| agent_types = {a["name"]: a["type"] for a in config["agents"]} | |
| categories = sorted(df[cat_col].dropna().unique()) | |
| if not categories: | |
| return None, {} | |
| K = len(categories) | |
| category_scores = {} | |
| is_full_support = highlight_track in ( | |
| "human_full_support", "agent_whenever_possible_full_support", "most_reliable", | |
| ) | |
| for cat in categories: | |
| cat_df = df[df[cat_col] == cat] | |
| Tk = len(cat_df) | |
| if Tk == 0: | |
| continue | |
| weights = [] | |
| for _, row in cat_df.iterrows(): | |
| chosen = get_chosen_performer(row, config, highlight_track, category_overrides) | |
| if chosen is None: | |
| weights.append(0.0) | |
| continue | |
| chosen_type = agent_types.get(chosen.rstrip("*"), "autonomous") | |
| if chosen_type == "autonomous": | |
| weights.append(1.0) | |
| elif chosen_type == "human" and is_full_support: | |
| # Check if autonomous support is available | |
| has_auto_support = False | |
| for alt in config["alternatives"]: | |
| if chosen in alt["performers"]: | |
| for sc in alt["supporters"]: | |
| sc_type = agent_types.get(sc.rstrip("*"), sc) | |
| if sc_type != "human" and sc in df.columns: | |
| sval = str(row.get(sc, "") or "").strip().lower() | |
| if sval in VALID_COLORS and sval != "red": | |
| has_auto_support = True | |
| weights.append(0.5 if has_auto_support else 0.0) | |
| else: | |
| weights.append(0.0) | |
| pk = sum(weights) / Tk | |
| category_scores[cat] = pk | |
| P = sum(category_scores.values()) / K if K > 0 else 0.0 | |
| return P, category_scores | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # APP LAYOUT | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SHOW = {} | |
| HIDE = {"display": "none"} | |
| app.layout = html.Div([ | |
| # ββ Stores ββ | |
| dcc.Store(id="team-config-store", data=None), | |
| dcc.Store(id="category-overrides-store", data={}), | |
| dcc.Store(id="base-figure-store", data=None), | |
| dcc.Store(id="arrow-indices-store", data=None), | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STICKY NAVIGATION HEADER | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| html.Nav(className="sticky-nav", children=[ | |
| html.A("Interdependence Analysis Dashboard", href="#", className="nav-brand"), | |
| html.Div(id="nav-links", className="nav-links", style=HIDE, children=[ | |
| html.A("Table", href="#table-anchor", className="nav-link"), | |
| html.A("Workflow", href="#workflow-anchor", className="nav-link"), | |
| html.A("Statistics", href="#statistics-anchor", className="nav-link"), | |
| ]), | |
| ]), | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SETUP SECTION [01] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| html.Div(id="setup-section", className="section", children=[ | |
| html.Div("[ 01 ]", className="section-number"), | |
| html.H2("Team Setup", className="section-title"), | |
| html.P( | |
| "Upload a CSV or manually define your Human-Autonomy Team configuration.", | |
| className="section-subtitle", | |
| ), | |
| # ββ Option 1: CSV Upload ββ | |
| html.Div(className="card", children=[ | |
| html.Div("Option 1 β Load from CSV", className="card-header"), | |
| html.P( | |
| "Upload a CSV and the team structure will be auto-detected from " | |
| "columns containing color values (red/yellow/green/orange). " | |
| "Columns ending with * are treated as performer roles.", | |
| style={"fontSize": "14px"}, | |
| ), | |
| html.P([ | |
| html.B("Expected column structure: "), | |
| "Row | Procedure | Task | [optional: Category] | Agent columns⦠| Metadata columns", | |
| ], style={"fontSize": "13px", "fontFamily": "var(--font-mono)"}), | |
| html.P([ | |
| "The Procedure column groups Tasks hierarchically (Procedure β Task). " | |
| "The optional Category column (e.g. Observe/Orient/Decide/Act from a OODA decomposition) " | |
| "is used for Automation Proportion computation. Any column with few unique string values " | |
| "not matching colors will be auto-detected as the Category column.", | |
| ], style={"fontSize": "13px"}), | |
| dcc.Upload( | |
| id="setup-upload", | |
| children=html.Div([ | |
| "Drag and Drop or ", | |
| html.A("Select a CSV File", style={"color": ACCENT, "cursor": "pointer", "fontWeight": "600"}), | |
| ]), | |
| className="upload-zone", | |
| style={ | |
| "width": "100%", "lineHeight": "60px", | |
| "textAlign": "center", "margin": "10px 0", | |
| }, | |
| multiple=False, | |
| ), | |
| html.Div([ | |
| html.Button( | |
| "Load Example (IA_V7.csv)", | |
| id="load-example-button", | |
| n_clicks=0, | |
| style={"marginTop": "8px", "fontSize": "13px"}, | |
| ), | |
| html.Span( | |
| " β load a pre-built example to explore the dashboard", | |
| style={"fontSize": "12px", "color": INK_MUTED, "marginLeft": "8px"}, | |
| ), | |
| ]), | |
| html.Div(id="upload-status", style={"marginTop": "10px", "fontStyle": "italic"}), | |
| ]), | |
| # ββ Option 2: Manual Setup ββ | |
| html.Div(className="card-alt", children=[ | |
| html.Div("Option 2 β Define Team Manually", className="card-header"), | |
| html.P( | |
| "The first task of the IA requires defining team alternatives, first list all of the agents " | |
| "in the team, then check for alternatives in agent's roles (e.g. performer vs supporter). " | |
| "Enter the agent role columns in order. Use * for performer columns. " | |
| "Group them as: Alt1-performers, Alt1-supporters, Alt2-performers, Alt2-supporters, β¦", | |
| style={"fontSize": "14px"}, | |
| ), | |
| html.Div([ | |
| html.Label("Agent columns (comma-separated):", style={"fontWeight": "bold"}), | |
| dcc.Input( | |
| id="manual-columns-input", | |
| value="Human*, Robot, Robot*, Human", | |
| style={"width": "100%", "marginBottom": "10px", "padding": "8px"}, | |
| ), | |
| ]), | |
| # ββ Procedure column ββ | |
| html.Div([ | |
| html.Label("Higher-level activity column name:", style={"fontWeight": "bold", "marginRight": "10px"}), | |
| dcc.Input( | |
| id="manual-procedure-col-input", | |
| value="Procedure", | |
| style={"width": "200px", "padding": "8px"}, | |
| ), | |
| ], style={"display": "flex", "alignItems": "center", "marginBottom": "6px"}), | |
| html.P( | |
| "Define the column name that groups several Tasks and represents one level of hierarchical " | |
| "decomposition of the joint activity β the analysis uses a two-level hierarchy: " | |
| "(e.g. Procedure β Task.) This column typically corresponds to high-level mission phases " | |
| "or activity clusters.", | |
| style={"fontSize": "13px", "marginTop": "2px", "marginBottom": "14px"}, | |
| ), | |
| # ββ Task column ββ | |
| html.Div([ | |
| html.Label("Lower-level activity column name:", style={"fontWeight": "bold", "marginRight": "10px"}), | |
| dcc.Input( | |
| id="manual-task-col-input", | |
| value="Task", | |
| style={"width": "200px", "padding": "8px"}, | |
| ), | |
| ], style={"display": "flex", "alignItems": "center", "marginBottom": "6px"}), | |
| html.P([ | |
| "Represents the terminal nodes of the activity decomposition. We recommend decomposing into " | |
| "required capacity in terms of information-processing stages: ", | |
| html.B("Sense β Interpret β Decide β Act"), | |
| ], style={"fontSize": "13px", "marginTop": "2px", "marginBottom": "14px"}), | |
| # ββ Category column ββ | |
| html.Div([ | |
| html.Label("Category column name (optional):", style={"fontWeight": "bold", "marginRight": "10px"}), | |
| dcc.Input( | |
| id="manual-category-col-input", | |
| value="Category", | |
| placeholder="e.g. Category, Type, Stage", | |
| style={"width": "220px", "padding": "8px"}, | |
| ), | |
| ], style={"display": "flex", "alignItems": "center", "marginBottom": "6px"}), | |
| html.P( | |
| "The Category column assigns each task to a named group. It is used as the grouping variable " | |
| "for Automation Proportion computation: one proportion pβ is computed per category, then " | |
| "averaged to produce the overall index P. Leave blank if not applicable.", | |
| style={"fontSize": "13px", "marginTop": "2px", "marginBottom": "14px"}, | |
| ), | |
| # Preset buttons | |
| html.Div([ | |
| html.Label("Presets: ", style={"fontWeight": "bold", "marginRight": "10px"}), | |
| html.Button("Human + Robot", id="preset-2agent", n_clicks=0, | |
| style={"marginRight": "10px"}), | |
| html.Button("Human + UGV + UAV", id="preset-3agent", n_clicks=0, | |
| style={"marginRight": "10px"}), | |
| ], style={"marginBottom": "15px"}), | |
| html.Button( | |
| "Create Team", id="create-team-button", n_clicks=0, | |
| className="btn-primary", | |
| style={"marginTop": "10px"}, | |
| ), | |
| ]), | |
| # ββ References ββ | |
| html.Div(style={"marginTop": "3rem", "borderTop": f"1px solid {BORDER}", "paddingTop": "1.5rem"}, children=[ | |
| html.P([ | |
| "If you want to know more about interdependence analysis for building effective Human-Autonomy Teams, " | |
| "check these publications and these two videos by Matthew Johnson: ", | |
| html.A("Video 1", href="https://www.youtube.com/watch?v=BnuTBMWnf6M", | |
| target="_blank", style={"color": ACCENT, "fontWeight": "600"}), | |
| " Β· ", | |
| html.A("Video 2", href="https://www.youtube.com/watch?v=M2UgTNPjHyM", | |
| target="_blank", style={"color": ACCENT, "fontWeight": "600"}), | |
| ".", | |
| ], style={"fontSize": "14px", "marginBottom": "1.2rem", "lineHeight": "1.7"}), | |
| html.H4("References", style={"textTransform": "uppercase", "letterSpacing": "0.05em", | |
| "fontSize": "13px", "color": INK_MUTED, "marginBottom": "1rem"}), | |
| html.Ol(style={"fontSize": "13px", "lineHeight": "1.8", "paddingLeft": "1.2rem", | |
| "color": INK, "fontFamily": "var(--font-body)"}, children=[ | |
| html.Li("Johnson, M. (2014). Coactive Design: Designing Support for Interdependence in Human-Robot Teamwork."), | |
| html.Li([ | |
| "Johnson, M., Bradshaw, J., & Feltovich, P. J. (2017). Tomorrow's HumanβMachine Design Tools: From Levels of Automation to Interdependencies. ", | |
| html.Em("Journal of Cognitive Engineering and Decision Making"), ", 12, 155534341773646. ", | |
| html.A("https://doi.org/10.1177/1555343417736462", | |
| href="https://doi.org/10.1177/1555343417736462", target="_blank", | |
| style={"color": ACCENT}), | |
| ]), | |
| html.Li([ | |
| "Johnson, M., Bradshaw, J., Feltovich, P. J., Hoffman, R., Jonker, C., Riemsdijk, B., & Sierhuis, M. (2011). Beyond Cooperative Robotics: The Central Role of Interdependence in Coactive Design. ", | |
| html.Em("IEEE Intelligent Systems"), ", 26, 81β88. ", | |
| html.A("https://doi.org/10.1109/MIS.2011.47", | |
| href="https://doi.org/10.1109/MIS.2011.47", target="_blank", | |
| style={"color": ACCENT}), | |
| ]), | |
| html.Li([ | |
| "Johnson, M., & Bradshaw, J. M. (2021). How Interdependence Explains the World of Teamwork. In W. F. Lawless, J. Llinas, D. A. Sofge, & R. Mittu (Eds.), ", | |
| html.Em("Engineering Artificially Intelligent Systems: A Systems Engineering Approach to Realizing Synergistic Capabilities"), | |
| " (pp. 122β146). Springer International Publishing. ", | |
| html.A("https://doi.org/10.1007/978-3-030-89385-9_8", | |
| href="https://doi.org/10.1007/978-3-030-89385-9_8", target="_blank", | |
| style={"color": ACCENT}), | |
| ]), | |
| html.Li([ | |
| "Johnson, M., Bradshaw, J. M., Feltovich, P. J., Jonker, C. M., van Riemsdijk, M. B., & Sierhuis, M. (2014). Coactive design: Designing support for interdependence in joint activity. ", | |
| html.Em("J. Hum.-Robot Interact."), ", 3(1), 43β69. ", | |
| html.A("https://doi.org/10.5898/JHRI.3.1.Johnson", | |
| href="https://doi.org/10.5898/JHRI.3.1.Johnson", target="_blank", | |
| style={"color": ACCENT}), | |
| ]), | |
| html.Li([ | |
| "Johnson, M., Vignati, M., & Duran, D. (2018). Understanding Human-Autonomy Teaming through Interdependence Analysis. ", | |
| html.A("ihmc", | |
| href="https://www.ihmc.us/wp-content/uploads/2019/01/180907-HAT-Interdependence-Analysis.pdf", | |
| target="_blank", style={"color": ACCENT}), | |
| ]), | |
| ]), | |
| ]), | |
| ]), | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONFIG SUMMARY (shown after setup) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| html.Div(id="config-summary", style=HIDE, children=[ | |
| html.Div(className="section", style={"paddingBottom": "1rem"}, children=[ | |
| html.Div("[ 01 ]", className="section-number"), | |
| html.Div([ | |
| html.H4("Current Team Configuration", style={"display": "inline-block", "margin": "0"}), | |
| html.Button("Change Team", id="reset-config-button", n_clicks=0, | |
| style={"marginLeft": "20px"}), | |
| ], style={"display": "flex", "alignItems": "center"}), | |
| html.Div(id="config-details", style={"marginTop": "1rem"}), | |
| ]), | |
| ]), | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ANALYSIS SECTION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| html.Div(id="analysis-section", style=HIDE, children=[ | |
| # ββ [02] Table ββ | |
| html.Div(id="table-anchor", className="section", children=[ | |
| html.Div("[ 02 ]", className="section-number"), | |
| html.H2("Interdependence Analysis Table", className="section-title"), | |
| html.Div(id="table-wrapper"), | |
| # Action buttons | |
| html.Div(style={"marginTop": "1rem"}, children=[ | |
| html.Div([ | |
| html.Div([ | |
| dcc.Upload( | |
| id="upload-data", | |
| children=html.Button("Load Table", id="load-button", n_clicks=0), | |
| multiple=False, | |
| style={"display": "inline-block", "marginRight": "10px"}, | |
| ), | |
| html.Button("Add Row", id="add-row-button", n_clicks=0), | |
| html.Button("Copy Cell Down", id="copy-down-button", n_clicks=0), | |
| ], style={"display": "flex", "gap": "10px"}), | |
| html.Div([ | |
| html.Button("Save Table", id="save-button", n_clicks=0), | |
| dcc.Download(id="download-csv"), | |
| ], style={"marginLeft": "auto"}), | |
| ], style={"display": "flex", "width": "100%"}), | |
| html.Div(id="save-confirmation", style={"marginTop": "10px", "fontStyle": "italic"}), | |
| ]), | |
| ]), | |
| # ββ [03] Workflow Graph ββ | |
| html.Div(id="workflow-anchor", className="section", children=[ | |
| html.Div("[ 03 ]", className="section-number"), | |
| html.H2("Workflow Graph", className="section-title"), | |
| # Procedure dropdown + View selector | |
| html.Div([ | |
| dcc.Dropdown( | |
| id="procedure-dropdown", | |
| options=[], | |
| value=None, | |
| placeholder="Select a procedure to filter the graphβ¦", | |
| clearable=True, | |
| style={"width": "320px", "marginRight": "30px"}, | |
| ), | |
| dcc.RadioItems( | |
| id="view-selector", | |
| options=[ | |
| {"label": "Full View (All Agents)", "value": "full"}, | |
| {"label": "Performers Only", "value": "performers"}, | |
| ], | |
| value="full", | |
| labelStyle={"display": "inline-block", "marginRight": "20px"}, | |
| style={"display": "flex", "alignItems": "center"}, | |
| ), | |
| ], style={"display": "flex", "alignItems": "center", "marginTop": "12px"}), | |
| # Allocation pattern selector | |
| dcc.RadioItems( | |
| id="highlight-selector", | |
| options=[ | |
| {"label": "No highlight", "value": "none"}, | |
| {"label": "Alt 1 performer β independent", "value": "human_baseline"}, | |
| {"label": "Alt 1 performer β interdependent", "value": "human_full_support"}, | |
| {"label": "Alt 2 performer β independent", "value": "agent_whenever_possible"}, | |
| {"label": "Alt 2 performer β interdependent", "value": "agent_whenever_possible_full_support"}, | |
| {"label": "Path of highest reliability", "value": "most_reliable"}, | |
| ], | |
| value="none", | |
| labelStyle={"display": "inline-block", "marginRight": "20px"}, | |
| style={"marginTop": "10px"}, | |
| ), | |
| # Category Overrides | |
| html.Div(id="category-overrides-section", style={"display": "none"}, children=[ | |
| html.H3("Category Overrides", style={"marginTop": "30px", "textTransform": "uppercase", | |
| "letterSpacing": "0.04em"}), | |
| html.P( | |
| "Click a bar to force a specific agent type for that category. " | |
| "Click again to reset to default strategy.", | |
| style={"fontSize": "14px"}, | |
| ), | |
| html.Div(id="category-override-warning", style={"fontSize": "13px", "color": ACCENT, "marginTop": "6px"}), | |
| html.Div(id="category-overrides-container"), | |
| ]), | |
| # Automation Proportion Summary | |
| html.Div(id="automation-proportion-box", children=[ | |
| html.Div([ | |
| html.Span("Automation Proportion: ", | |
| style={"fontSize": "16px", "fontWeight": "bold"}), | |
| html.Span(id="ap-summary-value", children="--", | |
| style={"fontSize": "20px", "fontWeight": "bold", "color": ACCENT}), | |
| ], className="ap-box"), | |
| html.Div(id="ap-detail", style={"fontSize": "13px", "marginTop": "6px"}), | |
| ], style={"marginTop": "20px", "display": "none"}), | |
| html.Div(id="automation-proportion-results"), | |
| dcc.Graph(id="interdependence-graph", config={"displayModeBar": False}), | |
| # Team alternative labels (dynamic) | |
| html.Div(id="alt-labels"), | |
| ]), | |
| # ββ [04] Statistics ββ | |
| html.Div(id="statistics-anchor", className="section", children=[ | |
| html.Div("[ 04 ]", className="section-number"), | |
| html.H2("Statistics", className="section-title"), | |
| html.Div([ | |
| dcc.Graph(id="allocation-type-bar-chart", config={"displayModeBar": False}), | |
| dcc.Graph(id="agent-autonomy-bar-chart", config={"displayModeBar": False}), | |
| ]), | |
| html.Details([ | |
| html.Summary("Capacity Assessment", style={ | |
| "fontSize": "1.1rem", "fontWeight": "bold", "cursor": "pointer", | |
| "padding": "0.75rem 0", "userSelect": "none", | |
| "textTransform": "uppercase", "letterSpacing": "0.04em", | |
| }), | |
| dcc.Graph(id="capacity-bar-chart", config={"displayModeBar": False}), | |
| dcc.Graph(id="most-reliable-bar-chart", config={"displayModeBar": False}), | |
| dcc.Graph(id="human-baseline-bar-chart", config={"displayModeBar": False}), | |
| ]), | |
| ]), | |
| ]), | |
| # Footer | |
| html.Footer( | |
| "Β© Benjamin R. Berton 2025 Polytechnique Montreal", | |
| className="site-footer", | |
| ), | |
| ], style={ | |
| "fontFamily": "var(--font-body, 'Space Grotesk', 'Inter', sans-serif)", | |
| "backgroundColor": BG, | |
| "color": INK, | |
| "margin": "0", | |
| "padding": "0", | |
| }) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CALLBACKS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ββ Preset buttons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def apply_preset(n2, n3): | |
| ctx = callback_context | |
| btn = ctx.triggered[0]["prop_id"].split(".")[0] | |
| if btn == "preset-2agent": | |
| return "Human*, Robot, Robot*, Human", "Task", "Procedure" | |
| elif btn == "preset-3agent": | |
| return "Human*, UGV, UAV, UGV*, UAV*, Human", "Task", "Procedure" | |
| return dash.no_update, dash.no_update, dash.no_update | |
| # ββ Nav-links visibility βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def toggle_nav_links(config): | |
| return SHOW if config else HIDE | |
| # ββ Setup / Config callback ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def handle_setup(upload_contents, create_clicks, reset_clicks, example_clicks, | |
| upload_filename, manual_columns, manual_task_col, | |
| manual_procedure_col, manual_category_col): | |
| ctx = callback_context | |
| triggered = ctx.triggered[0]["prop_id"].split(".")[0] | |
| no = dash.no_update | |
| if triggered == "reset-config-button": | |
| return None, None, HIDE, SHOW, HIDE, None, [], "" | |
| if triggered == "load-example-button": | |
| if EXAMPLE_CSV is None: | |
| return no, no, no, no, no, no, no, "β οΈ Example file not found." | |
| try: | |
| df = pd.read_csv(EXAMPLE_CSV) | |
| except Exception as e: | |
| return no, no, no, no, no, no, no, f"β οΈ Error reading example: {e}" | |
| config = detect_team_config(df) | |
| if config is None: | |
| return no, no, no, no, no, no, no, "β οΈ Could not detect team structure in example." | |
| if "Row" not in df.columns: | |
| df.insert(0, "Row", range(1, len(df) + 1)) | |
| config["all_columns"] = ["Row"] + [c for c in config["all_columns"] if c != "Row"] | |
| proc_col = config.get("procedure_column", "Procedure") | |
| proc_options = [] | |
| if proc_col in df.columns: | |
| proc_options = [{"label": p, "value": p} for p in df[proc_col].dropna().unique()] | |
| table = build_data_table(df, config) | |
| summary = config_summary_html(config) | |
| return config, table, SHOW, HIDE, SHOW, summary, proc_options, "β Loaded example: IA_V7.csv" | |
| if triggered == "setup-upload" and upload_contents: | |
| try: | |
| content_type, content_string = upload_contents.split(",") | |
| decoded = base64.b64decode(content_string) | |
| df = pd.read_csv(io.StringIO(decoded.decode("utf-8"))) | |
| except Exception as e: | |
| return no, no, no, no, no, no, no, f"β οΈ Error reading file: {e}" | |
| config = detect_team_config(df) | |
| if config is None: | |
| return no, no, no, no, no, no, no, "β οΈ Could not detect team structure. No color columns found." | |
| # Ensure Row column | |
| if "Row" not in df.columns: | |
| df.insert(0, "Row", range(1, len(df) + 1)) | |
| config["all_columns"] = ["Row"] + [c for c in config["all_columns"] if c != "Row"] | |
| # Procedure dropdown options | |
| proc_col = config.get("procedure_column", "Procedure") | |
| proc_options = [] | |
| if proc_col in df.columns: | |
| proc_options = [{"label": p, "value": p} for p in df[proc_col].dropna().unique()] | |
| table = build_data_table(df, config) | |
| summary = config_summary_html(config) | |
| return config, table, SHOW, HIDE, SHOW, summary, proc_options, f"β Loaded {upload_filename}" | |
| if triggered == "create-team-button": | |
| config = build_config_from_manual( | |
| manual_columns or "", | |
| task_col=manual_task_col or "Task", | |
| procedure_col=manual_procedure_col or "Procedure", | |
| category_col=manual_category_col.strip() or None if manual_category_col else None, | |
| ) | |
| if config is None: | |
| return no, no, no, no, no, no, no, no | |
| df = create_empty_df(config) | |
| table = build_data_table(df, config) | |
| summary = config_summary_html(config) | |
| return config, table, SHOW, HIDE, SHOW, summary, [], "" | |
| return no, no, no, no, no, no, no, no | |
| # ββ Table operations callback ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def handle_table(data, save_clicks, upload_contents, add_clicks, copy_clicks, | |
| active_cell, upload_filename, config): | |
| ctx = callback_context | |
| triggered = ctx.triggered[0]["prop_id"].split(".")[0] | |
| no = dash.no_update | |
| save_msg = "" | |
| download = None | |
| # ββ Save ββ | |
| if triggered == "save-button" and data and config: | |
| df = pd.DataFrame(data) | |
| download = dcc.send_data_frame(df.to_csv, "interdependence_analysis.csv", index=False) | |
| save_msg = "β Table downloaded as interdependence_analysis.csv" | |
| return no, save_msg, download, no, no, no, no, no, no | |
| # ββ Load new CSV (from analysis section) ββ | |
| if triggered == "upload-data" and upload_contents: | |
| try: | |
| ct, cs = upload_contents.split(",") | |
| decoded = base64.b64decode(cs) | |
| df = pd.read_csv(io.StringIO(decoded.decode("utf-8"))) | |
| except Exception as e: | |
| return no, f"β οΈ Error: {e}", None, no, no, no, no, no, no | |
| new_config = detect_team_config(df) | |
| if new_config is None: | |
| return no, "β οΈ Could not detect team structure.", None, no, no, no, no, no, no | |
| if "Row" not in df.columns: | |
| df.insert(0, "Row", range(1, len(df) + 1)) | |
| new_config["all_columns"] = ["Row"] + [c for c in new_config["all_columns"] if c != "Row"] | |
| proc_col = new_config.get("procedure_column", "Procedure") | |
| proc_options = [] | |
| if proc_col in df.columns: | |
| proc_options = [{"label": p, "value": p} for p in df[proc_col].dropna().unique()] | |
| table = build_data_table(df, new_config) | |
| summary = config_summary_html(new_config) | |
| return table, f"β Loaded {upload_filename}", None, proc_options, new_config, SHOW, HIDE, SHOW, summary | |
| # ββ Add Row ββ | |
| if triggered == "add-row-button" and data and config: | |
| df = pd.DataFrame(data) | |
| new_row = {col: "" for col in df.columns} | |
| df = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True) | |
| df = ensure_row_column(df) | |
| table = build_data_table(df, config) | |
| return table, "", None, no, no, no, no, no, no | |
| # ββ Copy Down ββ | |
| if triggered == "copy-down-button" and data and config: | |
| df = pd.DataFrame(data) | |
| if active_cell and "row" in active_cell and "column_id" in active_cell: | |
| r = active_cell["row"] | |
| c = active_cell["column_id"] | |
| if r is not None and c is not None and r + 1 < len(df): | |
| df.at[r + 1, c] = df.at[r, c] | |
| table = build_data_table(df, config) | |
| return table, "", None, no, no, no, no, no, no | |
| # ββ Table edited ββ | |
| if triggered == "responsibility-table" and data and config: | |
| df = pd.DataFrame(data) | |
| df = ensure_row_column(df) | |
| proc_col = config.get("procedure_column", "Procedure") | |
| proc_options = [] | |
| if proc_col in df.columns: | |
| proc_options = [{"label": p, "value": p} for p in df[proc_col].dropna().unique()] | |
| table = build_data_table(df, config) | |
| return table, "", None, proc_options, no, no, no, no, no | |
| return no, "", None, no, no, no, no, no, no | |
| # ββ Graph callbacks (two-stage: base figure + highlighting) βββββββββββββββββββ | |
| def build_base_figure(procedure, view_mode, data, config): | |
| """Build the base figure structure (without highlighting). | |
| Only runs when procedure, view mode, or data changes.""" | |
| if not data or not config: | |
| return None, None, None | |
| df = pd.DataFrame(data) | |
| if df.empty: | |
| return None, None, None | |
| fig, arrow_info = build_workflow_figure_base( | |
| df, config, procedure=procedure, view_mode=view_mode or "full", | |
| ) | |
| # Team alternative labels | |
| labels = [] | |
| for alt in config.get("alternatives", []): | |
| labels.append(html.Div( | |
| alt["name"], | |
| style={ | |
| "display": "inline-block", "textAlign": "center", | |
| "marginTop": "10px", "fontWeight": "bold", | |
| "flex": "1", | |
| }, | |
| )) | |
| alt_label_div = html.Div(labels, style={ | |
| "display": "flex", "width": "100%", | |
| "marginLeft": "150px", "marginRight": "50px", | |
| }) if labels else None | |
| return fig.to_dict(), arrow_info, alt_label_div | |
| def apply_highlighting_callback(base_fig_dict, arrow_info, highlight_track, | |
| category_overrides, procedure, data, config): | |
| """Apply highlighting to the cached base figure. Fast: only updates colors/widths.""" | |
| if not base_fig_dict or not data or not config: | |
| return go.Figure() | |
| df = pd.DataFrame(data) | |
| ht = None if highlight_track == "none" else highlight_track | |
| return apply_workflow_highlighting( | |
| base_fig_dict, arrow_info, df, config, | |
| procedure=procedure, highlight_track=ht, | |
| category_overrides=category_overrides or {}, | |
| ) | |
| # ββ Bar chart callback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def update_bar_charts(procedure, data, config): | |
| if not data or not config: | |
| return go.Figure(), go.Figure(), go.Figure(), go.Figure(), go.Figure() | |
| df = pd.DataFrame(data) | |
| proc_col = config.get("procedure_column", "Procedure") | |
| if procedure and proc_col in df.columns: | |
| df = df[df[proc_col] == procedure] | |
| return ( | |
| build_capacity_bar_chart(df, config), | |
| build_most_reliable_bar_chart(df, config), | |
| build_human_baseline_bar_chart(df, config), | |
| build_allocation_bar_chart(df, config), | |
| build_autonomy_bar_chart(df, config), | |
| ) | |
| # ββ Automation Proportion callback ββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_automation_proportion(highlight_track, category_overrides, procedure, data, config): | |
| if not data or not config: | |
| return {"display": "none"}, "--", {}, "", None | |
| df = pd.DataFrame(data) | |
| proc_col = config.get("procedure_column", "Procedure") | |
| if procedure and proc_col in df.columns: | |
| df = df[df[proc_col] == procedure] | |
| cat_col = config.get("category_column") or "Category" | |
| if cat_col not in df.columns or not highlight_track or highlight_track == "none": | |
| return {"display": "none"}, "--", {}, "", None | |
| P, cat_scores = compute_automation_proportion_data( | |
| df, config, highlight_track, category_overrides or {}, | |
| ) | |
| if P is None: | |
| return {"display": "none"}, "--", {}, "", None | |
| # Color code: accent if P > 0.5, green if P < 0.5, yellow if P β 0.5 | |
| if P > 0.55: | |
| color = ACCENT | |
| elif P < 0.45: | |
| color = PAL_GREEN | |
| else: | |
| color = PAL_YELLOW | |
| val_style = {"fontSize": "20px", "fontWeight": "bold", "color": color} | |
| box_style = {"textAlign": "center", "marginTop": "10px"} | |
| # Category detail | |
| detail_parts = [f"{cat}: {score:.2f}" for cat, score in sorted(cat_scores.items())] | |
| detail_text = " | ".join(detail_parts) if detail_parts else "" | |
| # Extract performer names for formula explanation | |
| alts = config.get("alternatives", []) | |
| def _perf_name(alt): | |
| perfs = alt.get("performers", []) | |
| return perfs[0].rstrip("*") if perfs else "Agent" | |
| alt1_name = _perf_name(alts[0]) if len(alts) > 0 else "Alt 1 performer" | |
| alt2_name = _perf_name(alts[1]) if len(alts) > 1 else "Alt 2 performer" | |
| # LaTeX formula display | |
| formula = html.Div([ | |
| html.P([ | |
| html.B("Formula: "), | |
| f"P = (1/K) Γ Ξ£ pβ = (1/{len(cat_scores)}) Γ " | |
| f"{sum(cat_scores.values()):.2f} = {P:.3f}", | |
| ], style={"fontSize": "13px", "marginTop": "5px"}), | |
| html.P([ | |
| html.B("Where: "), | |
| f"w(t) = 0.0 ({alt1_name} independent), " | |
| f"0.5 ({alt1_name} interdependent β supported by autonomous), " | |
| f"0.75 ({alt1_name} as supporter), " | |
| f"1.0 ({alt2_name} independent)", | |
| ], style={"fontSize": "12px"}), | |
| ]) | |
| return box_style, f"{P:.3f}", val_style, detail_text, formula | |
| # ββ Dynamic highlight-selector labels βββββββββββββββββββββββββββββββββββββββββ | |
| def update_highlight_options(config): | |
| def _perf_name(alt): | |
| perfs = alt.get("performers", []) | |
| return perfs[0].rstrip("*") if perfs else "Agent" | |
| if not config or not config.get("alternatives"): | |
| a1, a2 = "Alt 1 performer", "Alt 2 performer" | |
| else: | |
| alts = config["alternatives"] | |
| a1 = _perf_name(alts[0]) if len(alts) > 0 else "Alt 1 performer" | |
| a2 = _perf_name(alts[1]) if len(alts) > 1 else "Alt 2 performer" | |
| return [ | |
| {"label": "No highlight", "value": "none"}, | |
| {"label": f"{a1} β independent", "value": "human_baseline"}, | |
| {"label": f"{a1} β interdependent", "value": "human_full_support"}, | |
| {"label": f"{a2} β independent", "value": "agent_whenever_possible"}, | |
| {"label": f"{a2} β interdependent", "value": "agent_whenever_possible_full_support"}, | |
| {"label": "Path of highest reliability", "value": "most_reliable"}, | |
| ] | |
| # ββ Category Overrides callbacks ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_category_overrides(data, config): | |
| """Generate per-category override UI with mini bar charts.""" | |
| if not data or not config: | |
| return {"display": "none"}, None | |
| df = pd.DataFrame(data) | |
| cat_col = config.get("category_column") or "Category" | |
| if cat_col not in df.columns: | |
| return {"display": "none"}, None | |
| agent_types = {a["name"]: a["type"] for a in config["agents"]} | |
| performer_cols = get_performer_columns(config) | |
| categories = sorted(df[cat_col].dropna().unique()) | |
| if not categories: | |
| return {"display": "none"}, None | |
| # Group performers by type | |
| human_perfs = [ | |
| pc for pc in performer_cols | |
| if agent_types.get(pc.rstrip("*"), "").lower() == "human" | |
| ] | |
| auto_perfs = [ | |
| pc for pc in performer_cols | |
| if agent_types.get(pc.rstrip("*"), "").lower() == "autonomous" | |
| ] | |
| if not human_perfs or not auto_perfs: | |
| return {"display": "none"}, None | |
| human_label = "Human" | |
| auto_label = ", ".join([pc.rstrip("*") for pc in auto_perfs]) | |
| children = [] | |
| for cat in categories: | |
| cat_df = df[df[cat_col] == cat] | |
| human_assignable = sum( | |
| 1 for _, row in cat_df.iterrows() | |
| for pc in human_perfs | |
| if pc in df.columns | |
| and str(row.get(pc, "") or "").strip().lower() in ("green", "yellow", "orange") | |
| ) | |
| auto_assignable = sum( | |
| 1 for _, row in cat_df.iterrows() | |
| for pc in auto_perfs | |
| if pc in df.columns | |
| and str(row.get(pc, "") or "").strip().lower() in ("green", "yellow", "orange") | |
| ) | |
| fig = go.Figure() | |
| max_count = max(auto_assignable, human_assignable, 1) | |
| _ts = time.time() | |
| fig.add_trace(go.Bar( | |
| y=[auto_label], x=[auto_assignable], orientation="h", | |
| marker_color="#555250", name=auto_label, | |
| text=[f"{auto_assignable}"], textposition="outside", | |
| cliponaxis=False, customdata=[_ts], | |
| )) | |
| fig.add_trace(go.Bar( | |
| y=[human_label], x=[human_assignable], orientation="h", | |
| marker_color="#555250", name=human_label, | |
| text=[f"{human_assignable}"], textposition="outside", | |
| cliponaxis=False, customdata=[_ts], | |
| )) | |
| fig.update_layout( | |
| title=f"{cat} ({len(cat_df)} tasks)", | |
| height=100, margin=dict(l=80, r=40, t=25, b=5), | |
| showlegend=False, barmode="group", | |
| xaxis=dict(showticklabels=False, showgrid=False, zeroline=False, | |
| range=[0, max_count * 1.5]), | |
| yaxis=dict(showgrid=False), | |
| plot_bgcolor=BG, paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| ) | |
| children.append(html.Div([ | |
| dcc.Graph( | |
| id={"type": "category-bar-chart", "category": cat}, | |
| figure=fig, config={"displayModeBar": False}, | |
| style={"height": "100px"}, | |
| ), | |
| dcc.Store(id={"type": "category-override", "category": cat}, data="default"), | |
| dcc.Store(id={"type": "category-counts", "category": cat}, | |
| data={"human": human_assignable, "auto": auto_assignable, | |
| "auto_label": auto_label, | |
| "title": f"{cat} ({len(cat_df)} tasks)"}), | |
| ], style={ | |
| "display": "inline-block", "width": "220px", | |
| "verticalAlign": "top", "margin": "4px", | |
| "border": f"1px solid {BORDER}", "padding": "3px", | |
| "backgroundColor": SURFACE, | |
| })) | |
| return {"display": "block"}, children | |
| def toggle_category_selection(click_data, current_selection, counts, highlight_value): | |
| """Toggle category override when clicking a bar.""" | |
| if not click_data: | |
| return dash.no_update, dash.no_update | |
| if not highlight_value or highlight_value == "none": | |
| return dash.no_update, dash.no_update | |
| clicked_label = click_data["points"][0].get("y", None) | |
| if clicked_label is None: | |
| return dash.no_update, dash.no_update | |
| auto_label = counts.get("auto_label", "Autonomous") | |
| # Map clicked label to agent type | |
| if clicked_label == auto_label: | |
| clicked_type = "autonomous" | |
| elif clicked_label == "Human": | |
| clicked_type = "human" | |
| else: | |
| return dash.no_update, dash.no_update | |
| # Toggle: clicking same bar deselects | |
| new_selection = "default" if current_selection == clicked_type else clicked_type | |
| # Rebuild figure with updated colors | |
| human_color = ACCENT if new_selection == "human" else "#555250" | |
| auto_color = ACCENT if new_selection == "autonomous" else "#555250" | |
| auto_count = counts.get("auto", 0) | |
| human_count = counts.get("human", 0) | |
| max_count = max(auto_count, human_count, 1) | |
| _ts = time.time() | |
| fig = go.Figure() | |
| fig.add_trace(go.Bar( | |
| y=[auto_label], x=[auto_count], orientation="h", | |
| marker_color=auto_color, name=auto_label, | |
| text=[f"{auto_count}"], textposition="outside", | |
| cliponaxis=False, customdata=[_ts], | |
| )) | |
| fig.add_trace(go.Bar( | |
| y=["Human"], x=[human_count], orientation="h", | |
| marker_color=human_color, name="Human", | |
| text=[f"{human_count}"], textposition="outside", | |
| cliponaxis=False, customdata=[_ts], | |
| )) | |
| fig.update_layout( | |
| title=counts.get("title", ""), | |
| height=100, margin=dict(l=80, r=40, t=25, b=5), | |
| showlegend=False, barmode="group", | |
| xaxis=dict(showticklabels=False, showgrid=False, zeroline=False, | |
| range=[0, max_count * 1.5]), | |
| yaxis=dict(showgrid=False), | |
| plot_bgcolor=BG, paper_bgcolor=BG, | |
| font=dict(family="Space Grotesk, Inter, sans-serif", color=INK), | |
| ) | |
| return new_selection, fig | |
| def show_category_override_warning(highlight_value): | |
| """Show a warning when no highlight track is selected.""" | |
| if not highlight_value or highlight_value == "none": | |
| return ( | |
| "β Select a highlight strategy first. Automation proportion requires a track " | |
| "to compute against β pick a highlight above, or specify every category manually." | |
| ) | |
| return "" | |
| def collect_category_overrides(values, ids): | |
| """Collect all category overrides into a single store.""" | |
| overrides = {} | |
| for id_dict, value in zip(ids, values): | |
| if value != "default": | |
| overrides[id_dict["category"]] = value | |
| return overrides | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MAIN | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| app.run(host="0.0.0.0", port=8050, debug=True) | |