diff --git a/apps/analysis.py b/apps/analysis.py index af4b1460..3994b021 100644 --- a/apps/analysis.py +++ b/apps/analysis.py @@ -12,6 +12,7 @@ import html import json import os +import re from pathlib import Path import pandas as pd @@ -101,6 +102,28 @@ def get_run_directories(output_dir: Path) -> list[Path]: return sorted(run_dirs, key=lambda d: d.name, reverse=True) +def _system_name_from_run(run_dir: Path) -> str: + """Extract the system name from a run folder name (_).""" + m = re.match(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.\d+_(.+)$", run_dir.name) + return m.group(1) if m else run_dir.name + + +def filter_latest_runs(run_dirs: list[Path]) -> list[Path]: + """Keep only the most recent run per system name. + + Assumes run_dirs is already sorted newest-first (as returned by + get_run_directories), so the first occurrence of each system name wins. + """ + seen: set[str] = set() + result = [] + for d in run_dirs: + system = _system_name_from_run(d) + if system not in seen: + seen.add(system) + result.append(d) + return result + + def get_record_directories(run_dir: Path) -> list[Path]: """Get all record directories in a run, sorted by record ID.""" records_dir = run_dir / "records" @@ -448,12 +471,24 @@ def _model_suffix_from_config(run_config: dict) -> str: return "_".join(p for p in parts if p) -def _get_run_label(run_name: str, run_config: dict) -> str: - """Build a display label for a run, appending model info if not already in the name.""" +_TIMESTAMP_RUN_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.\d+)_(.+)$") + + +def _get_system_and_timestamp(run_name: str, run_config: dict) -> tuple[str, str]: + """Return (system_name, timestamp) for a run.""" + m = _TIMESTAMP_RUN_RE.match(run_name) + if m: + return m.group(2), m.group(1) suffix = _model_suffix_from_config(run_config) - if not suffix or suffix in run_name: - return run_name - return f"{run_name} ({suffix})" + if suffix and suffix not in run_name: + return f"{suffix} ({run_name})", "" + return run_name, "" + + +def _get_run_label(run_name: str, run_config: dict) -> str: + """Build a display label for a run (used for chart legends).""" + system, timestamp = _get_system_and_timestamp(run_name, run_config) + return f"{system} ({timestamp})" if timestamp else system def _color_cell(val): @@ -943,9 +978,13 @@ def render_cross_run_comparison(run_dirs: list[Path], output_dir_str: str = ""): metric_names = list(per_metric.keys()) all_metric_names.update(metric_names) model_details = _extract_model_details(run_config) + system_name, run_timestamp = _get_system_and_timestamp(run_name, run_config) summary: dict = { "run": run_name, + "run_output_dir": str(run_dir.parent), "label": _get_run_label(run_name, run_config), + "system_name": system_name, + "run_timestamp": run_timestamp, "records": metrics_summary.get("total_records", 0), "pipeline_type": _classify_pipeline_type(run_config), **model_details, @@ -967,9 +1006,13 @@ def render_cross_run_comparison(run_dirs: list[Path], output_dir_str: str = ""): all_metric_names.update(metric_names) df = pd.DataFrame(rows) model_details = _extract_model_details(run_config) + system_name, run_timestamp = _get_system_and_timestamp(run_name, run_config) summary = { "run": run_name, + "run_output_dir": str(run_dir.parent), "label": _get_run_label(run_name, run_config), + "system_name": system_name, + "run_timestamp": run_timestamp, "records": len(df), "pipeline_type": _classify_pipeline_type(run_config), **model_details, @@ -1030,19 +1073,26 @@ def render_cross_run_comparison(run_dirs: list[Path], output_dir_str: str = ""): # Metrics table: EVA composites first, then all individual metrics table_composites = [c for c in _EVA_BAR_COMPOSITES if c in summary_df.columns] - display_cols = ["label", "records"] + table_composites + ordered_metrics + display_cols = ["system_name", "run_timestamp", "records"] + table_composites + ordered_metrics display_df = summary_df[display_cols].copy() - # Add link column to navigate to Run Overview - output_dir_str = str(run_dirs[0].parent) if run_dirs else "" + # Add link column to navigate to Run Overview (use per-run output dir to support multiple output dirs) display_df.insert( 0, "link", - summary_df["run"].apply(lambda r: f"?output_dir={output_dir_str}&view=Run+Overview&run={r}"), + summary_df.apply(lambda row: f"?output_dir={row['run_output_dir']}&view=Run+Overview&run={row['run']}", axis=1), ) composite_rename = {c: f"[EVA] {_EVA_COMPOSITE_DISPLAY[c]}" for c in table_composites} - display_df = display_df.rename(columns={"label": "Run", "records": "# Records", **composite_rename, **col_rename}) + display_df = display_df.rename( + columns={ + "system_name": "System", + "run_timestamp": "Timestamp", + "records": "# Records", + **composite_rename, + **col_rename, + } + ) renamed_composites = [composite_rename[c] for c in table_composites] renamed_metrics = [col_rename[m] for m in ordered_metrics] all_score_cols = renamed_composites + renamed_metrics @@ -1752,14 +1802,22 @@ def main(): query_params = st.query_params # Sidebar: output directory selection - st.sidebar.header("Output Directory") + st.sidebar.header("Output Directories") default_output = query_params.get("output_dir", _DEFAULT_OUTPUT_DIR) - output_dir = Path(st.sidebar.text_input("Path to output directory", value=default_output)) + # Normalize comma-separated (from URL query params) to newline-separated (for text area) + if "," in default_output and "\n" not in default_output: + default_output = "\n".join(p.strip() for p in default_output.split(",")) + output_dirs_input = st.sidebar.text_area("Paths to output directories (one per line)", value=default_output) + output_dirs = [Path(p.strip()) for p in output_dirs_input.splitlines() if p.strip()] or [Path(_DEFAULT_OUTPUT_DIR)] + + run_dirs = [rd for od in output_dirs for rd in get_run_directories(od)] - run_dirs = get_run_directories(output_dir) + latest_only = st.sidebar.toggle("Latest run per system only", value=True) + if latest_only: + run_dirs = filter_latest_runs(run_dirs) if not run_dirs: - st.error(f"No run directories found in {output_dir}") + st.error(f"No run directories found in: {', '.join(str(d) for d in output_dirs)}") return # View mode @@ -1770,17 +1828,19 @@ def main(): view_mode = st.sidebar.radio("View", view_options, index=default_view_idx, label_visibility="collapsed") if view_mode == "Cross-Run Comparison": - render_cross_run_comparison([output_dir / d.name for d in run_dirs], str(output_dir)) + render_cross_run_comparison(run_dirs, ", ".join(str(d) for d in output_dirs)) return # Sidebar: run selection st.sidebar.header("Run Selection") - run_dir_names = [d.name for d in run_dirs] + multiple_output_dirs = len(output_dirs) > 1 + run_dir_labels = [str(d) if multiple_output_dirs else d.name for d in run_dirs] default_run_idx = 0 - if "run" in query_params and query_params["run"] in run_dir_names: - default_run_idx = run_dir_names.index(query_params["run"]) - selected_run_name = st.sidebar.selectbox("Select Run", run_dir_names, index=default_run_idx) - selected_run_dir = output_dir / selected_run_name + if "run" in query_params and query_params["run"] in run_dir_labels: + default_run_idx = run_dir_labels.index(query_params["run"]) + selected_run_label = st.sidebar.selectbox("Select Run", run_dir_labels, index=default_run_idx) + selected_run_dir = run_dirs[run_dir_labels.index(selected_run_label)] + selected_run_name = selected_run_dir.name run_config = _load_run_config(selected_run_dir) if run_config: @@ -1791,9 +1851,9 @@ def main(): if view_mode == "Run Overview": st.query_params.from_dict( { - "output_dir": str(output_dir), + "output_dir": ",".join(str(d) for d in output_dirs), "view": "Run Overview", - "run": selected_run_name, + "run": selected_run_label, } ) render_run_overview(selected_run_dir) @@ -1840,9 +1900,9 @@ def main(): # Update query params for deep linking new_params = { - "output_dir": str(output_dir), + "output_dir": ",".join(str(d) for d in output_dirs), "view": "Record Detail", - "run": selected_run_name, + "run": selected_run_label, "record": selected_record_name, } if selected_trial: