Project: s41467-024-52614-w | Function: clusterAnalysis (python)

system: You are an expert Python programmer implementing algorithms from scientific papers.
Complete the masked function based on the algorithm description.
Follow Python best practices and maintain consistent code style.
Return **only** the complete Python function code.

user: Complete this Python function:

**DESCRIPTION:** Declustering analysis that resolves sequence clusters by metanode, processing CD-HIT cluster files and table formats to extract all sequences belonging to each metanode. This supports the epistatic network analysis by providing complete sequence membership information.

**KEY TERMS:** {
  "reps_by_node": "representative sequences per metanode",
  "seq_by_node": "all sequences per metanode",
  "cdhit": "CD-HIT clustering format",
  "declusterDict": "declustering operation"
}

**CONTEXT CODE:**         # remap node members, if given
        for map_file in member_name_mapping:
            map(outfiles['membership'], f"{mapping_path}{map_file}")
        
        # ======== decluster members if the option is selected =========
        # resolve cluster sequences by metanode
        
        seq_by_node, seq_by_node_table = None, None
        if analyze_clustering:
            debugOut('Cluster analysis selected.','start block')
            old_cluster_file = expected_files['all_seq'] if findOldAnalysis([expected_files['all_seq']]) else None
            print(old_cluster_file)
            if not use_old_clustering or (use_old_clustering and old_cluster_file is None):
                seq_by_node, seq_by_node_table = clusterAnalysis(outfiles['membership'])
                with open(expected_files['all_seq'],'w') as f:
                    f.write('\t'.join(['node','sequence'])+'\n') # write the header
                    for node, seqs in seq_by_node.items():
                        for seq in seqs:
                            f.write(f'{node}\t{seq}\n') # write all sequences to the file
            else:
                debugOut(f'Using previous clustering at {metanode_bitscore} clustering bitscore and {analysis_min_bitscore} min bitscore','end block')
                seq_by_node, seq_by_node_table = readMembership(old_cluster_file)
            
        # further annotations
        if annot_metanodes:
            debugOut('Processing additional annotations.','block start')
            reps_by_node,  reps_by_node_table = readMembership(outfiles['membership'])
            
            node_stats = {} # storage for all node level annotations
            for node, reps in reps_by_node.items():
                node_stats[node] = { 'member_count':len(reps),'seq_count':0 }
                
            if seq_by_node is not None: # skip if declustering was not conducted
                for node, seqs in seq_by_node.items():
                    node_stats[node]['seq_count'] = len(seqs) # get the number of sequences per node, as opposed to just the representatives
            else:
                debugOut('Annotations are restricted to the \'member\' level, as declustering was not conducted, or the previous declustering file was not found.')
                
            annotate(node_stats, reps_by_node_table, seq_by_node_table, annotations, freq_limits)
            debugOut('Updating metanode summary file with new annotations.')
            stat_order = node_stats[list(node_stats.keys())[0]].keys() # grab second level headers from an arbitrary node
            with open(outfiles['nodes'],'w') as f:
                header = 'node' + "\t" + '\t'.join(stat_order) + '\n'
                f.write(header)
                for node in node_stats.keys():
                    f.write(f"{node}\t"+'\t'.join([str(node_stats[node][stat]) for stat in stat_order]) + '\n')
                        
            debugOut('Annotations complete.','end block')
    
# ===============  Helper functions ================
def clusterAnalysis(members_file):
    raise NotImplementedError("Function implementation has been masked")

def annotate(node_stats, reps_by_node_table, seq_by_node_table, annotations, freq_limits):
    
    debugOut('Compiling annotations.', 'start block')
    # count based annotation
    for annot_set in annotation_data_sets:
        for info in annot_set:
            if info['level'] == 'member':
                node_ref = reps_by_node_table
            elif info['level'] == 'sequence':
                if seq_by_node_table is None:
                    continue # skip to the next file
                node_ref = seq_by_node_table
            stat_header = f"{info['level']}:{info['label']}"
            annot_type = annotations[stat_header]
            delim, id_col, data_col = info.get('delim'), info.get('id_col'), info.get('data_col')
            parse_table = delim is not None and id_col is not None and data_col is not None
            
            
            files = info['files']
            if type(files) == type(""):
                files = [files]
            agg = {} # data collection container
            for node, _ in node_stats.items():
                node_stats[node][stat_header] = default_annot_values[annot_type] # initialize count for all nodes
                agg[node] = []
            for file in files:
                with open(f"{annot_path}{file}",'r') as f: # try to open the file
                    debugOut(f'Annotating by \'{annot_type}\' for members in file \'{file}\' for \'{info["label"]}\' label at the \'{info["level"]}\' level.')
                    
                    for curline in f:
                        line = curline.strip()
                        if line=="":
                            continue
                        if parse_table:
                            row = line.split(delim)
                            id = row[id_col].strip()
                            val = row[data_col].strip()
                        else: # counting
                            id = line.strip()
                            val = 1
                        if id in node_ref: # if all reps are unique, this should indicate one match
                            agg[node_ref[id]].append(val)
                    # summarize and/or format results
            if annot_type == 'count':
                for node, values in agg.items():
                    node_stats[node][stat_header] = sum(values)
            elif annot_type == 'freq':
                tally = {}
                for node, values in agg.items():
                    tally[node] = {}
                    for v in values: # count occurences of each unique value
                        if v not in tally[node]:
                            tally[node][v] = 1
                        else:
                            tally[node][v] += 1
                formatFreq(node_stats, stat_header, tally, freq_limits[stat_header])
            elif annot_type == 'label':
                for node, values in agg.items():
                    node_stats[node][stat_header] = ','.join(sorted(set(values)))
            elif annot_type == 'dist':
                for node, values in agg.items(): # calculate the 5 number summary
                    s = sorted( float(x) for x in values)
                    l = len(s)
                    if l > 5:
                        if l%2==0:
                            h = l//2-1 # e.g. 8/2-1 = 3, 4th index 
                            m = (s[h] + s[h+1]) / 2
                            lq = s[:h]
                            uq = s[h:]
                        else:
                            h = l//2 # 9//2 = 4, 5th index
                            m = s[h]
                            lq = s[:h]
                            uq = s[h+1:]
                        qlen = len(lq)
                        if qlen%2==0:
                            q1 = (lq[qlen//2-1] + lq[qlen//2]) / 2
                            q3 = (uq[qlen//2-1] + uq[qlen//2]) / 2
                        else:
                            q1 = lq[qlen//2]
                            q3 = uq[qlen//2]
                        summ = [s[0],q1,m,q3,s[-1]]
                        rounded = auto_round(summ)
                        out = f"{'-'.join( str(x) for x in rounded )}(n={l})"    
                    else:
                        out = ','.join( str(x) for x in auto_round(s) )
                    node_stats[node][stat_header] = out
            elif annot_type == 'lower node':
                for node, values in agg.items():
                    if len(values) > 0:
                        node_stats[node][stat_header] = values[0] # if correct, all values should be the same
                    
    debugOut('Annotations finished.', 'end block')

def formatFreq(node_stats, stat_header, freq, entry_limit): # freq dict to string
    for node, tally in freq.items():
        total = sum(tally.values())


Complete the function implementation replacing "raise NotImplementedError".