import json import sys import argparse import re from pathlib import Path from typing import List, Dict, Tuple, cast from difflib import SequenceMatcher import numpy as np from f1_nodes_functions import * try: from rapidfuzz import fuzz except ImportError: class fuzz: @staticmethod def ratio(a, b): return SequenceMatcher(None, a, b).ratio() * 100 try: from scipy.optimize import linear_sum_assignment HAS_SCIPY = True except ImportError: HAS_SCIPY = False # ========================= ANSI COLORS ========================= class C: HDR = "\033[95m" OKB = "\033[94m" OKG = "\033[92m" OKGREEN = "\033[92m" W = "\033[93m" R = "\033[91m" B = "\033[1m" DIM = "\033[2m" END = "\033[0m" HEADER = "\033[95m" OKCYAN = "\033[96m" FAIL = "\033[91m" WARNING = "\033[93m" ENDC = "\033[0m" LOG_FILE = None def custom_print(message: str = "", end: str = "\n"): """ Write the message to the global log file and to the console. """ global LOG_FILE full_message = message + end # Write to the log file if open if LOG_FILE: LOG_FILE.write(full_message) LOG_FILE.flush() # Write to the console (standard output) sys.stdout.write(full_message) sys.stdout.flush() # ========================= CORE FUNCTIONS ========================= def _normalize_name_nodes(name: str) -> str: """Standardize node names for comparison (based on gen_F1_nodes.py).""" return str(name or "").replace('-', '').replace('_', '').upper() def rf_score(a: str, b: str) -> float: """Calculate the similarity score between two node names.""" a_norm = _normalize_name_nodes(a) b_norm = _normalize_name_nodes(b) if hasattr(fuzz, "token_set_ratio"): return float(fuzz.token_set_ratio(a_norm, b_norm)) else: return 100.0 * SequenceMatcher(None, a_norm, b_norm).ratio() def load_nodes(data: Dict) -> List[Dict]: nodes = [] devices = data["result"]["network_topology"].get("devices", []) for device in devices: nodes.append({ "device_name": device.get("device_name"), "network_layer": str(device.get("network_layer", "access")).lower(), "device_type": device.get("device_type", "router"), "location_site": str(device.get("location_site")).lower(), }) return nodes def hungarian_match_nodes(sim: np.ndarray): """Apply the Hungarian algorithm (based on gen_F1_nodes.py logic).""" if not HAS_SCIPY: return [] m, n = sim.shape cost = 100.0 - sim row_ind, col_ind = linear_sum_assignment(cost) pairs = [(r, c, float(sim[r, c])) for r, c in zip(row_ind, col_ind) if r < m and c < n] return pairs def normalize_gen_to_ref_topology(ref_nodes, gen_nodes, min_sim: float = 9.0): """Normalize GEN sites and types to match REF using the Hungarian algorithm.""" if not gen_nodes or not ref_nodes: return gen_nodes # 1. Extract unique REF sites and types ref_sites = list(set(n.get('location_site') for n in ref_nodes)) ref_types = list(set(n.get('device_type') for n in ref_nodes)) # 2. Extract unique GEN sites and types gen_sites = list(set(n.get('location_site') for n in gen_nodes)) gen_types = list(set(n.get('device_type') for n in gen_nodes)) site_mapping = {} type_mapping = {} # 3. Normalize GEN sites by matching to REF sites if ref_sites and gen_sites: site_sim = build_similarity_matrix_nodes(ref_sites, gen_sites) for j, gen_site in enumerate(gen_sites): best_ref_idx = max(range(len(ref_sites)), key=lambda i: site_sim[i][j]) best_score = site_sim[best_ref_idx][j] if best_score >= min_sim: site_mapping[gen_site] = ref_sites[best_ref_idx] print(f" GEN site: {gen_site} → REF site (max similarity): {ref_sites[best_ref_idx]} (score: {best_score:.2f})") else: print(f" Warning: GEN site '{gen_site}' has no reliable match (best score: {best_score:.2f})") # 4. Apply site normalization to GEN nodes print(f" Applying site normalization:") for node in gen_nodes: original_site = node.get('location_site') if original_site in site_mapping: new_site = site_mapping[original_site] node['location_site'] = new_site print(f" Node {node['device_name']}: site {original_site} → {new_site}") # 5. Pre-normalize server-like types directly to Server print(f" Pre-normalizing types containing 'server':") for node in gen_nodes: original_type = node.get('device_type', '') if original_type and any(keyword in original_type.lower() for keyword in ['server', 'videodelivery', 'haproxy']): node['device_type'] = 'Server' print(f" Node {node['device_name']}: type {original_type} → Server (contains 'server')") # 6. Normalize types by layer using the Hungarian algorithm if ref_types and gen_types: print(f" Normalizing types by layer:") # Group nodes by layer ref_nodes_by_layer = {} gen_nodes_by_layer = {} for node in ref_nodes: layer = node.get('network_layer') if layer not in ref_nodes_by_layer: ref_nodes_by_layer[layer] = [] ref_nodes_by_layer[layer].append(node) for node in gen_nodes: layer = node.get('network_layer') if layer not in gen_nodes_by_layer: gen_nodes_by_layer[layer] = [] gen_nodes_by_layer[layer].append(node) all_layers = set(ref_nodes_by_layer.keys()) | set(gen_nodes_by_layer.keys()) for layer in sorted(all_layers): ref_layer_nodes = ref_nodes_by_layer.get(layer, []) gen_layer_nodes = gen_nodes_by_layer.get(layer, []) if not ref_layer_nodes or not gen_layer_nodes: continue ref_layer_types = list(set(n.get('device_type') for n in ref_layer_nodes)) gen_layer_types = list(set(n.get('device_type') for n in gen_layer_nodes)) print(f" Layer {layer}: {len(ref_layer_types)} REF types vs {len(gen_layer_types)} GEN types") type_sim = build_similarity_matrix_nodes(ref_layer_types, gen_layer_types) type_pairs = hungarian_match_nodes(type_sim) layer_type_mapping = {} for i, j, s in type_pairs: if s >= min_sim: layer_type_mapping[gen_layer_types[j]] = ref_layer_types[i] print(f" GEN type: {gen_layer_types[j]} → REF type: {ref_layer_types[i]} (score: {s:.2f})") type_mapping.update(layer_type_mapping) # 7. Apply normalized types to GEN nodes print(f" Applying type normalization:") for node in gen_nodes: original_type = node.get('device_type') if original_type in type_mapping: new_type = type_mapping[original_type] node['device_type'] = new_type print(f" Node {node['device_name']}: type {original_type} → {new_type}") print(f"Normalization complete: {len(site_mapping)} sites and {len(type_mapping)} types normalized") return gen_nodes, site_mapping, type_mapping def get_mapping(ref_nodes, gen_nodes, min_sim: float = 9.0): """Hierarchical mapping: layer → site → type using the Hungarian algorithm (adapted from gen_F1_nodes.py logic).""" if not gen_nodes: return {} mapping: Dict[str, str] = {} # Hierarchical grouping: layer → site → type ref_groups: Dict[str, Dict[str, List[str]]] = {} gen_groups: Dict[str, Dict[str, List[str]]] = {} # Group REF nodes by layer → site → type for n in ref_nodes: if n["device_name"] and isinstance(n["device_name"], str): layer = n["network_layer"] site = n.get("location_site") device_type = n.get("device_type") if layer not in ref_groups: ref_groups[layer] = {} if site not in ref_groups[layer]: ref_groups[layer][site] = {} if device_type not in ref_groups[layer][site]: ref_groups[layer][site][device_type] = [] ref_groups[layer][site][device_type].append(n["device_name"]) # Group GEN nodes by layer → site → type for n in gen_nodes: if n["device_name"] and isinstance(n["device_name"], str): layer = n["network_layer"] site = n.get("location_site") device_type = n.get("device_type") if layer not in gen_groups: gen_groups[layer] = {} if site not in gen_groups[layer]: gen_groups[layer][site] = {} if device_type not in gen_groups[layer][site]: gen_groups[layer][site][device_type] = [] gen_groups[layer][site][device_type].append(n["device_name"]) all_keys = set(ref_groups.keys()) | set(gen_groups.keys()) custom_print(f"\n{C.HDR}{C.B}===== Hierarchical Mapping: Layer → Site → Type (Hungarian Algorithm) ====={C.END}") for layer in sorted(all_keys, key=lambda x: (x != 'null', x)): ref_layer_dict = ref_groups.get(layer, {}) gen_layer_dict = gen_groups.get(layer, {}) display_layer = layer.upper() if layer != 'null' else layer custom_print(f"\n{C.OKB}--- Layer: {display_layer} ---{C.END}") # Process each site in this layer all_sites = set(ref_layer_dict.keys()) | set(gen_layer_dict.keys()) for site in sorted(all_sites): ref_site_types = ref_layer_dict.get(site, {}) gen_site_types = gen_layer_dict.get(site, {}) if not ref_site_types and not gen_site_types: continue custom_print(f" Site: {site}") # Process each type within this site/layer all_types = set(ref_site_types.keys()) | set(gen_site_types.keys()) for device_type in sorted(all_types): ref_names = ref_site_types.get(device_type, []) gen_names = gen_site_types.get(device_type, []) if not ref_names or not gen_names: if ref_names: custom_print(f" {C.R}Local FN {device_type}: {', '.join(ref_names)}{C.END}") if gen_names: custom_print(f" {C.R}Local FP {device_type}: {', '.join(gen_names)}{C.END}") continue # Apply the Hungarian algorithm for this specific group sim = build_similarity_matrix_nodes(ref_names, gen_names) pairs = hungarian_match_nodes(sim) group_matches = {} matches = [] for i, j, s in pairs: r_name = ref_names[i] g_name = gen_names[j] if s >= min_sim and r_name and g_name: group_matches[r_name] = g_name mapping[r_name] = g_name matches.append((r_name, g_name, s)) if matches: custom_print(f" {C.OKG}TP {device_type}: {len(matches)} match(s){C.END}") for r_name, g_name, score in matches: custom_print(f" - REF: {r_name:<20} <--> GEN: {g_name:<20} (Score: {score:.2f})") else: custom_print(f" {C.W}No matches for {device_type} (threshold: {min_sim:.1f}%){C.END}") # Display local FN/FP for this group fn_nodes = [name for name in ref_names if name not in group_matches] fp_nodes = [name for name in gen_names if name not in group_matches.values()] if fn_nodes: custom_print(f" {C.R}Local FN {device_type} ({len(fn_nodes)}): {', '.join(fn_nodes[:3])}{'...' if len(fn_nodes) > 3 else ''}{C.END}") if fp_nodes: custom_print(f" {C.R}Local FP {device_type} ({len(fp_nodes)}): {', '.join(fp_nodes[:3])}{'...' if len(fp_nodes) > 3 else ''}{C.END}") custom_print(f"\n{C.OKG}Final mapping: {len(mapping)} correspondences{C.END}") return mapping def rf_score_nodes(a: str, b: str) -> float: """Calculate the similarity score between two node names.""" a_norm = _normalize_name_nodes(a) b_norm = _normalize_name_nodes(b) if hasattr(fuzz, 'token_set_ratio'): return float(fuzz.token_set_ratio(a_norm, b_norm)) else: return fuzz.ratio(a_norm, b_norm) def build_similarity_matrix_nodes(ref_names: List[str], gen_names: List[str]) -> np.ndarray: """Build the similarity matrix (based on gen_F1_nodes.py logic).""" M = np.zeros((len(ref_names), len(gen_names)), dtype=float) for i, a in enumerate(ref_names): for j, b in enumerate(gen_names): if a and b: M[i, j] = rf_score_nodes(a, b) return M def hungarian_match(sim: np.ndarray): """Apply the Hungarian algorithm.""" if not HAS_SCIPY: custom_print(f"{C.R}Error: SciPy is not installed. Required for the Hungarian algorithm.{C.END}") sys.exit(1) m, n = sim.shape cost = 100.0 - sim row_ind, col_ind = linear_sum_assignment(cost) pairs = [(r, c, cast(float, sim[r, c])) for r, c in zip(row_ind, col_ind) if r < m and c < n] return pairs def convert_nodes_format(nodes: List[Dict]) -> List[Dict]: converted = [] for n in nodes: # Add default device_type if missing device_type = n.get("device_type", "router") converted.append({ "device_name": n["name"], "network_layer": n["layer"], "device_type": device_type, "location_site": n.get("site", "default"), "connections": [] # No connections for node mapping }) return converted def mapping_by_layer(ref_nodes: List[Dict], gen_nodes: List[Dict], min_sim: float, test_filename: str) -> Dict[str, str]: """Create the initial REF->GEN mapping using hierarchical layer/site/type grouping.""" if not gen_nodes: return {} ref_nodes_converted = convert_nodes_format(ref_nodes) gen_nodes_converted = convert_nodes_format(gen_nodes) mapping = get_mapping(ref_nodes_converted, gen_nodes_converted, min_sim) custom_print(f"\n{C.OKG}➡️ Final mapping size (TP_nodes):{C.END} {len(mapping)}") return mapping def calculate_node_metrics(mapping: Dict[str, str], ref_nodes: List[Dict], gen_nodes: List[Dict]): """ Calculate TP, FP, FN, precision, recall, and F1. **Exact calculation logic as in reference** + F1 penalty = 0 for invalid generated JSON. """ ref_count = len(ref_nodes) gen_count = len(gen_nodes) # --- COMPUTATION LOGIC --- if gen_count == 0 and len(mapping) == 0: # Penalty for invalid or empty generated JSON TP_nodes = 0 FP_nodes = 0 FN_nodes = ref_count P_nodes = 0.0 R_nodes = 0.0 F1_nodes = 0.0 percent_mapping = 0.0 else: TP_nodes = len(mapping) FN_nodes = ref_count - TP_nodes FP_nodes = gen_count - TP_nodes P_nodes = TP_nodes / (TP_nodes + FP_nodes) if (TP_nodes + FP_nodes) else 0.0 R_nodes = TP_nodes / (TP_nodes + FN_nodes) if (TP_nodes + FN_nodes) else 0.0 F1_nodes = 2 * P_nodes * R_nodes / (P_nodes + R_nodes) if (P_nodes + R_nodes) else 0.0 percent_mapping = R_nodes * 100.0 # ------------------------- # Detailed display of final metrics (logs) # **Keep .6f precision for detailed logs** custom_print(f"\n{C.HDR}{C.B}===== NODE F1-SCORE RESULTS (Final Calculation) ====={C.END}") custom_print(f" • REF nodes (Total): {ref_count}") custom_print(f" • GEN nodes (Total): {gen_count}") custom_print("-" * 50) custom_print(f" • True Positives ({C.B}TP_nodes{C.END}): {TP_nodes}") custom_print(f" • False Positives ({C.B}FP_nodes{C.END}): {FP_nodes}") custom_print(f" • False Negatives ({C.B}FN_nodes{C.END}): {FN_nodes}") custom_print("-" * 50) custom_print(f" • Precision ($P_{{nodes}}$): {P_nodes:.6f}") custom_print(f" • Recall ($R_{{nodes}}$): {R_nodes:.6f}") custom_print(f" • {C.B}Mapping percentage{C.END}: {percent_mapping:.2f} %") custom_print(f"{C.OKG} • Node F1-score ($F1_{{nodes}}$): {F1_nodes:.6f}{C.END}") return { "TP_nodes": TP_nodes, "FP_nodes": FP_nodes, "FN_nodes": FN_nodes, "P_nodes": P_nodes, "R_nodes": R_nodes, "F1_nodes": F1_nodes, "ref_count": ref_count, "gen_count": gen_count, "percent_mapping": percent_mapping } # ========================= MAIN ========================= def main(): ap = argparse.ArgumentParser(description="Calculate node metrics (TP/FP/FN/P/R/F1) for topologies.") ap.add_argument("--gen", type=Path, nargs="+", required=True, help="One or more files/directories containing generated JSON topologies to evaluate.") ap.add_argument("--ref", type=Path, help="Reference file to use (optional). If omitted, the script looks for topo_ref_case_*.json automatically.") ap.add_argument("--min-sim", type=float, default=9.0, help="Minimum similarity threshold (%) for Hungarian matching.") ap.add_argument("--output", type=Path, default=Path("analyse_metriques_nodes_detailed.txt"), help="Output file for detailed results.") args = ap.parse_args() input_base = args.gen[0] if args.gen[0].is_dir() else args.gen[0].parent results_dir = input_base / "results" results_dir.mkdir(parents=True, exist_ok=True) if not args.output.is_absolute() and args.output.parent == Path('.'): args.output = results_dir / args.output global LOG_FILE # Try opening the log file try: LOG_FILE = open(args.output, "w", encoding="utf-8") custom_print(f"{C.OKGREEN}✅ Detailed output saved to: {args.output}{C.ENDC}") except Exception as e: custom_print(f"{C.FAIL}❌ ERROR: Unable to open output file {args.output}. Logging will only print to console. Error: {e}{C.ENDC}") LOG_FILE = None summary_table = [] f1_scores = [] current_ref_path = None ref_nodes = [] # Collect all generated file paths gen_files = [] for p in args.gen: path = Path(p) if path.is_dir(): # Only process top-level files, not subdirectories for json_file in sorted(path.glob("*.json")): gen_files.append(json_file) elif path.is_file() and path.suffix.lower() == ".json": gen_files.append(path) else: custom_print(f"{C.WARNING} Skipped unsupported path or format: {path}{C.ENDC}") if not gen_files: custom_print(f"{C.FAIL}No JSON files found in the provided paths!{C.ENDC}") sys.exit(1) for gen_file in gen_files: gen_path = gen_file if args.ref: ref_path = args.ref.resolve() if not ref_path.exists(): raise FileNotFoundError(f"Specified reference file not found: {ref_path}") else: match = re.search(r'case_(\d+)', gen_path.name) if not match: custom_print(f"No case_X found in filename {gen_path.name}") continue case_id = match.group(1) ref_path = gen_path.parent / f"topo_ref_case_{case_id}.json" if not ref_path.exists(): custom_print(f"Missing reference for {gen_path.name}") continue if ref_path != current_ref_path: if args.ref: custom_print(f"\n{C.HEADER}*** SPECIFIED REFERENCE: {ref_path.name} ({ref_path.parent}) ***{C.ENDC}") else: custom_print(f"\n{C.HEADER}*** NEW REFERENCE: {ref_path.name} ({ref_path.parent}) ***{C.ENDC}") with open(ref_path) as f: ref_nodes = load_nodes(json.load(f)) current_ref_path = ref_path custom_print(f"\n{C.HEADER}{'='*70}") custom_print(f"ANALYSIS: {gen_path.name}") custom_print(f"{'='*70}{C.ENDC}") # Load the generated file with open(gen_path) as f: gen_data = json.load(f) # Check if the devices list is empty gen_devices = gen_data.get("result", {}).get("network_topology", {}).get("devices", []) if len(gen_devices) == 0: custom_print(f"{C.WARNING}⚠️ Empty devices list detected - format has devices: []{C.ENDC}") custom_print(f"{C.FAIL}Generated topology is empty, no mapping processing will be performed{C.ENDC}") # Calculate FN = total nodes in the reference fn_final = len(ref_nodes) tp_final = 0 fp_final = 0 custom_print(f"{C.OKCYAN}Immediate results:{C.ENDC}") custom_print(f" Final TP: {tp_final}") custom_print(f" Final FP: {fp_final}") custom_print(f" Final FN: {fn_final}") # Compute F1 p = tp_final / (tp_final + fp_final) if (tp_final + fp_final) > 0 else 0 r = tp_final / (tp_final + fn_final) if (tp_final + fn_final) > 0 else 0 f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0 custom_print(f"{C.OKG}FINAL RESULTS (EMPTY DEVICES):{C.END}") custom_print(f" Precision (P): {p:.4f}") custom_print(f" Recall (R): {r:.4f}") custom_print(f" F1 score: {f1:.4f}") f1_scores.append(f1) summary_table.append({ 'test': gen_path.name, 'ref_path': ref_path.parent.name, 'TP': tp_final, 'FP': fp_final, 'FN': fn_final, 'precision': p, 'recall': r, 'F1': f1, 'mapping_size': f"0/{len(ref_nodes)}", }) continue # If devices is not empty, continue normal processing gen_nodes = load_nodes(gen_data) # A. Normalize GEN topology to REF with Hungarian algorithm gen_nodes, site_mapping, type_mapping = normalize_gen_to_ref_topology(ref_nodes, gen_nodes, args.min_sim) custom_print(f"{C.OKCYAN}FULL DEBUG - NODE ANALYSIS{C.ENDC}") custom_print(f"Total REF nodes: {len(ref_nodes)}") custom_print(f"Total GEN nodes: {len(gen_nodes)}") mapping = get_mapping(ref_nodes, gen_nodes, args.min_sim) mapped_fp_names = set(mapping.values()) mapped_fn_names = set(mapping.keys()) custom_print(f"Mapping found: {len(mapping)} correspondences") custom_print(f"mapped_fn_names (REF): {len(mapped_fn_names)} nodes") custom_print(f"mapped_fp_names (GEN): {len(mapped_fp_names)} nodes") # Debug: list all nodes all_ref_names = {n['device_name'] for n in ref_nodes} all_gen_names = {n['device_name'] for n in gen_nodes} custom_print(f"All REF names: {all_ref_names}") custom_print(f"All GEN names: {all_gen_names}") stats = calculate_node_metrics(mapping, ref_nodes, gen_nodes) summary_table.append({ "test": gen_path.name, "ref_path": ref_path.parent.name, "TP": stats["TP_nodes"], "FP": stats["FP_nodes"], "FN": stats["FN_nodes"], "precision": stats["P_nodes"], "recall": stats["R_nodes"], "F1": stats["F1_nodes"], "mapping_size": f"{len(mapping)}/{max(len(ref_nodes), len(gen_nodes))}", }) # ======= FINAL GLOBAL TABLE ======= if summary_table: custom_print(f"\n{C.HDR}{C.B}===== GLOBAL NODE SUMMARY TABLE ====={C.END}") # console display custom_print(f"{'Test':<25} {'RefDir':<10} {'TP':<5} {'FP':<5} {'FN':<5} {'P':<6} {'R':<6} {'F1':<6} {'C(mapp)':<10}") custom_print("-" * 85) for s in summary_table: custom_print(f"{s['test']:<25} {s['ref_path']:<10} {s['TP']:<5} {s['FP']:<5} {s['FN']:<5} " f"{s['precision']:.2f} {s['recall']:.2f} {s['F1']:.2f} {s['mapping_size']}") # Compute global statistics f1_scores = [s['F1'] for s in summary_table] if f1_scores: mean_f1 = np.mean(f1_scores) std_f1 = np.std(f1_scores) custom_print("-" * 85) custom_print(f"{'MEAN':<25} {'':<10} {'':<5} {'':<5} {'':<5} {'':<6} {'':<6} {C.OKG}{mean_f1:.2f}{C.END}{'':<6} {'':<10}") custom_print(f"{'STD DEV':<25} {'':<10} {'':<5} {'':<5} {'':<5} {'':<6} {'':<6} {C.OKG}{std_f1:.2f}{C.END}{'':<6} {'':<10}") custom_print(f"{'NUMBER OF TESTS':<25} {'':<10} {'':<5} {'':<5} {'':<5} {'':<6} {'':<6} {len(f1_scores):.0f}{'':<6} {'':<10}") if gen_files: # Use the root results folder for summary outputs first_gen_path = gen_files[0] summary_path = results_dir / f"{first_gen_path.parent.name}_F1_nodes.txt" # Copy detailed log contents to the summary output file if LOG_FILE and args.output.exists(): with open(args.output, "r", encoding="utf-8") as log_file: log_content = log_file.read() with open(summary_path, "w", encoding="utf-8") as f: f.write(log_content) else: # If there is no log file, create a file containing the summary table with open(summary_path, "w", encoding="utf-8") as f: f.write("Node analysis - global summary\n") f.write(f"{'Test':<25} {'RefDir':<10} {'TP':<5} {'FP':<5} {'FN':<5} {'P':<6} {'R':<6} {'F1':<6} {'C(mapp)':<10}\n") f.write("-" * 85 + "\n") for s in summary_table: f.write(f"{s['test']:<25} {s['ref_path']:<10} {s['TP']:<5} {s['FP']:<5} {s['FN']:<5} " f"{s['precision']:.2f} {s['recall']:.2f} {s['F1']:.2f} {s['mapping_size']}\n") if f1_scores: f.write("-" * 85 + "\n") f.write(f"{'MEAN':<25} {'':<10} {'':<5} {'':<5} {'':<5} {'':<6} {'':<6} {mean_f1:.2f}{'':<6} {'':<10}\n") f.write(f"{'STD DEV':<25} {'':<10} {'':<5} {'':<5} {'':<5} {'':<6} {'':<6} {std_f1:.2f}{'':<6} {'':<10}\n") f.write(f"{'NUMBER OF TESTS':<25} {'':<10} {'':<5} {'':<5} {'':<5} {'':<6} {'':<6} {len(f1_scores):.0f}{'':<6} {'':<10}\n") custom_print(f"\n{C.OKG} Global summary saved to:{C.END} {summary_path}\n") else: custom_print(f"\n{C.W}No results to display in the global summary table.{C.END}") if __name__ == "__main__": try: main() except KeyboardInterrupt: custom_print("\nInterrupted by user.") sys.exit(130) finally: if LOG_FILE: LOG_FILE.close()